Files
bookhoard/cmd/server/tests/processing_issues_test.go
T
John O'Keefe 9b5defcb31 feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)
processing_issues only recorded cross-library move rejections; files
that failed to import vanished into an error counter and stdout. The
ledger now records every failure class and heals itself:

Schema (in place, pre-release boundary):
- media_item_id NULLABLE + file_path TEXT (deliberately unbounded —
  an over-long path is itself a failure mode; a VARCHAR(500) ledger
  column died on the exact 22001 it existed to report — caught by the
  new test). Row-less failures key on (library, file_path); item-backed
  on (item, type). Widened dedup = COALESCE-expression unique index;
  the old table UNIQUE stays (NULLs pairwise-distinct for row-less).
- Conflict-target upsert: repeat failures UPDATE the same row and
  REOPEN resolved ones — never duplicates.

Wirings:
- CreateMediaItem failure (single-file + multifile createAudioBookItem)
  → row-less import_failed (error).
- Extraction hard-fail with fallback import → item-backed
  metadata_extraction_failed (warning).
- Change-path re-extraction failure (the ev/sidecar guard) → item-backed
  metadata_extraction_failed; LogProcessingIssue keeps its signature, a
  row-less helper joins it.

Auto-resolution:
- Successful import → ResolveOpenProcessingIssuesForPath (library,
  file_path).
- Successful (re)extraction → ResolveOpenProcessingIssuesForItem
  (single-file change path, multifile editorial apply).
- Unchanged-skip scans do NOT resolve: extraction never re-ran, so the
  degraded metadata is still degraded — the page heals exactly when the
  book does.

Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR
page): explicit out-of-set library id → 403, never silent. This
tightens one pre-lens behavior — a nonexistent library id returned
200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md).
List is LEFT-JOINed so row-less rows appear (media_item_id "",
title falls back to the path).

Tests: full lifecycle state machine through real scans (fail → appears,
repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape);
family lens 403s with a fresh non-shared admin; all 7 pre-existing
issue suites green (two expectations widened to the lens, changelogged);
adjacent suites (CrossLibraryMove, Audiobook, Calibre,
ExtractorVersion, SchemaIdempotency) green; unit suites green.
2026-10-20 19:38:02 -04:00

689 lines
26 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestProcessingIssuesListAuthentication tests authentication and authorization for list endpoint
func TestProcessingIssuesListAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("GET /api/libraries/:id/issues/list - Non-admin user forbidden", func(t *testing.T) {
token := setup.RegularToken
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("GET /api/libraries/:id/issues/list - Admin user authorized", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
}
// TestProcessingIssuesListResponseStructure tests the response structure of list endpoint
func TestProcessingIssuesListResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
t.Run("Returns JSON array for empty library", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response []map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.IsType(t, []map[string]interface{}{}, response)
assert.Equal(t, 0, len(response), "Empty library should return empty array")
})
t.Run("Has correct content type", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
})
}
// TestProcessingIssuesListInputValidation tests input validation for list endpoint
func TestProcessingIssuesListInputValidation(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
testCases := []struct {
name string
libraryID string
expectedStatus int
description string
}{
{
name: "Invalid UUID format",
libraryID: "not-a-uuid",
expectedStatus: http.StatusBadRequest,
description: "Should reject malformed UUID",
},
{
name: "Empty UUID",
libraryID: "",
expectedStatus: http.StatusBadRequest,
description: "Should return 400 for empty ID",
},
{
name: "UUID with extra path traversal",
libraryID: "00000000-0000-0000-0000-000000000000/../../etc",
expectedStatus: http.StatusBadRequest,
description: "Should reject path traversal attempts",
},
{
name: "Nonexistent library UUID",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusForbidden,
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
},
{
name: "Special characters in UUID",
libraryID: "'; DROP TABLE processing_issues; --",
expectedStatus: http.StatusBadRequest,
description: "Should reject SQL injection attempts",
},
{
name: "Very long UUID",
libraryID: "00000000-0000-0000-0000-000000000000-extra-suffix",
expectedStatus: http.StatusBadRequest,
description: "Should reject overly long IDs",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, tc.expectedStatus, rec.Code, tc.description)
// For successful requests, verify response structure
if tc.expectedStatus == http.StatusOK {
var response []map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.IsType(t, []map[string]interface{}{}, response)
}
})
}
}
// TestProcessingIssueStatsAuthentication tests authentication and authorization for stats endpoint
func TestProcessingIssueStatsAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("GET /api/libraries/:id/issues/stats - Non-admin user forbidden", func(t *testing.T) {
token := setup.RegularToken
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("GET /api/libraries/:id/issues/stats - Admin user authorized", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
}
// TestProcessingIssueStatsResponseStructure tests the response structure of stats endpoint
func TestProcessingIssueStatsResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
t.Run("Returns correct structure for empty library", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Verify required fields
assert.Contains(t, response, "error_count", "Should have error_count field")
assert.Contains(t, response, "warning_count", "Should have warning_count field")
assert.Contains(t, response, "info_count", "Should have info_count field")
// Verify all counts are zero for empty library
assert.Equal(t, float64(0), response["error_count"], "error_count should be 0")
assert.Equal(t, float64(0), response["warning_count"], "warning_count should be 0")
assert.Equal(t, float64(0), response["info_count"], "info_count should be 0")
// Verify field types
assert.IsType(t, float64(0), response["error_count"], "error_count should be number")
assert.IsType(t, float64(0), response["warning_count"], "warning_count should be number")
assert.IsType(t, float64(0), response["info_count"], "info_count should be number")
})
t.Run("Has correct content type", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
})
t.Run("Has exactly 3 fields", func(t *testing.T) {
token := setup.Token
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Len(t, response, 3, "Should have exactly 3 fields")
})
}
// TestProcessingIssueStatsInputValidation tests input validation for stats endpoint
func TestProcessingIssueStatsInputValidation(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
testCases := []struct {
name string
libraryID string
expectedStatus int
description string
}{
{
name: "Invalid UUID format",
libraryID: "not-a-uuid",
expectedStatus: http.StatusBadRequest,
description: "Should reject malformed UUID",
},
{
name: "Empty UUID",
libraryID: "",
expectedStatus: http.StatusBadRequest,
description: "Should return 400 for empty ID",
},
{
name: "UUID with extra path traversal",
libraryID: "00000000-0000-0000-0000-000000000000/../../etc",
expectedStatus: http.StatusBadRequest,
description: "Should reject path traversal attempts",
},
{
name: "Nonexistent library UUID",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusForbidden,
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
},
{
name: "Special characters in UUID",
libraryID: "'; DROP TABLE processing_issues; --",
expectedStatus: http.StatusBadRequest,
description: "Should reject SQL injection attempts",
},
{
name: "Very long UUID",
libraryID: "00000000-0000-0000-0000-000000000000-extra-suffix",
expectedStatus: http.StatusBadRequest,
description: "Should reject overly long IDs",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, tc.expectedStatus, rec.Code, tc.description)
// For successful requests, verify response structure
if tc.expectedStatus == http.StatusOK {
var response map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
// Verify all counts are zero for nonexistent library
assert.Equal(t, float64(0), response["error_count"])
assert.Equal(t, float64(0), response["warning_count"])
assert.Equal(t, float64(0), response["info_count"])
}
})
}
}
// TestProcessingIssuesCrossLibraryIsolation tests that different libraries don't interfere
func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup := setupTestServer(t)
// Create two different libraries with different types
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
token := setup.Token
// Both libraries should return empty arrays (no issues created)
t.Run("Library 1 returns its own empty list", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+library1ID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response []map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, 0, len(response), "Library 1 should have 0 issues")
})
t.Run("Library 2 returns its own empty list", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response []map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, 0, len(response), "Library 2 should have 0 issues")
})
t.Run("Both libraries have independent stats", func(t *testing.T) {
// Get stats for library 1
req1 := httptest.NewRequest("GET", "/api/libraries/"+library1ID+"/issues/stats", nil)
req1.Header.Set("Authorization", "Bearer "+token)
rec1 := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
var stats1 map[string]interface{}
err := json.NewDecoder(rec1.Body).Decode(&stats1)
require.NoError(t, err)
// Get stats for library 2
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
req2.Header.Set("Authorization", "Bearer "+token)
rec2 := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
var stats2 map[string]interface{}
err = json.NewDecoder(rec2.Body).Decode(&stats2)
require.NoError(t, err)
// Both should have zero counts
assert.Equal(t, float64(0), stats1["error_count"])
assert.Equal(t, float64(0), stats1["warning_count"])
assert.Equal(t, float64(0), stats1["info_count"])
assert.Equal(t, float64(0), stats2["error_count"])
assert.Equal(t, float64(0), stats2["warning_count"])
assert.Equal(t, float64(0), stats2["info_count"])
})
}
// TestProcessingIssuesDifferentLibraryTypes tests endpoints with different library types
func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libraryTypes := []struct {
name string
libraryType string
}{
{"Ebooks library", "ebooks"},
{"Comics library", "comics"},
{"Manga library", "manga"},
}
for _, lt := range libraryTypes {
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response []map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.IsType(t, []map[string]interface{}{}, response)
})
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.NewDecoder(rec.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response, "error_count")
assert.Contains(t, response, "warning_count")
assert.Contains(t, response, "info_count")
})
}
}
// TestProcessingIssuesInvalidTokens tests with invalid authentication tokens
func TestProcessingIssuesInvalidTokens(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer invalid-token-12345")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("Stats endpoint rejects invalid token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer invalid-token-12345")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("List endpoint rejects missing bearer prefix", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", setup.Token) // Missing "Bearer " prefix
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("Stats endpoint rejects missing bearer prefix", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", setup.Token) // Missing "Bearer " prefix
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("List endpoint rejects empty authorization header", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
// ============================================================================
// Phase 11 (faceted plan): the honest ledger — row-less import failures,
// truthful upserts, self-healing resolution, family lens.
// ============================================================================
// createTestAdminOnce creates a fresh admin user (never the shared
// testuser) with unique credentials and registers its cleanup.
func createTestAdminOnce(t *testing.T, db *database.Queries, ts *httptest.Server) (UserTestData, string) {
t.Helper()
ctx := context.Background()
uniqueID := uuid.New().String()[:8]
email := fmt.Sprintf("issuesadmin-%s@tests.bookhoard.internal", uniqueID)
username := fmt.Sprintf("issuesadmin-%s", uniqueID)
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: email,
Username: username,
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Issues", Valid: true},
LastName: pgtype.Text{String: "Admin", Valid: true},
Role: "admin",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Should create admin test user")
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
require.NoError(t, err, "Should parse admin user UUID")
t.Cleanup(func() {
db.DeleteUser(context.Background(), pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true})
})
user := UserTestData{ID: userUUID, Email: email, Username: username, Password: "Test@Pass123!"}
token := loginWithCredentials(t, ts, email, "Test@Pass123!")
return user, token
}
func TestProcessingIssuesLedgerImportFailures(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Ledger %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A book whose library-relative path exceeds media_items.file_path's
// VARCHAR(500): CreateMediaItem fails deterministically, and the
// relative path itself cannot ever import — the classic row-less
// ledger case. Filename and dir components stay within filesystem
// limits; only the total path overflows.
longRel := ""
for i := 0; i < 9; i++ {
longRel = filepath.Join(longRel, strings.Repeat("d", 60))
}
longRel = filepath.Join(longRel, "deepbook.epub")
require.Greater(t, len(longRel), 500, "relative path must exceed the column limit")
deepDir := filepath.Join(folder, filepath.Dir(longRel))
require.NoError(t, os.MkdirAll(deepDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, longRel), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
// Fail → the row-less issue appears, keyed by (library, path).
open := func() []database.ListProcessingIssuesByLibraryRow {
rows, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libID)
require.NoError(t, err)
return rows
}
issues := open()
require.Len(t, issues, 1, "the failed import must appear in the ledger")
issue := issues[0]
require.Equal(t, "import_failed", issue.IssueType)
require.Equal(t, "error", issue.Severity)
require.False(t, issue.MediaItemID.Valid, "row-less: no media_items row exists")
require.Equal(t, filepath.ToSlash(longRel), issue.FilePath, "row-less key is the library-relative path")
require.Empty(t, issue.Title, "no item row → no title")
// The JSON list surfaces the row-less shape: empty item id, title
// fallen back to the path (pins the LEFT JOIN — an inner join would
// silently drop the ledger's newest kind).
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var listed []map[string]interface{}
require.NoError(t, json.NewDecoder(rec.Body).Decode(&listed))
require.Len(t, listed, 1)
assert.Equal(t, "", listed[0]["media_item_id"], "row-less issues carry no item id")
assert.Equal(t, filepath.ToSlash(longRel), listed[0]["title"], "title falls back to the file path")
assert.Equal(t, "import_failed", listed[0]["issue_type"])
// Repeat failure → the SAME row updates (no dups).
scanTestFolder(t, setup, admin, folder)
issues = open()
require.Len(t, issues, 1, "repeat failures upsert, never duplicate")
require.Equal(t, issue.ID, issues[0].ID)
// Success resolves by (library, path): a normal book imports at a
// path that has an open row-less issue — seeded here to model the
// transient failure the long path can't have (its path IS the
// failure).
goodRel := "Author/ledgerbook.epub"
_, err := setup.DBPool.Exec(ctx,
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'seeded transient failure', 'error', $2)`,
libID, goodRel)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, "Author", "ledgerbook.epub"), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
var resolved int
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT count(*) FROM processing_issues WHERE library_id = $1 AND file_path = $2 AND resolved AND resolved_at IS NOT NULL`,
libID, goodRel).Scan(&resolved))
require.Equal(t, 1, resolved, "the file's successful import must resolve its open issue")
require.Len(t, open(), 1, "only the long-path issue stays open")
}
func TestProcessingIssuesFamilyLens(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Lens %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A fresh admin (never the shared testuser) + a hidden-library row:
// default-ALLOW visibility with one explicit hide.
lensAdmin, lensToken := createTestAdminOnce(t, setup.DB, setup.Server)
_, err := setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
libID, pgtype.UUID{Bytes: [16]byte(lensAdmin.ID), Valid: true})
require.NoError(t, err)
// An open issue in that library (row-less, no item to cascade).
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'lens probe', 'error', 'hidden/book.epub')`,
libID)
require.NoError(t, err)
get := func(token, suffix string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/"+suffix, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
// Hidden from the fresh admin → 403 on both endpoints (never silent).
assert.Equal(t, http.StatusForbidden, get(lensToken, "list").Code, "hidden library list is a 403")
assert.Equal(t, http.StatusForbidden, get(lensToken, "stats").Code, "hidden library stats are a 403")
// Visible to the shared admin (no hide row) → the rows show.
assert.Equal(t, http.StatusOK, get(setup.Token, "list").Code)
assert.Equal(t, http.StatusOK, get(setup.Token, "stats").Code)
}