test(integration): cover retry, folder-path validation, and the archive guard
- TestRetryProcessingIssueRowless: a row-less import_failed entry retries to a real import and resolves; a missing file retries to 409 with the real reason and stays open; an item-backed issue retries through the rescan mechanism and resolves. - TestScanLibraryFolderPathValidation: an unregistered folder_path is a 400; a registered one scopes and enqueues (202). - TestScanSingleFolderSkipsArchiveReconciliation: scanning one folder of a two-folder library leaves the other folder's healthy item at missing_scan_count 0 / unarchived — pinning the partial-scope guard against the false-archive regression it exists to prevent.
This commit is contained in:
1 parent
248bb7816d
commit
69d53e92a7
1 file changed
+213
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user