package main // Regression tests for the archive reconciliation pass: a multi-folder // library must be reconciled against the UNION of its folders' walks, not // once per folder. The old per-folder loop listed every library item // against each single folder's walk, so with two registered folders every // item living under the other folder was flagged "missing (1/2)" on every // scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only // because each scan's walk kept clearing them), and with three or more // folders the crossed marks would have reached the archive threshold // mid-pass and archived real content. Folder-book roots (directory // file_path) must also count as present across folder boundaries — the // file walk never emits a directory. import ( "context" "fmt" "path/filepath" "testing" "time" "bookhoard/internal/database" "bookhoard/internal/services" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // scanTestFolders runs a synchronous scan over several folders — the // multi-folder shape the archive pass must get right (scanTestFolder // covers the single-folder case). func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) { t.Helper() scanner := services.NewMediaScanner(setup.DB) defer func() { _ = scanner.Close() }() scanner.SetAdminID(adminID) require.NoError(t, scanner.SetFolders(folders, false)) require.NoError(t, scanner.ScanFolders(context.Background())) } func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open // Second registered folder in the SAME library — the shape the // per-folder reconciliation got wrong. folderB := t.TempDir() _, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{ LibraryID: libID, FolderPath: folderB, }) require.NoError(t, err) admin, _ := moveTestAdminID(t, setup) // Folder A holds a lone single-file book; folder B holds a // folder-of-tracks book whose file_path is a DIRECTORY. writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3")) writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3")) writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3")) // Two consecutive scans: the first proves no flags are raised, the // second proves the flag/walk bounce (clear → re-flag) is gone too. for scanNo := 1; scanNo <= 2; scanNo++ { scanTestFolders(t, setup, admin, []string{folderA, folderB}) items := listLibraryAudioItems(t, setup, libID) var singleFile, folderBook bool for _, item := range items { assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath) assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath) switch item.FilePath { case "Author A/Lone Book/lonely.mp3": singleFile = true case "Author B/Folder Book": folderBook = true } } require.True(t, singleFile, "scan %d: single-file book must exist", scanNo) require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo) require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo) } }