package playback import ( "context" "database/sql" "errors" "strings" "testing" "mal/internal/db" "mal/internal/domain" ) func TestSaveProgressCreatesMinimalAnimeAndContinueWatchingEntry(t *testing.T) { repo := &fakePlaybackRepository{getAnimeErr: sql.ErrNoRows} auditSvc := &fakePlaybackAuditService{} svc := &playbackService{repo: repo, auditSvc: auditSvc} if err := svc.SaveProgress(context.Background(), "user-1", 12, 3, 45.5); err != nil { t.Fatalf("SaveProgress: %v", err) } assertSaveProgressCalls(t, repo, auditSvc) } func TestSaveProgressDoesNotUpsertAnimeWhenExistingRowFound(t *testing.T) { repo := &fakePlaybackRepository{} svc := &playbackService{repo: repo, auditSvc: &fakePlaybackAuditService{}} if err := svc.SaveProgress(context.Background(), "user-1", 12, 3, 45.5); err != nil { t.Fatalf("SaveProgress: %v", err) } if repo.upsertAnimeCalled { t.Fatalf("did not expect anime upsert for existing row") } } func TestSaveProgressWrapsContinueWatchingErrors(t *testing.T) { repo := &fakePlaybackRepository{upsertContinueErr: errors.New("insert failed")} svc := &playbackService{repo: repo, auditSvc: &fakePlaybackAuditService{}} err := svc.SaveProgress(context.Background(), "user-1", 12, 3, 45.5) if err == nil || !strings.Contains(err.Error(), "save progress transaction user_id=user-1 anime_id=12 episode=3") { t.Fatalf("SaveProgress error = %v, want wrapped transaction error", err) } } func assertSaveProgressCalls(t *testing.T, repo *fakePlaybackRepository, auditSvc *fakePlaybackAuditService) { t.Helper() if !repo.inTxCalled { t.Fatalf("expected transaction") } assertSaveProgressRepo(t, repo) assertSaveProgressAudit(t, auditSvc) } func assertSaveProgressRepo(t *testing.T, repo *fakePlaybackRepository) { t.Helper() if repo.upsertedAnime.ID != 12 || repo.upsertedAnime.TitleOriginal != "Anime 12" { t.Fatalf("upserted anime = %#v, want minimal anime 12", repo.upsertedAnime) } if repo.upsertedContinue.UserID != "user-1" || repo.upsertedContinue.AnimeID != 12 { t.Fatalf("continue watching params = %#v", repo.upsertedContinue) } if !repo.upsertedContinue.CurrentEpisode.Valid || repo.upsertedContinue.CurrentEpisode.Int64 != 3 { t.Fatalf("current episode = %#v, want 3", repo.upsertedContinue.CurrentEpisode) } if repo.upsertedContinue.CurrentTimeSeconds != 45.5 { t.Fatalf("current time = %f, want 45.5", repo.upsertedContinue.CurrentTimeSeconds) } } func assertSaveProgressAudit(t *testing.T, auditSvc *fakePlaybackAuditService) { t.Helper() if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "watch_progress_saved" { t.Fatalf("audit events = %#v, want watch_progress_saved", auditSvc.events) } if !strings.Contains(string(auditSvc.events[0].MetadataJSON), `"episode":3`) { t.Fatalf("audit metadata = %s, want episode", auditSvc.events[0].MetadataJSON) } } func TestCompleteAnimeUpsertsCompletedStatusAndAudits(t *testing.T) { repo := &fakePlaybackRepository{watchlistErr: sql.ErrNoRows} auditSvc := &fakePlaybackAuditService{} svc := &playbackService{repo: repo, auditSvc: auditSvc} if err := svc.CompleteAnime(context.Background(), "user-1", 12); err != nil { t.Fatalf("CompleteAnime: %v", err) } if repo.upsertedWatchlist.UserID != "user-1" || repo.upsertedWatchlist.AnimeID != 12 || repo.upsertedWatchlist.Status != "completed" { t.Fatalf("upserted watchlist = %#v, want completed", repo.upsertedWatchlist) } if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "watch_completed" || auditSvc.events[0].ResourceID != "12" { t.Fatalf("audit events = %#v, want watch_completed for 12", auditSvc.events) } } func TestCompleteAnimeSkipsUpsertWhenAlreadyCompleted(t *testing.T) { repo := &fakePlaybackRepository{watchlistEntry: db.WatchListEntry{UserID: "user-1", AnimeID: 12, Status: "completed"}} svc := &playbackService{repo: repo, auditSvc: &fakePlaybackAuditService{}} if err := svc.CompleteAnime(context.Background(), "user-1", 12); err != nil { t.Fatalf("CompleteAnime: %v", err) } if repo.upsertWatchlistCalled { t.Fatalf("did not expect completed watchlist upsert") } } func TestLoadWatchProgressPrefersWatchlistProgressForRequestedEpisode(t *testing.T) { repo := &fakePlaybackRepository{ watchlistEntry: db.WatchListEntry{ UserID: "user-1", AnimeID: 12, Status: "watching", CurrentEpisode: sql.NullInt64{Int64: 2, Valid: true}, CurrentTimeSeconds: 33, }, continueEntry: db.ContinueWatchingEntry{ UserID: "user-1", AnimeID: 12, CurrentEpisode: sql.NullInt64{Int64: 2, Valid: true}, CurrentTimeSeconds: 99, }, } svc := &playbackService{repo: repo} startTime, status, ids := svc.loadWatchProgress(context.Background(), "user-1", 12, 12, "2") if startTime != 33 || status != "watching" || len(ids) != 1 || ids[0] != 12 { t.Fatalf("progress = start:%f status:%q ids:%v, want watchlist progress", startTime, status, ids) } } func TestLoadWatchProgressFallsBackToContinueWatching(t *testing.T) { repo := &fakePlaybackRepository{ watchlistEntry: db.WatchListEntry{ UserID: "user-1", AnimeID: 12, Status: "watching", CurrentEpisode: sql.NullInt64{Int64: 1, Valid: true}, CurrentTimeSeconds: 33, }, continueEntry: db.ContinueWatchingEntry{ UserID: "user-1", AnimeID: 12, CurrentEpisode: sql.NullInt64{Int64: 2, Valid: true}, CurrentTimeSeconds: 99, }, } svc := &playbackService{repo: repo} startTime, status, ids := svc.loadWatchProgress(context.Background(), "user-1", 12, 12, "2") if startTime != 99 || status != "watching" || len(ids) != 1 || ids[0] != 12 { t.Fatalf("progress = start:%f status:%q ids:%v, want continue watching progress", startTime, status, ids) } } func TestPlaybackServiceProxyTokenWrappers(t *testing.T) { svc := &playbackService{proxyTokenKey: "secret", proxyTokens: newProxyTokenStore()} token, err := svc.SignProxyToken("https://cdn.example.test/seg.ts", "https://referer.example.test", "stream") if err != nil { t.Fatalf("SignProxyToken: %v", err) } targetURL, referer, err := svc.ResolveProxyToken(token, "stream") if err != nil { t.Fatalf("ResolveProxyToken: %v", err) } if targetURL != "https://cdn.example.test/seg.ts" || referer != "https://referer.example.test" { t.Fatalf("resolved target=%q referer=%q", targetURL, referer) } if _, _, err := svc.ResolveProxyToken(token, "subtitle"); err == nil || !strings.Contains(err.Error(), "invalid proxy token scope") { t.Fatalf("wrong scope error = %v, want invalid scope", err) } } func TestPlaybackServiceProxyTokenDisabled(t *testing.T) { svc := &playbackService{proxyTokens: newProxyTokenStore()} token, err := svc.SignProxyToken("https://cdn.example.test/seg.ts", "", "stream") if err != nil { t.Fatalf("SignProxyToken: %v", err) } if token != "" { t.Fatalf("token = %q, want empty when signing disabled", token) } if _, _, err := svc.ResolveProxyToken("token", "stream"); err == nil || !strings.Contains(err.Error(), "proxy token key not configured") { t.Fatalf("ResolveProxyToken error = %v, want missing key", err) } } type fakePlaybackRepository struct { inTxCalled bool getAnimeErr error upsertAnimeCalled bool upsertContinueErr error watchlistErr error upsertWatchlistCalled bool upsertedAnime db.UpsertAnimeParams upsertedContinue db.UpsertContinueWatchingEntryParams upsertedWatchlist db.UpsertWatchListEntryParams watchlistEntry db.WatchListEntry continueEntry db.ContinueWatchingEntry } func (r *fakePlaybackRepository) InTx(ctx context.Context, fn func(context.Context, domain.PlaybackRepository) error) error { r.inTxCalled = true return fn(ctx, r) } func (r *fakePlaybackRepository) UpsertAnime(_ context.Context, params db.UpsertAnimeParams) (db.Anime, error) { r.upsertAnimeCalled = true r.upsertedAnime = params return db.Anime{ID: params.ID, TitleOriginal: params.TitleOriginal}, nil } func (r *fakePlaybackRepository) GetAnime(context.Context, int64) (db.Anime, error) { if r.getAnimeErr != nil { return db.Anime{}, r.getAnimeErr } return db.Anime{ID: 12, TitleOriginal: "Anime 12"}, nil } func (r *fakePlaybackRepository) GetWatchListEntry(context.Context, db.GetWatchListEntryParams) (db.WatchListEntry, error) { if r.watchlistErr != nil { return db.WatchListEntry{}, r.watchlistErr } return r.watchlistEntry, nil } func (r *fakePlaybackRepository) GetContinueWatchingEntry(context.Context, db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { return r.continueEntry, nil } func (r *fakePlaybackRepository) SaveWatchProgress(context.Context, db.SaveWatchProgressParams) error { return nil } func (r *fakePlaybackRepository) UpsertWatchListEntry(_ context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { r.upsertWatchlistCalled = true r.upsertedWatchlist = params return db.WatchListEntry{ID: params.ID, UserID: params.UserID, AnimeID: params.AnimeID, Status: params.Status}, nil } func (r *fakePlaybackRepository) UpsertContinueWatchingEntry(_ context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { r.upsertedContinue = params if r.upsertContinueErr != nil { return db.ContinueWatchingEntry{}, r.upsertContinueErr } return db.ContinueWatchingEntry{ID: params.ID, UserID: params.UserID, AnimeID: params.AnimeID}, nil } func (r *fakePlaybackRepository) DeleteContinueWatchingEntry(context.Context, db.DeleteContinueWatchingEntryParams) error { return nil } func (r *fakePlaybackRepository) ListSkipSegmentOverrides(context.Context, string, int64, int64) ([]db.SkipSegmentOverrideRow, error) { return nil, nil } func (r *fakePlaybackRepository) UpsertSkipSegmentOverride(context.Context, db.SkipSegmentOverrideRow) error { return nil } func (r *fakePlaybackRepository) HasSkipSegmentOverrideTable(context.Context) (bool, error) { return true, nil } type fakePlaybackAuditService struct { events []domain.AuditEvent } func (s *fakePlaybackAuditService) Record(_ context.Context, event domain.AuditEvent) error { s.events = append(s.events, event) return nil }