Files
bookhoard/cmd/server/tests/test_helpers_test.go
T
John O'Keefe 4faa217822 test: repair integration harness — wire SettingsRegistry, reuse preserved dev admin
Two pre-existing harness breaks found while running the device tests:

- RegisterRoutes dereferences cfg.Settings (auth rate limit) but
  setupTestServer never set it — every integration test nil-panicked at
  route registration. Wire database.NewSettingsRegistry(queries) the
  same way main.go does.
- The cleanup deliberately PRESERVES testuser@tests.bookhoard.internal
  (shared dev admin), but setup then blindly re-INSERTed that user, so
  every run after the first failed on users_email_key. Reuse the user
  when it exists; default collections are created only for a NEW user
  (the preserved admin already has its set).

Also documented the offline runner used on slow links: build the test
binary on the host (CGO_ENABLED=0 go test -c) and execute it inside the
bookhoard-tests image against the compose network — no in-container
module downloads, no image rebuild.
2026-09-18 16:06:14 -04:00

829 lines
27 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",
})
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()
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 {
_, 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)
queueProcessor.SetProgressService(progressService)
queueCtx, queueCancel := context.WithCancel(context.Background())
go queueProcessor.Start(queueCtx)
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService)
koreaderHandler.SetAnnotationService(annotationService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
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)
seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService)
mediaHandler.SetAnnotationService(annotationService)
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,
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),
}
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")
} 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")
// Default collections are created exactly once per user; the
// preserved dev admin already has its set from the first run.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
}
// 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",
})
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,
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)
}
}
}
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
}
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)
}