//go:build integration package dbtest import ( "context" "log/slog" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "pipu/api/internal/library" "pipu/api/internal/platform/db" ) func TestLibraryDiagnostics(t *testing.T) { pool := connect(t) ctx := context.Background() require.NoError(t, db.Migrate(ctx, pool)) store := library.NewStore(pool) src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "diagnostics", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, store.DeleteSource(ctx, src.ID)) require.NoError(t, store.CleanupOrphans(ctx)) }) before, err := store.Counts(ctx) require.NoError(t, err) badID, err := store.SaveFile(ctx, src.ID, uuid.Nil, library.FileData{RelPath: "broken.mp3", Mtime: time.Now(), ScanError: "invalid audio"}, nil) require.NoError(t, err) counts, err := store.Counts(ctx) require.NoError(t, err) require.Equal(t, before.Errors+1, counts.Errors) require.NoError(t, store.MarkMissing(ctx, []uuid.UUID{badID}, time.Now())) counts, err = store.Counts(ctx) require.NoError(t, err) require.Equal(t, before.Errors, counts.Errors) fileErrors, err := store.FileErrors(ctx, "", 200) require.NoError(t, err) for _, item := range fileErrors { require.NotEqual(t, badID, item.ID, "absent files must not remain in read errors") } // Failed analysis jobs retain their place in diagnostics even without a file. root := t.TempDir() scanner := library.NewScanner(store, fakeProber{}, library.NewProgressBoard(), slog.New(slog.DiscardHandler)) tracks := map[uuid.UUID]bool{} for _, name := range []string{"Artist - First.flac", "Artist - Second.flac"} { require.NoError(t, os.WriteFile(filepath.Join(root, name), []byte(name), 0o644)) fileID, err := scanner.ScanFile(ctx, src, root, name) require.NoError(t, err) var trackID uuid.UUID require.NoError(t, pool.QueryRow(ctx, `SELECT track_id FROM media_files WHERE id = $1`, fileID).Scan(&trackID)) tracks[trackID] = true _, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'audio is shorter than 1 s' WHERE track_id = $1`, trackID) require.NoError(t, err) if name == "Artist - Second.flac" { _, err = pool.Exec(ctx, `DELETE FROM media_files WHERE id = $1`, fileID) require.NoError(t, err) } } var paths, missing int after := uuid.Nil for { page, err := store.AnalysisErrors(ctx, after, 1) require.NoError(t, err) if len(page) == 0 { break } item := page[0] require.NotEqual(t, after, item.TrackID, "pagination must advance") after = item.TrackID if !tracks[item.TrackID] { continue } require.Equal(t, "audio is shorter than 1 s", item.Error) if item.RelPath == nil { missing++ } else { paths++ require.Equal(t, "Artist - First.flac", *item.RelPath) require.Equal(t, src.ID, *item.SourceID) } } require.Equal(t, 1, paths) require.Equal(t, 1, missing) } func TestScanExcludedFolders(t *testing.T) { pool := connect(t) ctx := context.Background() require.NoError(t, db.Migrate(ctx, pool)) store := library.NewStore(pool) src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "exclusions", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, store.DeleteSource(ctx, src.ID)) require.NoError(t, store.CleanupOrphans(ctx)) }) root := t.TempDir() for _, rel := range []string{"Downloads_PumpEars/Artist - Loop.wav", "Downloads_PumpEars/nested/Artist - Hit.wav", "Downloads_PumpEars-copy/Artist - Song.flac"} { p := filepath.Join(root, filepath.FromSlash(rel)) require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) require.NoError(t, os.WriteFile(p, []byte(rel), 0o644)) } board := library.NewProgressBoard() scanner := library.NewScanner(store, fakeProber{}, board, slog.New(slog.DiscardHandler)) scan := func() library.Progress { require.NoError(t, scanner.Start(src, root, true)) require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 10*time.Millisecond) p, _ := board.Get(src.ID) require.Empty(t, p.Error) return p } require.Equal(t, 3, scan().Found) _, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'too short' WHERE track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID) require.NoError(t, err) before, err := store.AnalysisCounts(ctx) require.NoError(t, err) src.ExcludedDirs = []string{"Downloads_PumpEars"} src, err = store.SaveSource(ctx, src) require.NoError(t, err) saved, err := store.GetSource(ctx, src.ID) require.NoError(t, err) require.Equal(t, src.ExcludedDirs, saved.ExcludedDirs) counts, err := store.AnalysisCounts(ctx) require.NoError(t, err) require.Equal(t, before.Failed-2, counts.Failed, "old failed jobs from excluded folders disappear immediately") _, err = store.RetryFailedAnalysis(ctx) require.NoError(t, err) var failed int require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM analysis_jobs WHERE status = 'failed' AND track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID).Scan(&failed)) require.Equal(t, 2, failed, "retry must not requeue excluded jobs") p := scan() require.Equal(t, 1, p.Found) require.Equal(t, 1, p.ToProcess) require.Zero(t, p.Missing) files, err := store.Files(ctx, src.ID) require.NoError(t, err) require.Len(t, files, 3, "excluded records must be preserved") for _, f := range files { require.False(t, f.Missing, "excluded files must not be treated as absent") } // Literal matching must not treat % and _ as SQL wildcards. for _, test := range []struct { path string excluded bool }{{"100%_loops/a.wav", true}, {"100xxloops/a.wav", false}, {"100%_loops-copy/a.wav", false}} { var excluded bool require.NoError(t, pool.QueryRow(ctx, `SELECT library_path_excluded($1, ARRAY['100%_loops'])`, test.path).Scan(&excluded)) require.Equal(t, test.excluded, excluded) } src.ExcludedDirs = []string{} src, err = store.SaveSource(ctx, src) require.NoError(t, err) require.Equal(t, 3, scan().Found, "clearing exclusions makes the subtree eligible again") }