Problem: When running `make test-integration`, the last test to run would leave its "test" libraries in the database. This happened because: 1. setupTestServer() cleaned up old "test" libraries at the START 2. Tests created their own libraries 3. When tests finished, t.Cleanup() called setup.Close() which only closed connections but did NOT delete libraries 4. The LAST test's libraries persisted because no subsequent test cleaned them For example, "Job Status Test Library" from TestWorker_JobStatusTracking would remain in the database after all tests completed, visible when logging into the UI. Root Cause: The cleanup logic only ran at the START of each test (in setupTestServer), not at the END. This worked for intermediate tests (each test cleaned up the previous test's libraries), but the final test had no cleanup. Solution: Added library cleanup to the t.Cleanup() function in setupTestServer(). Now each test deletes its own "test" libraries when it completes, ensuring: - Clean state after `make test-integration` finishes - No residual test data in the database - Safe for tests with subtests (cleanup runs after all subtests finish) Note on Test Structure: Tests like TestOPDSEndpoints and TestCollectionSearchLibraryFilter create libraries once and share them across all subtests. The t.Cleanup() function runs AFTER all subtests complete, so this change is safe and doesn't interfere with subtest resource sharing. Files changed: - cmd/server/tests/test_helpers_test.go: Added library cleanup to t.Cleanup()
777 lines
25 KiB
Go
777 lines
25 KiB
Go
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"
|
||
"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
|
||
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},
|
||
}
|
||
|
||
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 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, 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)
|
||
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, 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,
|
||
CollectionHandler: collectionHandler,
|
||
FiltersHandler: filtersHandler,
|
||
DashboardHandler: dashboardHandler,
|
||
DashboardService: dashboardService,
|
||
OPDSHandler: opdsHandler,
|
||
JobsHandler: jobsHandler,
|
||
ConnManager: connManager,
|
||
QueueProcessor: queueProcessor,
|
||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||
LoginTracker: loginAttemptTracker,
|
||
}
|
||
|
||
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 ALL test users (any user with test email domains) to ensure clean state
|
||
// This handles users created during tests that may have been promoted to admin, etc.
|
||
allUsers, err := queries.ListUsers(ctx)
|
||
if err == nil {
|
||
for _, user := range allUsers {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Create fresh admin test user
|
||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||
adminUser, err := 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, err, "Failed to create admin test user")
|
||
|
||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||
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,
|
||
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 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()
|
||
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 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)
|
||
|
||
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 folderResp.Body.Close()
|
||
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 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)
|
||
|
||
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 resp.Body.Close()
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
}
|