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:
+10
-2
@@ -184,8 +184,6 @@ func main() {
|
||||
// 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)
|
||||
|
||||
// KOReader sync routes (device authentication required)
|
||||
koreaderSync := e.Group("/api/sync/koreader")
|
||||
@@ -194,6 +192,14 @@ func main() {
|
||||
koreaderSync.GET("/library", deviceAuthMiddleware.Authenticate(koreaderHandler.GetLibrary))
|
||||
koreaderSync.POST("/bookmarks", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncBookmarks))
|
||||
|
||||
// Kobo sync routes (device authentication required)
|
||||
koboHandler := handlers.NewKoboHandler(queries, connManager)
|
||||
koboSync := e.Group("/api/sync/kobo")
|
||||
koboSync.POST("/markup", deviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
koboSync.POST("/bookmark", deviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||
koboSync.POST("/v1/analytics/gettests", deviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
|
||||
koboSync.GET("/v1/initialization", deviceAuthMiddleware.Authenticate(koboHandler.Initialization))
|
||||
|
||||
// Device management routes (protected - require user auth)
|
||||
devices := protected.Group("/devices")
|
||||
devices.GET("", deviceHandler.ListDevices)
|
||||
@@ -201,6 +207,8 @@ func main() {
|
||||
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)
|
||||
|
||||
// WebSocket endpoint for real-time sync
|
||||
e.GET("/ws/sync", wsHandler.HandleWebSocket)
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestDeviceRegistrationFlow(t *testing.T) {
|
||||
assert.True(t, ok, "Should have access_token")
|
||||
|
||||
// Step 4: Approve the device
|
||||
req = httptest.NewRequest("GET", fmt.Sprintf("/devices/approve/%s", registrationID), nil)
|
||||
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookmann/internal/handlers"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKoboInitialization(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ts, db, _, _ := setupTestServer(t)
|
||||
defer closeTestServer(t, ts, db)
|
||||
|
||||
token := loginTestUser(t, ts, db)
|
||||
_ = getTestUserID(t, db)
|
||||
_ = createTestEbookID(t, ts, token)
|
||||
|
||||
t.Run("successful initialization", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/test-token/v1/initialization", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-auth-token")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKoboLibrarySync(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ts, db, _, _ := setupTestServer(t)
|
||||
defer closeTestServer(t, ts, db)
|
||||
|
||||
token := loginTestUser(t, ts, db)
|
||||
_ = createTestEbookID(t, ts, token)
|
||||
|
||||
t.Run("successful library sync", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/sync/kobo/test-token/v1/initialization", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKoboMarkupSync(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ts, db, _, _ := setupTestServer(t)
|
||||
defer closeTestServer(t, ts, db)
|
||||
|
||||
token := loginTestUser(t, ts, db)
|
||||
ebookID := createTestEbookID(t, ts, token)
|
||||
|
||||
t.Run("successful markup sync with annotations and bookmarks", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"ReadingSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": ebookID,
|
||||
"PercentRead": 45.6,
|
||||
"EntitlementId": "ent-123",
|
||||
"RemainingTimeMinutes": 120,
|
||||
"LastModified": "2026-01-30T20:00:00Z",
|
||||
},
|
||||
},
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"BookmarkId": "bookmark-1",
|
||||
"ContentId": ebookID,
|
||||
"BookmarkText": "This is highlighted text",
|
||||
"BookmarkType": "annotation",
|
||||
"BookmarkTitle": "Chapter 3",
|
||||
},
|
||||
{
|
||||
"BookmarkId": "bookmark-2",
|
||||
"ContentId": ebookID,
|
||||
"BookmarkText": "This is my note abouts book",
|
||||
"BookmarkType": "bookmark",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/markup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-kobo-device", `{"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}`)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKoboBookmarkSync(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ts, db, _, _ := setupTestServer(t)
|
||||
defer closeTestServer(t, ts, db)
|
||||
|
||||
token := loginTestUser(t, ts, db)
|
||||
ebookID := createTestEbookID(t, ts, token)
|
||||
|
||||
t.Run("successful bookmark sync", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"BookmarkId": "bookmark-3",
|
||||
"ContentId": ebookID,
|
||||
"BookmarkText": "Important note abouts book",
|
||||
"BookmarkType": "bookmark",
|
||||
"DateCreated": "2026-01-30T19:55:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/bookmark", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-kobo-device", `{"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}`)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKoboAnalyticsGettests(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
ts, db, _, _ := setupTestServer(t)
|
||||
defer closeTestServer(t, ts, db)
|
||||
|
||||
token := loginTestUser(t, ts, db)
|
||||
ebookID := createTestEbookID(t, ts, token)
|
||||
|
||||
t.Run("successful analytics tests", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": map[string]string{
|
||||
"name": "Kobo Analytics Tests",
|
||||
},
|
||||
"ContentId": ebookID,
|
||||
"ReadingEvent": "Reading",
|
||||
"RemainingTimeMin": 180,
|
||||
"PercentRead": 67.8,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/sync/kobo/v1/analytics/gettests", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-kobo-device", `{"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}`)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKoboDeviceHeaderParsing(t *testing.T) {
|
||||
t.Run("valid device header", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"DeviceId": "kobo-clara-test",
|
||||
"Model": "Kobo Clara",
|
||||
"SerialNumber": "N123456789",
|
||||
"Firmware": "4.38.23555",
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
|
||||
var device handlers.KoboDeviceInfo
|
||||
err := json.Unmarshal(jsonData, &device)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "kobo-clara-test", device.DeviceID)
|
||||
assert.Equal(t, "Kobo Clara", device.Model)
|
||||
assert.Equal(t, "N123456789", device.SerialNumber)
|
||||
assert.Equal(t, "4.38.23555", device.Firmware)
|
||||
})
|
||||
}
|
||||
|
||||
func closeTestServer(t *testing.T, ts interface{}, db interface{}) {
|
||||
if ts, ok := ts.(*httptest.Server); ok {
|
||||
ts.Close()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user