package main import ( "bookmann/internal/config" "bookmann/internal/database" "bookmann/internal/handlers" ratelimit "bookmann/internal/middleware" wsync "bookmann/internal/sync" "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "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" ) // 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, *handlers.Handler) func setupTestServer(t *testing.T) (*httptest.Server, *database.Queries, *config.Config, *handlers.Handler) { // Check if DATABASE_URL is set (for containerized testing) dbURL := os.Getenv("DATABASE_URL") var cfg *config.Config var dbPool *pgxpool.Pool var err error if dbURL != "" { // Use provided DATABASE_URL (for testing against containerized database) t.Logf("Using DATABASE_URL from environment for testing") // Parse the DATABASE_URL to extract connection details for config cfg = &config.Config{ ServerPort: "0", BaseURL: "http://localhost", DatabaseHost: "localhost", DatabasePort: "5432", DatabaseUser: "postgres", DatabasePassword: "", // Not used when DATABASE_URL is set DatabaseName: "bookmann", JWTSecret: "test-secret-key", UploadPath: "./test-uploads", TestMode: true, RateLimitEnabled: false, RequestsPerMinute: 1000, } // Connect using DATABASE_URL directly dbPool, err = pgxpool.New(context.Background(), dbURL) require.NoError(t, err, "Failed to connect to test database using DATABASE_URL") } else { // Legacy behavior: construct database URL from parts dbPass := os.Getenv("DATABASE_PASSWORD") if dbPass == "" { dbPass = os.Getenv("DBPASS") } // If password looks like it has special chars (=, +, /), use local postgres default if strings.Contains(dbPass, "=") || strings.Contains(dbPass, "+") || len(dbPass) > 20 { t.Logf("Warning: Database password has special characters, using local default 'postgres'") dbPass = "postgres" } // Load test configuration cfg = &config.Config{ ServerPort: "0", // Use random port for tests BaseURL: "http://localhost", DatabaseHost: "localhost", DatabasePort: "5432", DatabaseUser: "postgres", DatabasePassword: dbPass, DatabaseName: "bookmann", JWTSecret: "test-secret-key", UploadPath: "./test-uploads", TestMode: true, RateLimitEnabled: false, RequestsPerMinute: 1000, } // Connect to test database 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) deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) // Create WebSocket connection manager for testing connManager := wsync.NewConnectionManager() // Create Echo instance e := echo.New() // Middleware e.Use(echomiddleware.Logger()) e.Use(echomiddleware.Recover()) e.Use(echomiddleware.CORS()) // Setup routes protected := e.Group("/api") h := handlers.SetupRoutes(protected, queries, connManager) // Device management routes (public - for registration) e.POST("/api/devices/register", deviceHandler.InitiateRegistration) e.POST("/api/devices/register/status", deviceHandler.CheckRegistrationStatus) // Device management routes (protected - require user auth) devices := protected.Group("/devices") devices.GET("", deviceHandler.ListDevices) devices.GET("/:id", deviceHandler.GetDevice) devices.PUT("/:id", deviceHandler.UpdateDevice) devices.DELETE("/:id", deviceHandler.DeleteDevice) devices.GET("/pending", deviceHandler.ListPendingRegistrations) devices.GET("/approve/:registration_id", deviceHandler.ApproveDevice) devices.POST("/reject/:registration_id", deviceHandler.RejectDevice) // Auth routes (public - for testing) e.POST("/api/auth/register", authHandler.Register) e.POST("/api/auth/login", authHandler.Login) // Create test server ts := httptest.NewServer(e) // Return server, queries, config, and handler return ts, queries, cfg, h } // 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 { // Try to get existing test user user, err := db.GetUserByEmail(context.Background(), "testuser@example.com") if err == nil { // User exists, return their ID userUUID, err := uuid.FromBytes(user.ID.Bytes[:]) require.NoError(t, err, "Failed to parse user UUID") return userUUID } // If user doesn't exist, create one with a valid password // Password: "TestPass123!" meets complexity requirements // This is the bcrypt hash for "TestPass123!" passwordHash := "$2a$10$rKvZ.HZx3lLJ6IQCpH1lOukQ/xU8j5cH8mYhPY5YGfXllq5hG8y0Ou" newUser, err := db.CreateUser(context.Background(), 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: "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 } // 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 }