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) // Create refactored handlers (matching main.go) libraryService := services.NewLibraryService(queries) worker := services.NewWorker(3) collectionHandler := handlers.NewCollectionHandler(queries, connManager) 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, 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) 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 }