feat: add Kobo device sync support and fix device route protection

- Add Kobo sync handler with markup, bookmark, analytics, and initialization endpoints
- Add Kobo integration tests and Bruno API test collection
- Move device approve/reject routes from public to protected routes
- Enhance test infrastructure with DATABASE_URL support and helper functions
- Fix device GetDevice handler nil pointer handling
- Clean up test reports and session files
This commit is contained in:
2026-01-30 23:58:34 -05:00
parent b49ba7909f
commit a3aa9f67ac
11 changed files with 839 additions and 676 deletions
+125 -38
View File
@@ -40,38 +40,69 @@ func trimSpace(s string) string {
// 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) {
// Get database password - use default for testing since .env password has special chars
// Tests will run against the local test database, not the Docker one
dbPass := os.Getenv("DATABASE_PASSWORD")
if dbPass == "" {
dbPass = os.Getenv("DBPASS")
}
// Check if DATABASE_URL is set (for containerized testing)
dbURL := os.Getenv("DATABASE_URL")
// 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"
}
var cfg *config.Config
var dbPool *pgxpool.Pool
var err error
// 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,
}
if dbURL != "" {
// Use provided DATABASE_URL (for testing against containerized database)
t.Logf("Using DATABASE_URL from environment for testing")
// Connect to test database
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
require.NoError(t, err, "Failed to connect to test database")
// 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)
@@ -100,8 +131,6 @@ func setupTestServer(t *testing.T) (*httptest.Server, *database.Queries, *config
// Device management routes (public - for registration)
e.POST("/api/devices/register", deviceHandler.InitiateRegistration)
e.POST("/api/devices/register/status", deviceHandler.CheckRegistrationStatus)
e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice)
e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice)
// Device management routes (protected - require user auth)
devices := protected.Group("/devices")
@@ -110,6 +139,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *database.Queries, *config
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)
@@ -129,7 +160,7 @@ func loginTestUser(t *testing.T, ts *httptest.Server, db *database.Queries) stri
loginRequest := map[string]interface{}{
"login": "testuser@example.com",
"password": "testpass123",
"password": "Test@Pass123!",
}
body, _ := json.Marshal(loginRequest)
@@ -147,24 +178,26 @@ func loginTestUser(t *testing.T, ts *httptest.Server, db *database.Queries) stri
json.NewDecoder(resp.Body).Decode(&result)
token, ok := result["access_token"].(string)
require.True(t, ok, "Response should contain access_token")
require.NotEmpty(t, token, "Token should not be empty")
require.True(t, ok, "Should have access_token")
require.NotEmpty(t, token, "Access token should not be empty")
return token
}
// getTestUserID retrieves the test user ID from the database
func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
// Try to get the test user by email
// 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
passwordHash := "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" // "testpass123" hashed
// 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",
@@ -180,3 +213,57 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
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
}