diff --git a/cmd/server/tests/processing_issues_retry_test.go b/cmd/server/tests/processing_issues_retry_test.go new file mode 100644 index 00000000..ec732935 --- /dev/null +++ b/cmd/server/tests/processing_issues_retry_test.go @@ -0,0 +1,213 @@ +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) +}