From 9b46396f323f8e0b2c91424d052ff82c02d38e7f Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 10 Apr 2026 17:26:49 +0200 Subject: [PATCH] ci: add quality checks and smoke tests --- .github/workflows/ci.yml | 36 ++++++ internal/features/watchlist/service_test.go | 125 ++++++++++++++++++++ internal/shared/middleware/auth_test.go | 66 +++++++++++ 3 files changed, 227 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 internal/features/watchlist/service_test.go create mode 100644 internal/shared/middleware/auth_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39185e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: verify dependencies + run: go mod download + + - name: vet + run: go vet ./... + + - name: test + run: go test ./... + + - name: build + run: go build ./... + + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... diff --git a/internal/features/watchlist/service_test.go b/internal/features/watchlist/service_test.go new file mode 100644 index 0000000..faf5f3e --- /dev/null +++ b/internal/features/watchlist/service_test.go @@ -0,0 +1,125 @@ +package watchlist + +import ( + "context" + "database/sql" + "testing" + "time" + + "mal/internal/database" +) + +type fakeQuerier struct { + database.Querier + upsertAnimeCalled bool + upsertEntryCalled bool + addRows []database.GetUserWatchListRow +} + +func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg database.UpsertAnimeParams) (database.Anime, error) { + f.upsertAnimeCalled = true + return database.Anime{}, nil +} + +func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg database.UpsertWatchListEntryParams) (database.WatchListEntry, error) { + f.upsertEntryCalled = true + return database.WatchListEntry{}, nil +} + +func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]database.GetUserWatchListRow, error) { + return f.addRows, nil +} + +func TestAddEntry_RejectsInvalidAnimeID(t *testing.T) { + t.Parallel() + + q := &fakeQuerier{} + svc := NewService(q) + + err := svc.AddEntry(context.Background(), "user-1", AddRequest{ + AnimeID: 0, + Status: "watching", + }) + + if err != ErrInvalidAnimeID { + t.Fatalf("expected ErrInvalidAnimeID, got %v", err) + } + + if q.upsertAnimeCalled || q.upsertEntryCalled { + t.Fatal("expected no database writes for invalid anime id") + } +} + +func TestAddEntry_RejectsInvalidStatus(t *testing.T) { + t.Parallel() + + q := &fakeQuerier{} + svc := NewService(q) + + err := svc.AddEntry(context.Background(), "user-1", AddRequest{ + AnimeID: 1, + Status: "invalid", + }) + + if err != ErrInvalidStatus { + t.Fatalf("expected ErrInvalidStatus, got %v", err) + } + + if q.upsertAnimeCalled || q.upsertEntryCalled { + t.Fatal("expected no database writes for invalid status") + } +} + +func TestExport_UsesDisplayTitleFallbackOrder(t *testing.T) { + t.Parallel() + + q := &fakeQuerier{ + addRows: []database.GetUserWatchListRow{ + { + AnimeID: 101, + TitleOriginal: "Original", + TitleEnglish: sql.NullString{String: "English", Valid: true}, + Status: "watching", + ImageUrl: "https://img", + UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }, + { + AnimeID: 102, + TitleOriginal: "Original 2", + TitleJapanese: sql.NullString{String: "JP Title", Valid: true}, + Status: "completed", + ImageUrl: "https://img2", + UpdatedAt: time.Date(2026, 1, 3, 3, 4, 5, 0, time.UTC), + }, + { + AnimeID: 103, + TitleOriginal: "Original 3", + Status: "on_hold", + ImageUrl: "https://img3", + UpdatedAt: time.Date(2026, 1, 4, 3, 4, 5, 0, time.UTC), + }, + }, + } + + svc := NewService(q) + export, err := svc.Export(context.Background(), "user-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(export.Entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(export.Entries)) + } + + if export.Entries[0].Title != "English" { + t.Fatalf("expected english title first, got %q", export.Entries[0].Title) + } + + if export.Entries[1].Title != "JP Title" { + t.Fatalf("expected japanese title fallback, got %q", export.Entries[1].Title) + } + + if export.Entries[2].Title != "Original 3" { + t.Fatalf("expected original title fallback, got %q", export.Entries[2].Title) + } +} diff --git a/internal/shared/middleware/auth_test.go b/internal/shared/middleware/auth_test.go new file mode 100644 index 0000000..ad67f8e --- /dev/null +++ b/internal/shared/middleware/auth_test.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "mal/internal/database" +) + +func TestRequireAuth_UnauthenticatedAPIRequest(t *testing.T) { + t.Parallel() + + h := RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/watchlist", nil) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + + if got := rec.Header().Get("HX-Redirect"); got != "/login" { + t.Fatalf("expected HX-Redirect /login, got %q", got) + } +} + +func TestRequireAuth_AuthenticatedRequestPassesThrough(t *testing.T) { + t.Parallel() + + h := RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/watchlist", nil) + ctx := context.WithValue(req.Context(), UserContextKey, &database.User{ID: "user-1"}) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } +} + +func TestRequireGlobalAuth_AllowsPublicRoute(t *testing.T) { + t.Parallel() + + h := RequireGlobalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +}