From ae1b323732c78be62ba5174122f7eefbe4d06876 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 27 Jun 2026 15:43:56 +0200 Subject: [PATCH] feat: invalidate top-picks on watchlist changes --- internal/watchlist/service.go | 39 ++++++++++++++++++++++-------- internal/watchlist/service_test.go | 35 +++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/internal/watchlist/service.go b/internal/watchlist/service.go index 373bc439..5ebaa003 100644 --- a/internal/watchlist/service.go +++ b/internal/watchlist/service.go @@ -12,24 +12,29 @@ import ( ) type watchlistService struct { - repo domain.WatchlistRepository - jikan *jikan.Client + repo domain.WatchlistRepository + jikan *jikan.Client + invalidator domain.RecommendationInvalidator } -func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) domain.WatchlistService { - return &watchlistService{repo: repo, jikan: jikan} +func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client, invalidator domain.RecommendationInvalidator) domain.WatchlistService { + return &watchlistService{repo: repo, jikan: jikan, invalidator: invalidator} } func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error { - anime, fetchErr := s.jikan.GetAnimeByID(ctx, int(animeID)) + var anime jikan.Anime + var fetchErr error + if s.jikan != nil { + anime, fetchErr = s.jikan.GetAnimeByID(ctx, int(animeID)) + } if fetchErr != nil { // still allow status updates for already-known anime rows anime = jikan.Anime{} } - return s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error { + if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error { _, err := repo.GetAnime(txCtx, animeID) - if err != nil && fetchErr == nil { + if err != nil && fetchErr == nil && anime.MalID > 0 { durationSeconds := anime.DurationSeconds() duration := sql.NullFloat64{Valid: durationSeconds > 0} if duration.Valid { @@ -65,14 +70,28 @@ func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, anime CurrentTimeSeconds: existing.CurrentTimeSeconds, }) return err - }) + }); err != nil { + return err + } + s.invalidateTopPicks(userID) + return nil } func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error { - return s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{ + if err := s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{ UserID: userID, AnimeID: animeID, - }) + }); err != nil { + return err + } + s.invalidateTopPicks(userID) + return nil +} + +func (s *watchlistService) invalidateTopPicks(userID string) { + if s.invalidator != nil { + s.invalidator.InvalidateTopPicksForUser(userID) + } } func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]domain.UserWatchListRow, error) { diff --git a/internal/watchlist/service_test.go b/internal/watchlist/service_test.go index fbaffc03..6212506f 100644 --- a/internal/watchlist/service_test.go +++ b/internal/watchlist/service_test.go @@ -12,7 +12,7 @@ import ( func TestWatchlistServiceGetWatchlistMap(t *testing.T) { repo := &fakeWatchlistRepository{watchlistAnimeIDs: []int64{1, 3}} - svc := NewWatchlistService(repo, nil) + svc := NewWatchlistService(repo, nil, nil) got, err := svc.GetWatchlistMap(context.Background(), "user-1", []int64{1, 2, 3}) if err != nil { @@ -28,7 +28,7 @@ func TestWatchlistServiceGetWatchlistMap(t *testing.T) { func TestWatchlistServiceGetWatchlistMapSkipsEmptyInputs(t *testing.T) { repo := &fakeWatchlistRepository{} - svc := NewWatchlistService(repo, nil) + svc := NewWatchlistService(repo, nil, nil) got, err := svc.GetWatchlistMap(context.Background(), "", []int64{1}) if err != nil { @@ -52,7 +52,7 @@ func TestWatchlistServiceGetWatchlistMapSkipsEmptyInputs(t *testing.T) { func TestWatchlistServiceDeleteContinueWatchingClearsProgressInTransaction(t *testing.T) { repo := &fakeWatchlistRepository{} - svc := NewWatchlistService(repo, nil) + svc := NewWatchlistService(repo, nil, nil) if err := svc.DeleteContinueWatching(context.Background(), "user-1", 12); err != nil { t.Fatalf("DeleteContinueWatching: %v", err) @@ -76,7 +76,7 @@ func TestWatchlistServiceDeleteContinueWatchingClearsProgressInTransaction(t *te func TestWatchlistServiceDeleteContinueWatchingStopsAfterDeleteError(t *testing.T) { repo := &fakeWatchlistRepository{deleteContinueErr: errors.New("delete failed")} - svc := NewWatchlistService(repo, nil) + svc := NewWatchlistService(repo, nil, 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) @@ -88,7 +88,8 @@ func TestWatchlistServiceDeleteContinueWatchingStopsAfterDeleteError(t *testing. func TestWatchlistServiceRemoveEntry(t *testing.T) { repo := &fakeWatchlistRepository{} - svc := NewWatchlistService(repo, nil) + invalidator := &fakeRecommendationInvalidator{} + svc := NewWatchlistService(repo, nil, invalidator) if err := svc.RemoveEntry(context.Background(), "user-1", 9); err != nil { t.Fatalf("RemoveEntry: %v", err) @@ -96,6 +97,22 @@ func TestWatchlistServiceRemoveEntry(t *testing.T) { if repo.deletedWatchlist.UserID != "user-1" || repo.deletedWatchlist.AnimeID != 9 { t.Fatalf("delete params = %#v", repo.deletedWatchlist) } + if invalidator.userID != "user-1" { + t.Fatalf("invalidated user = %q, want user-1", invalidator.userID) + } +} + +func TestWatchlistServiceUpdateEntryInvalidatesRecommendations(t *testing.T) { + repo := &fakeWatchlistRepository{} + invalidator := &fakeRecommendationInvalidator{} + svc := NewWatchlistService(repo, nil, invalidator) + + if err := svc.UpdateEntry(context.Background(), "user-1", 9, "watching"); err != nil { + t.Fatalf("UpdateEntry: %v", err) + } + if invalidator.userID != "user-1" { + t.Fatalf("invalidated user = %q, want user-1", invalidator.userID) + } } type fakeWatchlistRepository struct { @@ -110,6 +127,14 @@ type fakeWatchlistRepository struct { deletedWatchlist db.DeleteWatchListEntryParams } +type fakeRecommendationInvalidator struct { + userID string +} + +func (i *fakeRecommendationInvalidator) InvalidateTopPicksForUser(userID string) { + i.userID = userID +} + func (r *fakeWatchlistRepository) InTx(ctx context.Context, fn func(context.Context, domain.WatchlistRepository) error) error { r.inTxCalled = true return fn(ctx, r)