package main // Retry + scoped-scan integration tests (2026-10 Processing Issues work). // // - Per-issue retry re-imports a row-less import_failed ledger entry // through the individual scanner (NOT a library scan) and closes it. // - A missing file retries to 409 with the real reason. // - An item-backed issue retries through the book-detail rescan // mechanism and closes. // - POST /api/libraries/:id/scan with folder_path must be a registered // folder (validated, never trusted). // - A single-folder scan must NOT run archive reconciliation for the // library's other folders — without that guard the scoped walk would // mark every item under the unscanned folders missing and archive them. import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bookhoard/internal/database" ) func retryIssueViaAPI(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, issueID uuid.UUID) *httptest.ResponseRecorder { t.Helper() libUUID, err := uuid.FromBytes(libraryID.Bytes[:]) require.NoError(t, err) req := httptest.NewRequest("POST", "/api/libraries/"+libUUID.String()+"/issues/"+issueID.String()+"/retry", nil) req.Header.Set("Authorization", "Bearer "+setup.Token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) return rec } func openIssuesFor(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID) []database.ListProcessingIssuesByLibraryRow { t.Helper() rows, err := setup.DB.ListProcessingIssuesByLibrary(context.Background(), libraryID) require.NoError(t, err) return rows } func seedRowlessIssue(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, relPath string) { t.Helper() _, err := setup.DBPool.Exec(context.Background(), `INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path) VALUES (NULL, $1, 'import_failed', 'seeded failure for retry test', 'error', $2)`, libraryID, relPath) require.NoError(t, err) } func TestRetryProcessingIssueRowless(t *testing.T) { setup := setupTestServer(t) defer setup.Close() admin, _ := moveTestAdminID(t, setup) libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Retry Rowless %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // A real file whose import "failed" earlier (the ledger row exists, // the media_items row does not) — exactly the production shape the // retry button targets. rel := "Author/retrybook.epub" require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(folder, rel), []byte("dummy epub content"), 0o644)) seedRowlessIssue(t, setup, libID, filepath.ToSlash(rel)) issues := openIssuesFor(t, setup, libID) require.Len(t, issues, 1) issueID, err := uuid.FromBytes(issues[0].ID.Bytes[:]) require.NoError(t, err) rec := retryIssueViaAPI(t, setup, libID, issueID) require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) // The file imported through the individual scanner... var count int require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT count(*) FROM media_items WHERE library_id = $1 AND file_path = $2`, libID, filepath.ToSlash(rel)).Scan(&count)) require.Equal(t, 1, count, "retry must import the file") // ...and the ledger closed (import self-heal + explicit path resolve). require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved AND resolved_at IS NOT NULL`, issues[0].ID).Scan(&count)) require.Equal(t, 1, count, "successful retry must resolve the issue") require.Len(t, openIssuesFor(t, setup, libID), 0) // Missing file: retry answers 409 with the real reason, issue stays. seedRowlessIssue(t, setup, libID, "Author/vanished.epub") issues = openIssuesFor(t, setup, libID) require.Len(t, issues, 1) vanishedID, err := uuid.FromBytes(issues[0].ID.Bytes[:]) require.NoError(t, err) rec = retryIssueViaAPI(t, setup, libID, vanishedID) require.Equal(t, http.StatusConflict, rec.Code) assert.Contains(t, rec.Body.String(), "not found on disk") // Item-backed issue: retry runs the book-detail rescan mechanism. itemRow, err := setup.DB.GetMediaItemByLibraryAndPath(context.Background(), database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)}) require.NoError(t, err) _, err = setup.DBPool.Exec(context.Background(), `INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity) VALUES ($1, $2, 'metadata_extraction_failed', 'seeded item issue for retry test', 'warning')`, itemRow.ID, libID) require.NoError(t, err) // vanished.epub's issue is still open — pick the item-backed one. var itemIssueID uuid.UUID for _, open := range openIssuesFor(t, setup, libID) { if open.MediaItemID.Valid { itemIssueID, err = uuid.FromBytes(open.ID.Bytes[:]) require.NoError(t, err) break } } require.NotEqual(t, uuid.Nil, itemIssueID, "item-backed fixture") rec = retryIssueViaAPI(t, setup, libID, itemIssueID) require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) var resolvedCount int require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT count(*) FROM processing_issues WHERE id = $1 AND resolved`, itemIssueID).Scan(&resolvedCount)) require.Equal(t, 1, resolvedCount, "successful item retry must resolve the issue") _ = admin // scanner attribution is implicit via the library owner } func TestScanLibraryFolderPathValidation(t *testing.T) { setup := setupTestServer(t) defer setup.Close() libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan Validate %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) post := func(folderPath string) *httptest.ResponseRecorder { body, _ := json.Marshal(map[string]string{"folder_path": folderPath}) req := httptest.NewRequest("POST", "/api/libraries/"+libID.String()+"/scan", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) return rec } // An unregistered path is rejected, never scanned. rec := post("/tmp/definitely-not-registered") require.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "not one of this library's registered folders") // A registered folder scopes the scan and enqueues normally. rec = post(folder) require.Equal(t, http.StatusAccepted, rec.Code, "body: %s", rec.Body.String()) } func TestScanSingleFolderSkipsArchiveReconciliation(t *testing.T) { setup := setupTestServer(t) defer setup.Close() ctx := context.Background() admin, _ := moveTestAdminID(t, setup) libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive Guard %d", time.Now().UnixNano()), "ebooks") t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // Second registered folder: the scoped scan below covers only this one. folderB := t.TempDir() _, err := setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{ LibraryID: libID, FolderPath: folderB, }) require.NoError(t, err) // A healthy item under folder A. rel := "Author/archguard.epub" require.NoError(t, os.MkdirAll(filepath.Join(folderA, "Author"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(folderA, rel), []byte("dummy epub content"), 0o644)) scanTestFolder(t, setup, admin, folderA) item, err := setup.DB.GetMediaItemByLibraryAndPath(ctx, database.GetMediaItemByLibraryAndPathParams{LibraryID: libID, FilePath: filepath.ToSlash(rel)}) require.NoError(t, err) require.False(t, item.ArchivedAt.Valid) require.Equal(t, int32(0), item.MissingScanCount) // Scan ONLY folder B. Without the partial-scope guard the archive // pass would see nothing from folder A and mark this item missing // (1/2 toward archival) — the exact false-archive the guard exists // to prevent. scanTestFolder(t, setup, admin, folderB) after, err := setup.DB.GetMediaItem(ctx, item.ID) require.NoError(t, err) require.False(t, after.ArchivedAt.Valid, "scoped scan must not archive items under unscanned folders") require.Equal(t, int32(0), after.MissingScanCount, "scoped scan must not mark unscanned-folder items missing") // Control: the item's own folder scans clean as before. scanTestFolder(t, setup, admin, folderA) after, err = setup.DB.GetMediaItem(ctx, item.ID) require.NoError(t, err) require.False(t, after.ArchivedAt.Valid) require.Equal(t, int32(0), after.MissingScanCount) }