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" "bookhoard/internal/sync" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "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) } // 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) } // setupTestServer creates a test server with a test database // Returns: (*httptest.Server, *database.Queries, *config.Config) func setupTestServer(t *testing.T) (*httptest.Server, *database.Queries, *config.Config) { // 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 = "./test-uploads" cfg.TestMode = true cfg.RateLimitEnabled = false cfg.RequestsPerMinute = 1000 // Connect to test database using the same method as main application dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL()) 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 := sync.NewConnectionManager() connManager.StartCleanupTask() // Create sync queue processor queueProcessor := sync.NewSyncQueueProcessor(queries) go queueProcessor.Start(context.Background()) 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 conversion service for OPDS conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") 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, KOReaderHandler: koreaderHandler, WSHandler: wsHandler, ConflictHandler: conflictHandler, AnalyticsHandler: analyticsHandler, QueueHandler: queueHandler, CollectionHandler: nil, // Not needed for tests OPDSHandler: opdsHandler, ConnManager: connManager, QueueProcessor: queueProcessor, DeviceAuthMiddleware: deviceAuthMiddleware, LoginTracker: loginAttemptTracker, } router.RegisterRoutes(routerConfig) // Create test server ts := httptest.NewServer(e) // Return server, queries, and config return ts, queries, cfg } // 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 } // createTestEbookID creates a test ebook and returns its ID func createTestEbookID(t *testing.T, ts *httptest.Server, token string) string { // First create a library libReq := map[string]interface{}{ "name": "Test Library", "description": "A test library for ebooks", "type": "ebooks", } libBody, _ := json.Marshal(libReq) req, _ := http.NewRequest("POST", ts.URL+"/api/libraries", bytes.NewBuffer(libBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) client := &http.Client{} resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusCreated, resp.StatusCode) var libResult map[string]interface{} json.NewDecoder(resp.Body).Decode(&libResult) libData := libResult["id"].(string) // Create a test ebook ebookReq := map[string]interface{}{ "library_id": libData, "title": "Test Ebook", "author": "Test Author", "file_path": "/tmp/test.epub", "file_size": 1024, "mime_type": "application/epub+zip", } ebookBody, _ := json.Marshal(ebookReq) req2, _ := http.NewRequest("POST", ts.URL+"/api/media-items", bytes.NewBuffer(ebookBody)) req2.Header.Set("Content-Type", "application/json") req2.Header.Set("Authorization", "Bearer "+token) resp2, err := client.Do(req2) require.NoError(t, err) defer resp2.Body.Close() require.Equal(t, http.StatusCreated, resp2.StatusCode) var ebookResult map[string]interface{} json.NewDecoder(resp2.Body).Decode(&ebookResult) ebookID := ebookResult["id"].(string) return ebookID }