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) }