diff --git a/api/watchlist/service.go b/api/watchlist/service.go index 7875baa..a9f29ce 100644 --- a/api/watchlist/service.go +++ b/api/watchlist/service.go @@ -3,8 +3,11 @@ package watchlist import ( "context" "database/sql" + "encoding/csv" "errors" "fmt" + "io" + "strconv" "strings" "github.com/google/uuid" @@ -186,3 +189,53 @@ func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, ani return tx.Commit() } + +func (s *Service) ImportWatchlist(ctx context.Context, userID string, r io.Reader) error { + reader := csv.NewReader(r) + // Read header + if _, err := reader.Read(); err != nil { + return fmt.Errorf("failed to read csv header: %w", err) + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read csv records: %w", err) + } + + for i, record := range records { + if len(record) < 4 { + continue // Skip malformed rows + } + + animeID, err := strconv.ParseInt(record[0], 10, 64) + if err != nil { + return fmt.Errorf("row %d: invalid anime id: %w", i+1, err) + } + + status := record[1] + if _, ok := validStatuses[status]; !ok { + status = "plan_to_watch" + } + + currentEpisode, _ := strconv.ParseInt(record[2], 10, 64) + currentTimeSeconds, _ := strconv.ParseFloat(record[3], 64) + + if err := s.ensureAnimeExists(ctx, animeID); err != nil { + return fmt.Errorf("row %d: failed to ensure anime: %w", i+1, err) + } + + _, err = s.db.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + ID: uuid.New().String(), + UserID: userID, + AnimeID: animeID, + Status: status, + CurrentEpisode: sql.NullInt64{Int64: currentEpisode, Valid: currentEpisode > 0}, + CurrentTimeSeconds: currentTimeSeconds, + }) + if err != nil { + return fmt.Errorf("row %d: failed to upsert entry: %w", i+1, err) + } + } + + return nil +} diff --git a/api/watchlist/service_test.go b/api/watchlist/service_test.go index b4b7cfa..093a52b 100644 --- a/api/watchlist/service_test.go +++ b/api/watchlist/service_test.go @@ -2,6 +2,7 @@ package watchlist import ( "context" + "strings" "testing" "mal/internal/db" @@ -11,7 +12,15 @@ type fakeQuerier struct { db.Querier upsertAnimeCalled bool upsertEntryCalled bool - addRows []db.GetUserWatchListRow + upsertEntryParams db.UpsertWatchListEntryParams + getAnimeFunc func(ctx context.Context, id int64) (db.Anime, error) +} + +func (f *fakeQuerier) GetAnime(ctx context.Context, id int64) (db.Anime, error) { + if f.getAnimeFunc != nil { + return f.getAnimeFunc(ctx, id) + } + return db.Anime{}, nil } func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) { @@ -21,11 +30,12 @@ func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { f.upsertEntryCalled = true + f.upsertEntryParams = arg return db.WatchListEntry{}, nil } func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { - return f.addRows, nil + return nil, nil } func TestAddEntry_RejectsInvalidAnimeID(t *testing.T) { @@ -61,3 +71,31 @@ func TestAddEntry_RejectsInvalidStatus(t *testing.T) { t.Fatal("expected no database writes for invalid status") } } + +func TestImportWatchlist(t *testing.T) { + t.Parallel() + + q := &fakeQuerier{} + svc := NewService(q, nil, nil) + + csvData := `anime_id,status,current_episode,current_time_seconds +1,watching,5,120.5 +2,invalid,10,0 +` + err := svc.ImportWatchlist(context.Background(), "user-1", strings.NewReader(csvData)) + if err != nil { + t.Fatalf("ImportWatchlist failed: %v", err) + } + + if !q.upsertEntryCalled { + t.Fatal("expected entries to be upserted") + } + + // Verify the second record with invalid status was defaulted + // Note: We need a way to track all calls if we want to check the second record specifically, + // but the current fake only tracks the last call. + // For now, let's just check the last call which was record 2. + if q.upsertEntryParams.Status != "plan_to_watch" { + t.Errorf("expected status to be defaulted to plan_to_watch, got %s", q.upsertEntryParams.Status) + } +}