From 6040e3254ee3987c8b08d29c50fb451e4e7b3f65 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 24 Jun 2026 16:08:50 +0200 Subject: [PATCH] test: add watchlist handler and service unit tests --- internal/watchlist/handler_test.go | 172 +++++++++++++++++++++++++++++ internal/watchlist/service_test.go | 162 +++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 internal/watchlist/handler_test.go create mode 100644 internal/watchlist/service_test.go diff --git a/internal/watchlist/handler_test.go b/internal/watchlist/handler_test.go new file mode 100644 index 0000000..7133575 --- /dev/null +++ b/internal/watchlist/handler_test.go @@ -0,0 +1,172 @@ +package watchlist + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "mal/internal/db" + "mal/internal/domain" + + "github.com/gin-gonic/gin" +) + +func TestHandleUpdateWatchlist(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &fakeWatchlistService{} + router := newWatchlistTestRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watchlist", strings.NewReader(`{"animeId":1,"status":"watching"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if svc.updatedUserID != "user-1" || svc.updatedAnimeID != 1 || svc.updatedStatus != "watching" { + t.Fatalf("update args user=%q anime=%d status=%q", svc.updatedUserID, svc.updatedAnimeID, svc.updatedStatus) + } +} + +func TestHandleUpdateWatchlistRejectsInvalidBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := newWatchlistTestRouter(&fakeWatchlistService{}) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watchlist", strings.NewReader(`{"animeId":0,"status":""}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleUpdateWatchlistReportsServiceError(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := newWatchlistTestRouter(&fakeWatchlistService{updateErr: errors.New("boom")}) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watchlist", strings.NewReader(`{"animeId":1,"status":"watching"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func TestHandleDeleteWatchlist(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &fakeWatchlistService{} + router := newWatchlistTestRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodDelete, "/api/watchlist/12", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if svc.removedUserID != "user-1" || svc.removedAnimeID != 12 { + t.Fatalf("remove args user=%q anime=%d", svc.removedUserID, svc.removedAnimeID) + } +} + +func TestHandleDeleteWatchlistRejectsInvalidID(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := newWatchlistTestRouter(&fakeWatchlistService{}) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodDelete, "/api/watchlist/nope", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleDeleteContinueWatching(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &fakeWatchlistService{} + router := newWatchlistTestRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodDelete, "/api/continue-watching/44", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if svc.deletedContinueUserID != "user-1" || svc.deletedContinueAnimeID != 44 { + t.Fatalf("delete continue args user=%q anime=%d", svc.deletedContinueUserID, svc.deletedContinueAnimeID) + } +} + +func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("User", &domain.User{User: db.User{ID: "user-1", Username: "alice"}}) + c.Next() + }) + NewWatchlistHandler(svc).Register(router) + return router +} + +type fakeWatchlistService struct { + updateErr error + removeErr error + deleteContinueErr error + + updatedUserID string + updatedAnimeID int64 + updatedStatus string + + removedUserID string + removedAnimeID int64 + + deletedContinueUserID string + deletedContinueAnimeID int64 +} + +func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error { + s.updatedUserID = userID + s.updatedAnimeID = animeID + s.updatedStatus = status + return s.updateErr +} + +func (s *fakeWatchlistService) RemoveEntry(_ context.Context, userID string, animeID int64) error { + s.removedUserID = userID + s.removedAnimeID = animeID + return s.removeErr +} + +func (s *fakeWatchlistService) GetWatchlist(context.Context, string) ([]domain.UserWatchListRow, error) { + return nil, nil +} + +func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) { + return nil, nil +} + +func (s *fakeWatchlistService) GetWatchListEntry(context.Context, string, int64) (domain.WatchlistEntry, error) { + return db.WatchListEntry{}, nil +} + +func (s *fakeWatchlistService) GetContinueWatchingEntry(context.Context, string, int64) (db.ContinueWatchingEntry, error) { + return db.ContinueWatchingEntry{}, nil +} + +func (s *fakeWatchlistService) DeleteContinueWatching(_ context.Context, userID string, animeID int64) error { + s.deletedContinueUserID = userID + s.deletedContinueAnimeID = animeID + return s.deleteContinueErr +} diff --git a/internal/watchlist/service_test.go b/internal/watchlist/service_test.go new file mode 100644 index 0000000..fbaffc0 --- /dev/null +++ b/internal/watchlist/service_test.go @@ -0,0 +1,162 @@ +package watchlist + +import ( + "context" + "database/sql" + "errors" + "testing" + + "mal/internal/db" + "mal/internal/domain" +) + +func TestWatchlistServiceGetWatchlistMap(t *testing.T) { + repo := &fakeWatchlistRepository{watchlistAnimeIDs: []int64{1, 3}} + svc := NewWatchlistService(repo, nil) + + got, err := svc.GetWatchlistMap(context.Background(), "user-1", []int64{1, 2, 3}) + if err != nil { + t.Fatalf("GetWatchlistMap: %v", err) + } + if !got[1] || got[2] || !got[3] { + t.Fatalf("watchlist map = %#v, want 1 and 3 only", got) + } + if repo.watchlistMapUserID != "user-1" { + t.Fatalf("repo user id = %q, want user-1", repo.watchlistMapUserID) + } +} + +func TestWatchlistServiceGetWatchlistMapSkipsEmptyInputs(t *testing.T) { + repo := &fakeWatchlistRepository{} + svc := NewWatchlistService(repo, nil) + + got, err := svc.GetWatchlistMap(context.Background(), "", []int64{1}) + if err != nil { + t.Fatalf("GetWatchlistMap empty user: %v", err) + } + if len(got) != 0 { + t.Fatalf("empty user map = %#v, want empty", got) + } + if repo.watchlistMapCalled { + t.Fatalf("repo should not be called for empty user") + } + + got, err = svc.GetWatchlistMap(context.Background(), "user-1", nil) + if err != nil { + t.Fatalf("GetWatchlistMap empty ids: %v", err) + } + if len(got) != 0 { + t.Fatalf("empty ids map = %#v, want empty", got) + } +} + +func TestWatchlistServiceDeleteContinueWatchingClearsProgressInTransaction(t *testing.T) { + repo := &fakeWatchlistRepository{} + svc := NewWatchlistService(repo, nil) + + if err := svc.DeleteContinueWatching(context.Background(), "user-1", 12); err != nil { + t.Fatalf("DeleteContinueWatching: %v", err) + } + if !repo.inTxCalled { + t.Fatalf("expected transaction") + } + if repo.deletedContinue.UserID != "user-1" || repo.deletedContinue.AnimeID != 12 { + t.Fatalf("deleted continue params = %#v", repo.deletedContinue) + } + if repo.savedProgress.UserID != "user-1" || repo.savedProgress.AnimeID != 12 { + t.Fatalf("saved progress params = %#v", repo.savedProgress) + } + if repo.savedProgress.CurrentEpisode.Valid { + t.Fatalf("current episode should be cleared") + } + if repo.savedProgress.CurrentTimeSeconds != 0 { + t.Fatalf("current time = %f, want 0", repo.savedProgress.CurrentTimeSeconds) + } +} + +func TestWatchlistServiceDeleteContinueWatchingStopsAfterDeleteError(t *testing.T) { + repo := &fakeWatchlistRepository{deleteContinueErr: errors.New("delete failed")} + svc := NewWatchlistService(repo, nil) + + if err := svc.DeleteContinueWatching(context.Background(), "user-1", 12); err == nil || err.Error() != "delete failed" { + t.Fatalf("DeleteContinueWatching error = %v, want delete failed", err) + } + if repo.saveProgressCalled { + t.Fatalf("SaveWatchProgress should not run after delete error") + } +} + +func TestWatchlistServiceRemoveEntry(t *testing.T) { + repo := &fakeWatchlistRepository{} + svc := NewWatchlistService(repo, nil) + + if err := svc.RemoveEntry(context.Background(), "user-1", 9); err != nil { + t.Fatalf("RemoveEntry: %v", err) + } + if repo.deletedWatchlist.UserID != "user-1" || repo.deletedWatchlist.AnimeID != 9 { + t.Fatalf("delete params = %#v", repo.deletedWatchlist) + } +} + +type fakeWatchlistRepository struct { + watchlistAnimeIDs []int64 + watchlistMapUserID string + watchlistMapCalled bool + inTxCalled bool + saveProgressCalled bool + deleteContinueErr error + deletedContinue db.DeleteContinueWatchingEntryParams + savedProgress db.SaveWatchProgressParams + deletedWatchlist db.DeleteWatchListEntryParams +} + +func (r *fakeWatchlistRepository) InTx(ctx context.Context, fn func(context.Context, domain.WatchlistRepository) error) error { + r.inTxCalled = true + return fn(ctx, r) +} + +func (r *fakeWatchlistRepository) UpsertAnime(context.Context, db.UpsertAnimeParams) (db.Anime, error) { + return db.Anime{}, nil +} + +func (r *fakeWatchlistRepository) GetAnime(context.Context, int64) (db.Anime, error) { + return db.Anime{}, sql.ErrNoRows +} + +func (r *fakeWatchlistRepository) UpsertWatchListEntry(_ context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { + return db.WatchListEntry{ID: arg.ID, UserID: arg.UserID, AnimeID: arg.AnimeID, Status: arg.Status}, nil +} + +func (r *fakeWatchlistRepository) DeleteWatchListEntry(_ context.Context, arg db.DeleteWatchListEntryParams) error { + r.deletedWatchlist = arg + return nil +} + +func (r *fakeWatchlistRepository) GetUserWatchList(context.Context, string) ([]db.GetUserWatchListRow, error) { + return nil, nil +} + +func (r *fakeWatchlistRepository) GetUserWatchlistAnimeIDs(_ context.Context, userID string, _ []int64) ([]int64, error) { + r.watchlistMapCalled = true + r.watchlistMapUserID = userID + return r.watchlistAnimeIDs, nil +} + +func (r *fakeWatchlistRepository) GetWatchListEntry(context.Context, db.GetWatchListEntryParams) (db.WatchListEntry, error) { + return db.WatchListEntry{}, sql.ErrNoRows +} + +func (r *fakeWatchlistRepository) GetContinueWatchingEntry(context.Context, db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { + return db.ContinueWatchingEntry{}, nil +} + +func (r *fakeWatchlistRepository) DeleteContinueWatchingEntry(_ context.Context, arg db.DeleteContinueWatchingEntryParams) error { + r.deletedContinue = arg + return r.deleteContinueErr +} + +func (r *fakeWatchlistRepository) SaveWatchProgress(_ context.Context, arg db.SaveWatchProgressParams) error { + r.saveProgressCalled = true + r.savedProgress = arg + return nil +}