feat: add ImportWatchlist to service
This commit is contained in:
@@ -3,8 +3,11 @@ package watchlist
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -186,3 +189,53 @@ func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, ani
|
|||||||
|
|
||||||
return tx.Commit()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package watchlist
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
@@ -11,7 +12,15 @@ type fakeQuerier struct {
|
|||||||
db.Querier
|
db.Querier
|
||||||
upsertAnimeCalled bool
|
upsertAnimeCalled bool
|
||||||
upsertEntryCalled 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) {
|
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) {
|
func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
|
||||||
f.upsertEntryCalled = true
|
f.upsertEntryCalled = true
|
||||||
|
f.upsertEntryParams = arg
|
||||||
return db.WatchListEntry{}, nil
|
return db.WatchListEntry{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
|
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) {
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user