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) }