396 lines
14 KiB
Go
396 lines
14 KiB
Go
package watchlist
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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 TestHandleGetPublicWatchlistReturnsMinimalJSON(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC)
|
|
completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC)
|
|
lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC)
|
|
svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)}
|
|
router := newWatchlistTestRouter(svc)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
|
|
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 got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
|
t.Fatalf("Content-Type = %q, want application/json", got)
|
|
}
|
|
if svc.watchlistUserID != "user-42" {
|
|
t.Fatalf("GetWatchlist user id = %q, want user-42", svc.watchlistUserID)
|
|
}
|
|
|
|
var body publicWatchlistResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
|
|
}
|
|
|
|
assertPublicWatchlistEnvelope(t, body)
|
|
if len(body.Entries) != 2 {
|
|
t.Fatalf("entries = %d, want 2", len(body.Entries))
|
|
}
|
|
assertCompletedPublicEntry(t, body.Entries[0], addedAt, completedAt)
|
|
assertWatchingPublicEntry(t, body.Entries[1], lastWatchedAt)
|
|
|
|
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
|
|
}
|
|
|
|
func TestHandleDownloadWatchlistReturnsAttachment(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC)
|
|
completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC)
|
|
lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC)
|
|
svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)}
|
|
router := newWatchlistTestRouter(svc)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watchlist/export", nil)
|
|
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 got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist.json"` {
|
|
t.Fatalf("Content-Disposition = %q, want watchlist.json attachment", got)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
|
t.Fatalf("Content-Type = %q, want application/json", got)
|
|
}
|
|
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
|
|
}
|
|
|
|
func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow {
|
|
return []domain.UserWatchListRow{
|
|
{
|
|
AnimeID: 1,
|
|
Status: "completed",
|
|
CreatedAt: addedAt,
|
|
UpdatedAt: completedAt.Add(48 * time.Hour),
|
|
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
|
|
TitleOriginal: "Sousou no Frieren",
|
|
TitleEnglish: sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
|
|
TitleJapanese: sql.NullString{String: "葬送のフリーレン", Valid: true},
|
|
ImageUrl: "https://cdn.example/frieren.webp",
|
|
Airing: sql.NullBool{Bool: false, Valid: true},
|
|
},
|
|
{
|
|
AnimeID: 2,
|
|
Status: "watching",
|
|
CreatedAt: addedAt.Add(24 * time.Hour),
|
|
CurrentEpisode: sql.NullInt64{Int64: 3, Valid: true},
|
|
CurrentTimeSeconds: 10,
|
|
PlaybackCurrentEpisode: sql.NullInt64{Int64: 7, Valid: true},
|
|
PlaybackCurrentTimeSeconds: sql.NullFloat64{Float64: 321.5, Valid: true},
|
|
PlaybackUpdatedAt: sql.NullTime{Time: lastWatchedAt, Valid: true},
|
|
TitleOriginal: "Dungeon Meshi",
|
|
ImageUrl: "https://cdn.example/dungeon.webp",
|
|
},
|
|
}
|
|
}
|
|
|
|
type publicWatchlistResponse struct {
|
|
SchemaVersion string `json:"schema_version"`
|
|
UserID string `json:"user_id"`
|
|
Summary struct {
|
|
Total int `json:"total"`
|
|
Completed int `json:"completed"`
|
|
Watching int `json:"watching"`
|
|
} `json:"summary"`
|
|
Entries []publicWatchlistResponseEntry `json:"entries"`
|
|
}
|
|
|
|
type publicWatchlistResponseEntry struct {
|
|
MALID int64 `json:"mal_id"`
|
|
Status string `json:"status"`
|
|
AddedAt string `json:"added_at"`
|
|
CompletedAt *string `json:"completed_at"`
|
|
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
|
|
LastWatchedAt *string `json:"last_watched_at"`
|
|
Progress struct {
|
|
CurrentEpisode *int64 `json:"current_episode"`
|
|
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
|
|
} `json:"progress"`
|
|
Titles struct {
|
|
Preferred string `json:"preferred"`
|
|
Original string `json:"original"`
|
|
English *string `json:"english"`
|
|
Japanese *string `json:"japanese"`
|
|
} `json:"titles"`
|
|
}
|
|
|
|
func assertPublicWatchlistEnvelope(t *testing.T, body publicWatchlistResponse) {
|
|
t.Helper()
|
|
assertEqual(t, "schema version", body.SchemaVersion, "1.0")
|
|
assertEqual(t, "user id", body.UserID, "user-42")
|
|
assertEqual(t, "summary total", body.Summary.Total, 2)
|
|
assertEqual(t, "summary completed", body.Summary.Completed, 1)
|
|
assertEqual(t, "summary watching", body.Summary.Watching, 1)
|
|
}
|
|
|
|
func assertCompletedPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, addedAt time.Time, completedAt time.Time) {
|
|
t.Helper()
|
|
assertEqual(t, "completed MAL id", entry.MALID, int64(1))
|
|
assertEqual(t, "completed status", entry.Status, "completed")
|
|
assertEqual(t, "added_at", entry.AddedAt, addedAt.Format(time.RFC3339))
|
|
assertPointerEqual(t, "completed_at", entry.CompletedAt, completedAt.Format(time.RFC3339))
|
|
assertPointerEqual(t, "completion date accuracy", entry.CompletionDateAccuracy, "exact")
|
|
assertEqual(t, "preferred title", entry.Titles.Preferred, "Frieren: Beyond Journey's End")
|
|
assertEqual(t, "original title", entry.Titles.Original, "Sousou no Frieren")
|
|
}
|
|
|
|
func assertWatchingPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, lastWatchedAt time.Time) {
|
|
t.Helper()
|
|
assertPointerEqual(t, "watching progress episode", entry.Progress.CurrentEpisode, int64(7))
|
|
assertPointerEqual(t, "watching progress seconds", entry.Progress.CurrentTimeSeconds, 321.5)
|
|
assertPointerEqual(t, "last_watched_at", entry.LastWatchedAt, lastWatchedAt.Format(time.RFC3339))
|
|
assertNilPointer(t, "watching completed_at", entry.CompletedAt)
|
|
assertNilPointer(t, "watching completion date accuracy", entry.CompletionDateAccuracy)
|
|
}
|
|
|
|
func assertEqual[T comparable](t *testing.T, label string, got T, want T) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Fatalf("%s = %v, want %v", label, got, want)
|
|
}
|
|
}
|
|
|
|
func assertPointerEqual[T comparable](t *testing.T, label string, got *T, want T) {
|
|
t.Helper()
|
|
if got == nil || *got != want {
|
|
t.Fatalf("%s = %v, want %v", label, got, want)
|
|
}
|
|
}
|
|
|
|
func assertNilPointer[T any](t *testing.T, label string, got *T) {
|
|
t.Helper()
|
|
if got != nil {
|
|
t.Fatalf("%s = %v, want null", label, got)
|
|
}
|
|
}
|
|
|
|
func assertMinimalPublicWatchlistJSON(t *testing.T, data []byte) {
|
|
t.Helper()
|
|
var raw struct {
|
|
RecommendationGuidance json.RawMessage `json:"recommendation_guidance"`
|
|
Entries []map[string]json.RawMessage `json:"entries"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatalf("decode raw response: %v", err)
|
|
}
|
|
if raw.RecommendationGuidance != nil {
|
|
t.Fatalf("recommendation_guidance should not be part of the public contract")
|
|
}
|
|
for _, key := range []string{
|
|
"updated_at",
|
|
"status_meaning",
|
|
"myanimelist_status",
|
|
"anilist_status",
|
|
"exclude_from_recommendations",
|
|
"recommendation_exclusion_reason",
|
|
"anime",
|
|
"external_ids",
|
|
"links",
|
|
} {
|
|
if _, exists := raw.Entries[0][key]; exists {
|
|
t.Fatalf("%s should not be part of the public contract", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleGetPublicWatchlistCanDownloadJSON(t *testing.T) {
|
|
router := newWatchlistTestRouter(&fakeWatchlistService{})
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist?download=1", nil)
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist-user-42.json"` {
|
|
t.Fatalf("Content-Disposition = %q", got)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
watchlist []domain.UserWatchListRow
|
|
watchlistUserID string
|
|
watchlistErr error
|
|
}
|
|
|
|
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, userID string) ([]domain.UserWatchListRow, error) {
|
|
s.watchlistUserID = userID
|
|
return s.watchlist, s.watchlistErr
|
|
}
|
|
|
|
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
|
|
}
|