package importer import ( "context" "io" "log/slog" "os" "path/filepath" "strings" "testing" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/acquire" "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/downloader" "git.fabledsword.com/bvandeusen/minstrel/internal/wanted" ) func newPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) dbtest.ResetDB(t, pool) return pool } const hash = "0123456789abcdef0123456789abcdef01234567" // fakeClient is a download client holding one finished torrent. type fakeClient struct { downloader.Driver content string deleted []string } func (c *fakeClient) Torrents(_ context.Context, _ ...string) ([]downloader.Torrent, error) { return []downloader.Torrent{{Hash: hash, Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading", ContentPath: c.content}}, nil } func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error { c.deleted = append(c.deleted, hashes...) return nil } // fakeCatalogue knows RAM as one three-track edition. type fakeCatalogue struct{} func (fakeCatalogue) Releases(_ context.Context, _ string) (catalogue.Editions, error) { return catalogue.Editions{Editions: []catalogue.Edition{{ Release: dbq.MbRelease{Mbid: "rel-ram", Title: "Random Access Memories", Status: "Official", ReleaseDate: "2013-05-17"}, Tracks: []dbq.MbReleaseTrack{ {MediumPosition: 1, MediumFormat: "CD", Position: 1, Title: "Give Life Back to Music", RecordingMbid: "rec-01"}, {MediumPosition: 1, MediumFormat: "CD", Position: 2, Title: "The Game of Love", RecordingMbid: "rec-02"}, {MediumPosition: 1, MediumFormat: "CD", Position: 3, Title: "Giorgio by Moroder", RecordingMbid: "rec-03"}, }, }}}, nil } // fakePrinter names each file's recording by its file name: 01.flac is // rec-01, unless wrong is set. type fakePrinter struct{ wrong bool } func (p fakePrinter) Recordings(_ context.Context, path string) ([]string, error) { if p.wrong { return []string{"rec-other"}, nil } return []string{"rec-" + strings.TrimSuffix(filepath.Base(path), ".flac")}, nil } type fakeScanner struct{ scanned []string } func (s *fakeScanner) ScanFiles(_ context.Context, paths []string) ([]pgtype.UUID, error) { s.scanned = append(s.scanned, paths...) return nil, nil } type stack struct { pool *pgxpool.Pool q *dbq.Queries im *Importer client *fakeClient scanner *fakeScanner library string want dbq.Want grab dbq.Grab } // newStack wants RAM, has a finished download of it waiting in a fake // client, and an importer filing into a temp library. func newStack(t *testing.T, printer Printer) *stack { t.Helper() ctx := context.Background() pool := newPool(t) q := dbq.New(pool) content := t.TempDir() for _, n := range []string{"01", "02", "03"} { if err := os.WriteFile(filepath.Join(content, n+".flac"), []byte("not really flac "+n), 0o644); err != nil { t.Fatal(err) } } w, err := wanted.WantWith(ctx, q, wanted.WantParams{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", ArtistName: "Daft Punk", Title: "Random Access Memories", PrimaryType: "Album", FirstReleaseDate: "2013-05-17", Reason: wanted.ReasonRequest}) if err != nil { t.Fatal(err) } g, err := acquire.NewMemory(pool).Grabbed(ctx, dbq.InsertGrabParams{WantID: w.ID, InfoHash: hash, Name: "Daft Punk - RAM [FLAC]", Key: hash, Client: "qbittorrent", SavePath: content, Origin: acquire.OriginSchedule}, pgtype.UUID{}, "") if err != nil { t.Fatal(err) } s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{}, library: t.TempDir(), want: w, grab: g} s.im = New(pool, s.scanner, fakeCatalogue{}, printer, func() []string { return []string{s.library} }, slog.New(slog.NewTextHandler(io.Discard, nil))) s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil } return s } func (s *stack) grabNow(t *testing.T) dbq.Grab { t.Helper() g, err := s.q.GetGrab(context.Background(), s.grab.ID) if err != nil { t.Fatal(err) } return g } func TestImport_AFinishedDownloadOfTheAlbumIsFiled_Integration(t *testing.T) { s := newStack(t, fakePrinter{}) rep := s.im.Pass(context.Background()) if rep.Imported != 1 { t.Fatalf("pass = %+v", rep) } g := s.grabNow(t) if g.ImportState != StateImported || g.EndReason != StateImported || !g.EndedAt.Valid || g.ImportedFiles != 3 { t.Fatalf("grab = %s %s ended=%v files=%d: %s", g.ImportState, g.EndReason, g.EndedAt.Valid, g.ImportedFiles, g.ImportReason) } dir := filepath.Join(s.library, "Daft Punk", "Random Access Memories (2013)") if g.Destination != dir { t.Errorf("destination %q", g.Destination) } got, err := os.ReadFile(filepath.Join(dir, "Daft Punk - Random Access Memories - 02 - The Game of Love.flac")) if err != nil || string(got) != "not really flac 02" { t.Errorf("filed copy: %q %v", got, err) } if _, err := os.Stat(filepath.Join(s.client.content, "02.flac")); err != nil { t.Errorf("the download's own file went: %v", err) } if len(s.scanner.scanned) != 3 || len(s.client.deleted) != 0 { t.Errorf("scanned %v, deleted %v", s.scanner.scanned, s.client.deleted) } w, _ := s.q.GetWantByReleaseGroup(context.Background(), "rg-ram") if !w.SearchedAt.Valid { t.Error("an imported want is due for search again") } if s.im.Pass(context.Background()).Imported != 0 { t.Error("imported twice") } } func TestImport_TheWrongAlbumIsRefusedBlockedAndSearchedAgain_Integration(t *testing.T) { s := newStack(t, fakePrinter{wrong: true}) ctx := context.Background() if err := s.q.MarkWantSearched(ctx, s.want.ID); err != nil { t.Fatal(err) } if rep := s.im.Pass(ctx); rep.Refused != 1 { t.Fatalf("pass = %+v", rep) } g := s.grabNow(t) if g.ImportState != StateRefused || g.EndReason != StateRefused || !strings.Contains(g.ImportReason, "recordings") { t.Fatalf("grab = %s %s: %s", g.ImportState, g.EndReason, g.ImportReason) } blocks, err := s.q.ListReleaseBlocks(ctx) if err != nil || len(blocks) != 1 || blocks[0].InfoHash != hash || blocks[0].Reason != acquire.BlockImport { t.Errorf("blocks = %+v %v", blocks, err) } if len(s.client.deleted) != 1 { t.Errorf("the refused download was not removed: %v", s.client.deleted) } w, _ := s.q.GetWantByReleaseGroup(ctx, "rg-ram") if w.SearchedAt.Valid { t.Error("a refused want is not due for search") } entries, _ := os.ReadDir(s.library) if len(entries) != 0 { t.Errorf("the library got %d entries", len(entries)) } } func TestImport_ADoubtfulDownloadWaitsForAPersonThenImportsAnyway_Integration(t *testing.T) { s := newStack(t, nil) // no AcoustID; untagged files: nothing but lengths ctx := context.Background() if rep := s.im.Pass(ctx); rep.Review != 1 { t.Fatalf("pass = %+v", rep) } g := s.grabNow(t) if g.ImportState != StateReview || g.EndedAt.Valid || !strings.Contains(g.ImportReason, "A person decides") { t.Fatalf("grab = %s ended=%v: %s", g.ImportState, g.EndedAt.Valid, g.ImportReason) } if rep := s.im.Pass(ctx); rep.Review+rep.Imported != 0 { t.Errorf("a held download was looked at again: %+v", rep) } if _, err := s.q.ForceGrabImport(ctx, g.ID); err != nil { t.Fatal(err) } if rep := s.im.Pass(ctx); rep.Imported != 1 { t.Fatalf("after import anyway: %+v", rep) } if g := s.grabNow(t); g.ImportState != StateImported || !strings.Contains(g.ImportReason, "anyway") { t.Errorf("grab = %s: %s", g.ImportState, g.ImportReason) } } func TestImport_APersonRefusesADoubtfulDownload_Integration(t *testing.T) { s := newStack(t, nil) ctx := context.Background() s.im.Pass(ctx) g, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{}) if err != nil { t.Fatal(err) } if g.ImportState != StateRefused || !g.EndedAt.Valid || len(s.client.deleted) != 1 { t.Errorf("grab = %s ended=%v deleted=%v", g.ImportState, g.EndedAt.Valid, s.client.deleted) } if _, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{}); err == nil { t.Error("refused twice") } } func TestImport_AnUnfinishedDownloadIsOnlyWatched_Integration(t *testing.T) { s := newStack(t, fakePrinter{}) s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return &halfClient{s.client}, nil } if rep := s.im.Pass(context.Background()); rep.Downloading != 1 { t.Fatalf("pass = %+v", rep) } g := s.grabNow(t) if g.ImportState != StateDownloading || g.Progress < 0.49 || g.Progress > 0.51 { t.Errorf("grab = %s %.2f", g.ImportState, g.Progress) } } type halfClient struct{ *fakeClient } func (c *halfClient) Torrents(ctx context.Context, h ...string) ([]downloader.Torrent, error) { ts, _ := c.fakeClient.Torrents(ctx, h...) ts[0].Progress, ts[0].State = 0.5, "downloading" return ts, nil }