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.
689 lines
26 KiB
Go
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)
|
|
}
|