Files
bookhoard/cmd/server/tests/test_helpers_test.go
T
John O'Keefe 4ee24e7152 feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields —
  every book column, file/lifecycle facts, the three HOLDING
  STRUCTURES (library, collections with excluded-aware membership,
  series with FULL primary+alternate_info membership), and the whole
  user-relative class (status, progress, last read, my rating, review,
  notes/highlights presence, note/highlight text, time spent, pages
  read, collections, added by) reading the VIEWER's rows. Operators
  are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
  over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
  shape), the field-aware suggestions, and a true-COUNT live count.
  Mandatory WHERE head everywhere (family lens + integrity facts).
  Values are always bound parameters — no user string reaches a
  field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
  fields/operators/malformed values are 400); qf wins over flat params
  on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
  field-values autocomplete upgraded in place — any registry field
  key, qf binds the OTHER rules (symmetric under-filter suggestions,
  zero-match values never offered); classic six without a tree keep
  their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
  markup, Alpine tree state, sanctioned island; grouped field menus,
  operator menus with red negations, field-aware value menus, live
  count, chips per rule with red negations, URL rehydration, smart
  serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
  numbers/dates/empty), every user-relative field class with the
  my-data weld (two readers, different worlds), note-text substring
  pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
  403s), suggestion-under-rules exactness, qf validation + precedence,
  flat-vs-tree equivalence; registry completeness unit test.
2026-10-20 14:49:50 -04:00

932 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/router"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
echomiddleware "github.com/labstack/echo/v5/middleware"
"github.com/stretchr/testify/require"
)
// CustomValidator wraps the go-playground validator
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
// TestDeviceSetup provides a complete, isolated test environment for device tests
type TestDeviceSetup struct {
Server *httptest.Server
DB *database.Queries
Config *config.Config
User UserTestData
Device DeviceTestData
Library LibraryTestData
UserToken string
}
type LibraryTestData struct {
ID string
Name string
Type string
}
type UserTestData struct {
ID uuid.UUID
Email string
Username string
Password string
Token string
}
type DeviceTestData struct {
ID uuid.UUID
Name string
Type string
Identifier string
AuthToken string
PGType database.Devices
}
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
type TestServerSetup struct {
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
AnnotationService *wsync.AnnotationService
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
}
// Close cleans up all resources in the correct order
func (s *TestServerSetup) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
// Stop queue processor first
if s.QueueCancel != nil {
s.QueueCancel()
s.QueueCancel = nil
}
// Reset global worker instance
services.WorkerInstance = nil
// Stop connection manager cleanup task
if s.CleanupCancel != nil {
s.CleanupCancel()
s.CleanupCancel = nil
}
// Close HTTP server
if s.Server != nil {
s.Server.Close()
s.Server = nil
}
// Close database pool (this waits for all connections to be released)
if s.DBPool != nil {
s.DBPool.Close()
s.DBPool = nil
}
s.closed = true
return nil
}
// Helper functions for testing
func containsPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}
func trimSpace(s string) string {
return strings.TrimSpace(s)
}
// isRunningInContainer detects if tests are running inside a Docker container
func isRunningInContainer() bool {
// Check for container-specific marker file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check if /app/uploads exists (container path)
if _, err := os.Stat("/app/uploads"); err == nil {
return true
}
// Check environment variable (explicit override)
if os.Getenv("TEST_IN_CONTAINER") == "true" {
return true
}
return false
}
// getUploadPath returns the appropriate upload path based on runtime environment
func getUploadPath() string {
// Check for explicit override first
if path := os.Getenv("TEST_UPLOAD_PATH"); path != "" {
return path
}
if isRunningInContainer() {
return "/app/uploads" // Container path (right side of volume mount)
}
return "./uploads" // Host path (left side of volume mount)
}
// getCachePath returns the appropriate cache path based on runtime environment
func getCachePath() string {
// Check for explicit override first
if path := os.Getenv("TEST_CACHE_PATH"); path != "" {
return path
}
if isRunningInContainer() {
return "/app/cache/kepub" // Container path (volume mount)
}
// Note: This is a Docker volume on host, not a folder
// Tests using this should handle the volume appropriately
return "/app/cache/kepub"
}
// setupDeviceTest creates a complete test environment for device tests
func setupDeviceTest(t *testing.T) *TestDeviceSetup {
serverSetup := setupTestServer(t)
// Create user ONCE with known credentials
user := createTestUserOnce(t, serverSetup.DB)
// Login to get token
token := loginUserWithCredentials(t, serverSetup.Server, user.Email, user.Password)
return &TestDeviceSetup{
Server: serverSetup.Server,
DB: serverSetup.DB,
Config: serverSetup.Config,
User: user,
UserToken: token,
}
}
// createTestUserOnce returns the pre-created test user info
func createTestUserOnce(t *testing.T, db *database.Queries) UserTestData {
ctx := context.Background()
user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Test user should exist (created by setupTestServer)")
userUUID, err := uuid.FromBytes(user.ID.Bytes[0:16])
require.NoError(t, err, "Should parse user UUID")
return UserTestData{
ID: userUUID,
Email: "testuser@tests.bookhoard.internal",
Username: "testuser",
Password: "Test@Pass123!",
}
}
// createRegularUserOnce creates a regular (non-admin) test user with unique credentials
func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
ctx := context.Background()
uniqueID := uuid.New().String()[:8]
email := fmt.Sprintf("regularuser-%s@tests.bookhoard.internal", uniqueID)
username := fmt.Sprintf("regularuser-%s", uniqueID)
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: email,
Username: username,
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Regular", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Should create regular test user")
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
require.NoError(t, err, "Should parse user UUID")
createDefaultCollectionsForUser(t, db, pgtype.UUID{Bytes: userUUID, Valid: true})
t.Cleanup(func() {
ctx := context.Background()
db.DeleteUser(ctx, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true})
})
return UserTestData{
ID: userUUID,
Email: email,
Username: username,
Password: "Test@Pass123!",
}
}
// uuidToPGType converts uuid.UUID to pgtype.UUID
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
}
// createDefaultCollectionsForUser creates the 4 default system collections for a user
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
ctx := context.Background()
// Canonical Title-case names — the same list the registration seed
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
// A stale kebab-case copy lived here once and duplicated every
// system collection in the shared dev DB.
defaultCollections := []struct {
Name string
Description string
Icon string
Color string
QueryType string
Priority int32
}{
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
}
for _, col := range defaultCollections {
// Idempotent by the STABLE identity (user + query_type): existing
// rows (renamed, tombstoned) heal to canonical metadata in place;
// insert only when the query type has no row. A blind insert here
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if gerr == nil {
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
})
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
continue
}
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
})
require.NoError(t, err, "Should create default collection: "+col.Name)
}
}
// loginUserWithCredentials performs explicit login with provided credentials
func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string {
loginRequest := map[string]interface{}{
"login": email,
"password": password,
}
body, _ := json.Marshal(loginRequest)
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Failed to login")
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
token, ok := result["access_token"].(string)
require.True(t, ok, "Should have access_token")
require.NotEmpty(t, token, "Access token should not be empty")
return token
}
// CreateDevice creates a test device for the TestDeviceSetup
func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, deviceIdentifier string) *DeviceTestData {
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
device, err := s.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: deviceName,
DeviceType: deviceType,
DeviceIdentifier: deviceIdentifier,
AuthToken: deviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create device")
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
require.NoError(t, err, "Should parse device ID")
return &DeviceTestData{
ID: deviceUUID,
Name: deviceName,
Type: deviceType,
Identifier: deviceIdentifier,
AuthToken: deviceToken,
PGType: device,
}
}
// CreateLibrary creates a test library for the TestDeviceSetup
func (s *TestDeviceSetup) CreateLibrary(t *testing.T, name, libraryType string) string {
ctx := context.Background()
// Get the library_type_id for the specified type
libraryTypeRow, err := s.DB.GetLibraryTypeByName(ctx, libraryType)
require.NoError(t, err, "Should find library type")
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
library, err := s.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "Test library description", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: pgUserID,
})
require.NoError(t, err, "Should create library")
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err, "Should parse library ID")
s.Library = LibraryTestData{
ID: libraryUUID.String(),
Name: name,
Type: libraryType,
}
return libraryUUID.String()
}
// CreateCollection creates a test collection for the TestDeviceSetup
func (s *TestDeviceSetup) CreateCollection(t *testing.T, name string) string {
ctx := context.Background()
pgUserID := pgtype.UUID{Bytes: [16]byte(s.User.ID), Valid: true}
collection, err := s.DB.CreateCollection(ctx, database.CreateCollectionParams{
UserID: pgUserID,
Name: name,
Description: pgtype.Text{String: "Test collection description", Valid: true},
Color: pgtype.Text{String: "#FF5733", Valid: true},
Icon: pgtype.Text{String: "folder", Valid: true},
})
require.NoError(t, err, "Should create collection")
collectionUUID, err := uuid.FromBytes(collection.ID.Bytes[0:16])
require.NoError(t, err, "Should parse collection ID")
return collectionUUID.String()
}
// setupTestServer creates a test server with a test database
// Returns: *TestServerSetup with automatic cleanup via t.Cleanup
func setupTestServer(t *testing.T) *TestServerSetup {
// Load configuration using the same method as main application
cfg := config.LoadConfig()
// Apply test-specific overrides
cfg.ServerPort = "0" // Use random port for tests
cfg.BaseURL = "http://localhost"
cfg.JWTSecret = "test-secret-key"
cfg.UploadPath = getUploadPath()
cfg.TestMode = true
cfg.RateLimitEnabled = false
cfg.RequestsPerMinute = 1000
// Connect to test database using the same method as main application
// Use max_conns=1 to prevent connection pool exhaustion during test runs
// (78 tests × 1 connection = 78 connections, well under PostgreSQL's 100 default max_connections)
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
require.NoError(t, err, "Failed to connect to test database")
queries := database.New(dbPool)
// Create login attempt tracker
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
// Create handlers
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
// Create WebSocket connection manager
connManager := wsync.NewConnectionManager()
cleanupCancel := connManager.StartCleanupTask()
progressService := wsync.NewProgressService(queries, connManager)
annotationService := wsync.NewAnnotationService(queries, connManager)
queueProcessor := wsync.NewSyncQueueProcessor(queries, progressService, annotationService)
queueCtx, queueCancel := context.WithCancel(context.Background())
go queueProcessor.Start(queueCtx)
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor, progressService, annotationService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create refactored handlers (matching main.go)
libraryService := services.NewLibraryService(queries)
worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker
jobsHandler := handlers.NewJobsHandler(queries, worker)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
conversionService := services.NewConversionService(queries, getCachePath())
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// Create Echo instance
e := echo.New()
// Set up validator
v := validator.New()
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
t.Fatal("Failed to register password validator:", err)
}
e.Validator = &CustomValidator{validator: v}
// Middleware
e.Use(echomiddleware.RequestLogger())
e.Use(echomiddleware.Recover())
e.Use(echomiddleware.CORSWithConfig(echomiddleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
}))
// Setup routes using router package
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
StatsHandler: statsHandler,
StatisticsService: statisticsService,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
ProcessingIssuesHandler: processingIssuesHandler,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
JobsHandler: jobsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
// RegisterRoutes dereferences Settings for the auth rate limit;
// the main binary wires database.NewSettingsRegistry(queries) —
// the test harness must too or route registration nil-panics.
Settings: database.NewSettingsRegistry(queries),
// main.go:246 passes the shared LibraryService here; omitting it
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
LibraryService: libraryService,
}
router.RegisterRoutes(routerConfig)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "Failed to create listener")
// Configure Echo's HTTP server with the listener
serverConfig := &http.Server{
Handler: e,
Addr: ln.Addr().String(),
}
ts := &httptest.Server{
Listener: ln,
Config: serverConfig,
}
ts.Start()
ctx := context.Background()
// Delete transient test users but preserve the dev admin user
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
allUsers, err := queries.ListUsers(ctx)
if err == nil {
for _, user := range allUsers {
if user.Email == "testuser@tests.bookhoard.internal" {
continue
}
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
queries.DeleteUser(ctx, user.ID)
}
}
}
// Delete test libraries (names containing "test" - case insensitive)
// This cleans up libraries created by tests while preserving user-created libraries
// NOTE: Do not use "test" in library names if you want to keep them!
allLibs, _ := queries.ListLibraries(ctx)
for _, lib := range allLibs {
if strings.Contains(strings.ToLower(lib.Name), "test") {
queries.DeleteLibrary(ctx, lib.ID)
}
}
// The admin test user doubles as the shared dev admin and is
// deliberately PRESERVED by the cleanup above — reuse it when it
// already exists instead of re-creating (a blind INSERT hits
// users_email_key on every run after the first).
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
var adminUUID uuid.UUID
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID")
// Reset the password to the known test constant: other tests (e.g.
// password-change tests) mutate the shared admin's password, which
// would otherwise lock every later test out with login 401s until
// the database is wiped.
if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{
ID: existing.ID,
PasswordHash: passwordHash,
}); updErr != nil {
require.NoError(t, updErr, "Failed to reset shared admin test password")
}
} else {
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@tests.bookhoard.internal",
Username: "testuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "admin",
})
require.NoError(t, cerr, "Failed to create admin test user")
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID")
}
// The canonical system-collection set is ensured on EVERY run: the
// helper is idempotent by (user, query_type) and heals tombstoned or
// renamed rows, so suite runs can't accumulate naming-drift copies.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Delete test-created COLLECTIONS (names containing "test"): without
// this, every suite run leaves its collections behind and the next
// run's creates die on UNIQUE(user_id, name) — the "collections
// 500-panic" rot. System collections are skipped (permanent rows).
// Same convention as the library pass: names containing "test".
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
// Same passes for test DEVICES (identifier/name containing "test")
// and test SAVED FILTERS (name containing "test"): both accumulated
// across runs and poisoned later runs (duplicate identifiers, 409s,
// non-empty "initially empty" assertions).
_, derr := dbPool.Exec(ctx,
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
adminUUID)
if derr != nil {
t.Logf("device cleanup failed: %v", derr)
}
_, ferr := dbPool.Exec(ctx,
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
adminUUID)
if ferr != nil {
t.Logf("saved-filter cleanup failed: %v", ferr)
}
// Create fresh regular test user
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
Email: "testregularuser@tests.bookhoard.internal",
Username: "testregularuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Regular", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Failed to create regular test user")
regularUUID, err := uuid.FromBytes(regularUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse regular user UUID")
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: regularUUID, Valid: true})
// Login to get tokens
adminToken := loginWithCredentials(t, ts, "testuser@tests.bookhoard.internal", "Test@Pass123!")
regularToken := loginWithCredentials(t, ts, "testregularuser@tests.bookhoard.internal", "Test@Pass123!")
// Create TestServerSetup struct with all resources
setup := &TestServerSetup{
Server: ts,
DB: queries,
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
CleanupCancel: cleanupCancel,
QueueCtx: queueCtx,
QueueCancel: queueCancel,
Token: adminToken,
RegularToken: regularToken,
}
// Register cleanup function to run automatically when test completes
t.Cleanup(func() {
// Clean up test libraries created by this test
ctx := context.Background()
allLibs, err := queries.ListLibraries(ctx)
if err == nil {
for _, lib := range allLibs {
if strings.Contains(strings.ToLower(lib.Name), "test") {
queries.DeleteLibrary(ctx, lib.ID)
}
}
}
// Same pass for test-created COLLECTIONS (the "collections
// 500-panic" rot: they lingered forever and poisoned later runs).
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
if err := setup.Close(); err != nil {
t.Errorf("Failed to cleanup test server: %v", err)
}
})
return setup
}
func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password string) string {
loginRequest := map[string]interface{}{
"login": email,
"password": password,
}
body, _ := json.Marshal(loginRequest)
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Failed to login")
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
token, ok := result["access_token"].(string)
require.True(t, ok, "Should have access_token")
require.NotEmpty(t, token, "Access token should not be empty")
return token
}
func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
ctx := context.Background()
user, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err, "Test user should exist")
userUUID, err := uuid.FromBytes(user.ID.Bytes[:])
require.NoError(t, err, "Failed to parse user UUID")
return userUUID
}
// createLibraryDirect inserts a library owned by the given user WITHOUT a
// nested test-server setup: a second setupTestServer bootstraps away the
// first setup's users, and the ghost-session guard rightly rejects the
// outer test's tokens from then on.
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
ctx := context.Background()
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
require.NoError(t, err, "Should find library type")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "Test library description", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
})
require.NoError(t, err, "Should create library")
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err, "Should parse library ID")
return libraryUUID.String()
}
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
ctx := context.Background()
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
require.NoError(t, err, "Regular user should exist")
userUUID, err := uuid.FromBytes(user.ID.Bytes[:])
require.NoError(t, err, "Failed to parse user UUID")
return userUUID
}
// createTestMediaItemID creates a test media item and returns its ID
func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
uniqueName := fmt.Sprintf("Test Library %d", time.Now().UnixNano())
httpClient := &http.Client{}
libReq := map[string]interface{}{
"name": uniqueName,
"description": "A test library for media items",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := httpClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResult map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&libResult)
require.NoError(t, err)
libData := libResult["id"].(string)
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
folderReqHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libData+"/folders", bytes.NewBuffer(folderBody))
folderReqHTTP.Header.Set("Content-Type", "application/json")
folderReqHTTP.Header.Set("Authorization", "Bearer "+setup.Token)
folderResp, err := httpClient.Do(folderReqHTTP)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(folderResp.Body)
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
mediaItemReq := map[string]interface{}{
"library_id": libData,
"title": "Test Media Item",
"author": "Test Author",
"file_path": "/tmp/test.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
mediaItemBody, _ := json.Marshal(mediaItemReq)
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaItemBody))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+setup.Token)
resp2, err := httpClient.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var mediaItemResult map[string]interface{}
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
require.NoError(t, err)
mediaItemID := mediaItemResult["id"].(string)
t.Cleanup(func() {
deleteReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/libraries/"+libData, nil)
deleteReq.Header.Set("Authorization", "Bearer "+setup.Token)
httpClient.Do(deleteReq)
})
return mediaItemID
}
// addFolderToLibrary adds a folder to a test library via HTTP API
func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string, folderPath string) {
t.Helper()
payload := map[string]interface{}{
"folder_path": folderPath,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/"+libraryID+"/folders", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
}