test: add playback progress service tests for save, complete and load

This commit is contained in:
2026-06-24 16:11:36 +02:00
committed by Milas Holsting
parent 0c413782e6
commit 9026f96b04

View File

@@ -0,0 +1,277 @@
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
}