Files
bookhoard/cmd/server/tests/test_helpers.go
T
john-okeefe 2deb845cbc Phase 0: Fix test infrastructure
- Fix critical bug in test_helpers.go (dead code, wrong return type)
- Add test_helpers_db.go with 6 new helper functions:
  * verifyDeviceCreated, verifyDeviceDeleted
  * verifyUserField, verifyMediaItemInDB, verifyMediaItemDeleted
  * createTestLibraryWithFolder
- Impact: All tests can now create users reliably

- Create Phase 1 example (phase1_example_test.go) demonstrating:
  * Struct-based assertions replacing map[string]interface{}
  * Database verification after mutations
  * Type-safe compile-time error detection
- Impact: Template pattern for remaining 500+ conversions

This work transforms brittle map-based tests into reliable struct-based
assertions with database verification, preventing silent API changes
and data corruption bugs.
2026-02-13 17:42:02 -05:00

567 lines
17 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"
"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/v4"
echomiddleware "github.com/labstack/echo/v4/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
UserToken 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
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
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
}
// 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 creates a test user with deterministic UUID
func createTestUserOnce(t *testing.T, db *database.Queries) UserTestData {
ctx := context.Background()
// Check if user exists and delete for fresh state
existingUser, err := db.GetUserByEmail(ctx, "testuser@example.com")
if err == nil {
// User exists, delete them to ensure fresh password
err = db.DeleteUser(ctx, existingUser.ID)
if err != nil {
// If delete fails (user might be referenced elsewhere), log and continue
t.Logf("Warning: Could not delete existing test user: %v", err)
}
}
// Create a fresh test user with a valid password
// Password: "Test@Pass123!" meets complexity requirements
// This is a bcrypt hash for "Test@Pass123!"
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@example.com",
Username: "testuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "admin",
})
require.NoError(t, err, "Should create test user")
// Get the user ID from created user
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
require.NoError(t, err, "Should parse user UUID")
return UserTestData{
ID: userUUID,
Email: "testuser@example.com",
Username: "testuser",
Password: "Test@Pass123!",
}
}
// 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 resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
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,
}
}
// 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()
// Create sync queue processor with cancellable context
queueProcessor := wsync.NewSyncQueueProcessor(queries)
queueCtx, queueCancel := context.WithCancel(context.Background())
go queueProcessor.Start(queueCtx)
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
// Create refactored handlers (matching main.go Phase 6)
libraryService := services.NewLibraryService(queries)
worker := services.NewWorker(3)
collectionHandler := handlers.NewCollectionHandler(queries, connManager)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
searchHandler := handlers.NewSearchHandler(queries)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
conversionService := services.NewConversionService(queries, getCachePath())
opdsHandler := handlers.NewOPDSHandler(queries, 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.Logger())
e.Use(echomiddleware.Recover())
e.Use(echomiddleware.CORS())
// Setup routes using router package
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
SearchHandler: searchHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
CollectionHandler: collectionHandler,
OPDSHandler: opdsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
}
router.RegisterRoutes(routerConfig)
// Create test server
ts := httptest.NewServer(e)
// Create TestServerSetup struct with all resources
setup := &TestServerSetup{
Server: ts,
DB: queries,
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
QueueProcessor: queueProcessor,
CleanupCancel: cleanupCancel,
QueueCtx: queueCtx,
QueueCancel: queueCancel,
}
// Register cleanup function to run automatically when test completes
t.Cleanup(func() {
if err := setup.Close(); err != nil {
t.Errorf("Failed to cleanup test server: %v", err)
}
})
return setup
}
// loginTestUser logs in a test user and returns the JWT token
func loginTestUser(t *testing.T, ts *httptest.Server, db *database.Queries) string {
// Ensure test user exists first
_ = getTestUserID(t, db)
loginRequest := map[string]interface{}{
"login": "testuser@example.com",
"password": "Test@Pass123!",
}
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 test user")
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
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()
// Check if test user exists and delete them first to ensure fresh state
user, err := db.GetUserByEmail(ctx, "testuser@example.com")
if err == nil {
// User exists, delete them to ensure fresh password
err = db.DeleteUser(ctx, user.ID)
if err != nil {
// If delete fails (user might be referenced elsewhere), log and continue
t.Logf("Warning: Could not delete existing test user: %v", err)
}
}
// Create a fresh test user with a valid password
// Password: "Test@Pass123!" meets complexity requirements
// This is the bcrypt hash for "Test@Pass123!"
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@example.com",
Username: "testuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "admin",
})
require.NoError(t, err, "Failed to create test user")
userUUID, err := uuid.FromBytes(newUser.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, ts *httptest.Server, token string) string {
// First create a library
libReq := map[string]interface{}{
"name": "Test Library",
"description": "A test library for media items",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req, _ := http.NewRequest("POST", ts.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResult)
libData := libResult["id"].(string)
// Add a folder to the library (required before adding media items)
// Use /app/uploads which is already mounted in the test container
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
folderReqHTTP, _ := http.NewRequest("POST", ts.URL+"/api/libraries/"+libData+"/folders", bytes.NewBuffer(folderBody))
folderReqHTTP.Header.Set("Content-Type", "application/json")
folderReqHTTP.Header.Set("Authorization", "Bearer "+token)
folderResp, err := client.Do(folderReqHTTP)
require.NoError(t, err)
defer folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
// Create a test media item
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", ts.URL+"/api/media-items", bytes.NewBuffer(mediaItemBody))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var mediaItemResult map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
mediaItemID := mediaItemResult["id"].(string)
return mediaItemID
}