package main // Phase 13 (faceted plan): force rescan = original-scan semantics, and // the manually_edited filter. // // Force rescan contract: re-review every field on every book from all // sources (sidecars → folder structure → tags → fallbacks) on multi- // file books too — the pre-Phase-13 skip is gone — with // metadata_overrides as the only protection. A force pass also stamps // the fresh manifest with the current parser version (success-only) // and closes the book's open ledger issues. import ( "context" "encoding/json" "fmt" "net/http" "os" "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" ) // pinExtractorVersionOne pins the SHARED dev DB's extractor version to // the default 1 for the test's duration. Prior interrupted runs can // leave it bumped (an early test failure aborts before its restore // cleanup registers), and every stamp assertion in these tests assumes // the default. The cleanup restores 1 — the repo's steady state. func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) { t.Helper() setExtractorVersion(t, setup, 1) t.Cleanup(func() { setExtractorVersion(t, setup, 1) }) } // forceScanTestFolder runs a FORCE scan over one folder. func forceScanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) { t.Helper() scanner := services.NewMediaScanner(setup.DB) defer func() { _ = scanner.Close() }() scanner.SetAdminID(adminID) require.NoError(t, scanner.SetFolders([]string{folder}, false)) scanner.SetForce(true) require.NoError(t, scanner.ScanFolders(context.Background())) } func TestForceRescanMultifileSidecarApply(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) ctx := context.Background() admin, _ := moveTestAdminID(t, setup) pinExtractorVersionOne(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Force Rescan %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "01 First.mp3")) writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "02 Second.mp3")) scanTestFolder(t, setup, admin, folder) rel := filepath.ToSlash("Force Author/Force Title") book, found := findAudioItemByPath(t, setup, libID, rel) require.True(t, found, "folder book must import") require.Equal(t, "Force Title", book.Title) // Corrupt, then FORCE: the pre-Phase-13 skip is gone — the full // chain re-derives the title from the folder basename. corruptItemColumn(t, setup, book.ID, "title", "ZZZ") forceScanTestFolder(t, setup, admin, folder) book, found = findAudioItemByPath(t, setup, libID, rel) require.True(t, found) require.Equal(t, "Force Title", book.Title, "force rescan must re-apply folder-book metadata") require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the current parser version") // metadata_overrides is the ONLY protection: a recorded override // survives the force pass untouched. _, err := setup.DBPool.Exec(ctx, `UPDATE media_items SET metadata_overrides = ARRAY['title'], title = 'Manual Title' WHERE id = $1`, book.ID) require.NoError(t, err) forceScanTestFolder(t, setup, admin, folder) book, _ = findAudioItemByPath(t, setup, libID, rel) require.Equal(t, "Manual Title", book.Title, "metadata_overrides protect manual edits under force rescan") } func TestForceRescanSingleFileSidecarReapply(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) pinExtractorVersionOne(t, setup) libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Force Single %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) bookDir := filepath.Join(folder, "Force Probe Author", "Force Probe Book") require.NoError(t, os.MkdirAll(bookDir, 0o755)) opf := ` Force Probe Book Force Probe Author ` require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(bookDir, "Force Probe Book.epub"), []byte("dummy epub content"), 0o644)) scanTestFolder(t, setup, admin, folder) rel := filepath.ToSlash("Force Probe Author/Force Probe Book/Force Probe Book.epub") book, found := findAudioItemByPath(t, setup, libID, rel) require.True(t, found) // Single-file force rescan already ran the full chain — pin it, and // pin the NEW part: the manifest comes out stamped. corruptItemColumn(t, setup, book.ID, "title", "ZZZ") forceScanTestFolder(t, setup, admin, folder) book, found = findAudioItemByPath(t, setup, libID, rel) require.True(t, found) require.Equal(t, "Force Probe Book", book.Title, "force rescan re-derives sidecar metadata (single-file)") require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the manifest") } func TestManuallyEditedFilter(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) ctx := context.Background() libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manual Edits %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) createBook := func(path string) pgtype.UUID { created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: libID, Title: path, Authors: []string{}, FilePath: path, FileSize: pgtype.Int8{Int64: 64, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, err) return created.ID } editedID := createBook("edited-book.epub") cleanID := createBook("clean-book.epub") _, err := setup.DBPool.Exec(ctx, `UPDATE media_items SET metadata_overrides = ARRAY['title'] WHERE id = $1`, editedID) require.NoError(t, err) qfIDs := func(op string) []string { status, rows := qfSearch(t, setup, setup.Token, map[string][]string{ "qf": {qfAll(qfRule("manually_edited", op))}, "library_id": {libID.String()}, }) require.Equal(t, http.StatusOK, status) ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r["id"].(string)) } return ids } trueIDs := qfIDs("is_true") require.Len(t, trueIDs, 1) assert.Equal(t, editedID.String(), trueIDs[0], "is_true matches only manually-edited books") falseIDs := qfIDs("is_false") require.Len(t, falseIDs, 1) assert.Equal(t, cleanID.String(), falseIDs[0], "is_false matches only untouched books") // The deep-linkable JSON shape round-trips through the qf parser // (arity-none rule: no values on the wire). var parsed struct { Mode string `json:"mode"` Children []struct { Field string `json:"field"` } `json:"children"` } require.NoError(t, json.Unmarshal([]byte(qfAll(qfRule("manually_edited", "is_true"))), &parsed)) require.Len(t, parsed.Children, 1) assert.Equal(t, "manually_edited", parsed.Children[0].Field) }