Files
bookhoard/cmd/server/tests/test_helpers.go
T
john-okeefe 380af685dc feat(dashboard): implement Phase 7 router registration and config setup
Add DashboardService and DashboardHandler to application configuration:

Router Config Updates (internal/router/router.go):
- Add services import for DashboardService type
- Add DashboardService field to Config struct
- DashboardService: Used by SSR routes in frontend.go for data fetching
- DashboardHandler: Used by API routes in dashboard.go for JSON endpoints

Server Initialization (cmd/server/main.go):
- Create dashboardService instance using services.NewDashboardService(queries)
- Keep dashboardHandler creation (already exists from Phase 4)
- Add DashboardService to routerConfig
- Both services now available for dependency injection

Test Helpers (cmd/server/tests/test_helpers.go):
- Create dashboardService instance for testing
- Create dashboardHandler instance for testing
- Add both DashboardService and DashboardHandler to routerConfig
- Ensures test environment matches production setup

Architecture Rationale:
- DashboardService: Service layer with business logic (reusable by SSR, mobile)
- DashboardHandler: HTTP handler layer (JSON API endpoints)
- Separation allows SSR templates to call service directly
- API routes use handler for proper HTTP response handling
- Mobile apps can use API endpoints via DashboardHandler

All three files updated consistently for complete integration.
2026-02-19 21:06:23 -05:00

718 lines
22 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
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
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,
}
}
// 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()
// 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)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
// Create refactored handlers (matching main.go)
libraryService := services.NewLibraryService(queries)
worker := services.NewWorker(3)
collectionHandler := handlers.NewCollectionHandler(queries, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
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,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
CollectionHandler: collectionHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
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
}
func loginRegularUser(t *testing.T, ts *httptest.Server, db *database.Queries) string {
// Ensure test user exists first
_ = getRegularUserID(t, db)
loginRequest := map[string]interface{}{
"login": "testregularuser@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 getRegularUserID(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, "testregularuser@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: "testregularuser@example.com",
Username: "testregularuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "user",
})
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) string {
// Generate unique library name to avoid conflicts between tests
uniqueName := fmt.Sprintf("Test Library %d", time.Now().UnixNano())
// Get a fresh token to ensure we have a valid user
// (previous tests may have deleted/recreated the test user)
loginReq := map[string]interface{}{
"login": "testuser@example.com",
"password": "Test@Pass123!",
}
loginBody, _ := json.Marshal(loginReq)
loginReqHTTP, _ := http.NewRequest("POST", ts.URL+"/api/auth/login", bytes.NewBuffer(loginBody))
loginReqHTTP.Header.Set("Content-Type", "application/json")
httpClient := &http.Client{}
loginResp, err := httpClient.Do(loginReqHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
validToken := loginResult["access_token"].(string)
// First create a library
libReq := map[string]interface{}{
"name": uniqueName,
"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 "+validToken)
resp, err := httpClient.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 "+validToken)
folderResp, err := httpClient.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 "+validToken)
resp2, err := httpClient.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)
// Cleanup: delete the library after the test
t.Cleanup(func() {
deleteReq, _ := http.NewRequest("DELETE", ts.URL+"/api/libraries/"+libData, nil)
deleteReq.Header.Set("Authorization", "Bearer "+validToken)
httpClient.Do(deleteReq)
})
return mediaItemID
}