test: add watchlist handler and service unit tests
This commit is contained in:
172
internal/watchlist/handler_test.go
Normal file
172
internal/watchlist/handler_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
162
internal/watchlist/service_test.go
Normal file
162
internal/watchlist/service_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user