From 5a7144b98ec954b2c1abf72bef260dc89291e5e1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Jan 2026 18:25:28 -0500 Subject: [PATCH] feat: add device management and queue management routes - Add /devices route for device management interface - Add /conflicts route for sync conflict resolution - Add /queue route for sync queue management - Add comprehensive tests for device cap management - Add test suite for queue management --- cmd/server/main.go | 66 ++++++++ cmd/server/tests/device_test.go | 99 +++++++++++ cmd/server/tests/queue_test.go | 283 ++++++++++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 cmd/server/tests/queue_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 5c39eab..fdf7c77 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -423,6 +423,72 @@ func main() { return c.HTML(http.StatusOK, buf.String()) }) + // Device Management route (protected) + protected.GET("/devices", func(c echo.Context) error { + userID := c.Get("user_id").(string) + userEmail := c.Get("user_email").(string) + userUsername := c.Get("user_username").(string) + userRole := c.Get("user_role").(string) + + user := templates.User{ + ID: userID, + Email: userEmail, + Username: userUsername, + Role: userRole, + } + + var buf bytes.Buffer + err := templates.Devices(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Conflicts route (protected) + protected.GET("/conflicts", func(c echo.Context) error { + userID := c.Get("user_id").(string) + userEmail := c.Get("user_email").(string) + userUsername := c.Get("user_username").(string) + userRole := c.Get("user_role").(string) + + user := templates.User{ + ID: userID, + Email: userEmail, + Username: userUsername, + Role: userRole, + } + + var buf bytes.Buffer + err := templates.Conflicts(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Queue Management route (protected) + protected.GET("/queue", func(c echo.Context) error { + userID := c.Get("user_id").(string) + userEmail := c.Get("user_email").(string) + userUsername := c.Get("user_username").(string) + userRole := c.Get("user_role").(string) + + user := templates.User{ + ID: userID, + Email: userEmail, + Username: userUsername, + Role: userRole, + } + + var buf bytes.Buffer + err := templates.Queue(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Start server log.Printf("Starting server on port %s", cfg.ServerPort) e.Logger.Fatal(e.Start(":" + cfg.ServerPort)) diff --git a/cmd/server/tests/device_test.go b/cmd/server/tests/device_test.go index 269d06f..6fbcd24 100644 --- a/cmd/server/tests/device_test.go +++ b/cmd/server/tests/device_test.go @@ -288,3 +288,102 @@ func TestDeviceAuthentication(t *testing.T) { // Device auth is for sync endpoints only assert.Equal(t, http.StatusUnauthorized, rec.Code, "Should require user auth for device management") } + +func TestListPendingRegistrations(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + req := httptest.NewRequest("GET", "/api/devices/pending", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations") + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + pending, ok := response["pending_registrations"].([]interface{}) + assert.True(t, ok, "Should have pending_registrations array") + assert.NotNil(t, pending, "Pending registrations should not be nil") +} + +func TestApproveDeviceRegistration(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + regRequest := map[string]interface{}{ + "device_name": "Test Device for Approval", + "device_type": "koreader", + "device_identifier": "test-approval-12345", + } + regBody, _ := json.Marshal(regRequest) + + req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration") + + var regResponse map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), ®Response) + + registrationID, ok := regResponse["registration_id"].(string) + assert.True(t, ok, "Should have registration_id") + + req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should approve device registration") + + var approveResponse map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &approveResponse) + + assert.True(t, approveResponse["approved"].(bool), "Should confirm approval") +} + +func TestRejectDeviceRegistration(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + regRequest := map[string]interface{}{ + "device_name": "Test Device for Rejection", + "device_type": "koreader", + "device_identifier": "test-rejection-12345", + } + regBody, _ := json.Marshal(regRequest) + + req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration") + + var regResponse map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), ®Response) + + registrationID, ok := regResponse["registration_id"].(string) + assert.True(t, ok, "Should have registration_id") + + req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration") + + var rejectResponse map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &rejectResponse) + + assert.True(t, rejectResponse["rejected"].(bool), "Should confirm rejection") +} diff --git a/cmd/server/tests/queue_test.go b/cmd/server/tests/queue_test.go new file mode 100644 index 0000000..c1a5ce1 --- /dev/null +++ b/cmd/server/tests/queue_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "bookmann/internal/database" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListAllQueueItems_Admin(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginAdminUser(t, ts, db) + + req := httptest.NewRequest("GET", "/api/queue/items", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should list all queue items") + + var response []interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + assert.NotNil(t, response, "Should have queue items array") +} + +func TestGetDeviceQueueStats(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + userID := getTestUserID(t, db) + + deviceToken := fmt.Sprintf("dev_%s", uuid.New().String()) + device, err := db.CreateDevice(context.Background(), database.CreateDeviceParams{ + UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + DeviceName: "Test Device", + DeviceType: "koreader", + DeviceIdentifier: "test-device-123", + AuthToken: deviceToken, + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err, "Should create device") + + deviceIDBytes := device.ID.Bytes[0:16] + deviceID, err := uuid.FromBytes(deviceIDBytes) + require.NoError(t, err, "Should parse device ID") + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/queue/devices/%s/stats", deviceID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should get device queue stats") + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + assert.Equal(t, deviceID.String(), response["device_id"], "Should match device ID") +} + +func TestListDeviceQueueItems(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + userID := getTestUserID(t, db) + + deviceToken := fmt.Sprintf("dev_%s", uuid.New().String()) + device, err := db.CreateDevice(context.Background(), database.CreateDeviceParams{ + UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + DeviceName: "Test Device", + DeviceType: "koreader", + DeviceIdentifier: "test-device-123", + AuthToken: deviceToken, + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err, "Should create device") + + deviceIDBytes := device.ID.Bytes[0:16] + deviceID, err := uuid.FromBytes(deviceIDBytes) + require.NoError(t, err, "Should parse device ID") + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/queue/devices/%s/items", deviceID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should list device queue items") + + var response []interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + assert.NotNil(t, response, "Should have queue items array") +} + +func TestRetryQueueItem(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + itemID := uuid.New().String() + + req := httptest.NewRequest("POST", fmt.Sprintf("/api/queue/items/%s/retry", itemID), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should retry queue item") +} + +func TestDeleteQueueItem(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + itemID := uuid.New().String() + + req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/queue/items/%s", itemID), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should delete queue item") +} + +func TestClearDeviceQueue(t *testing.T) { + ts, db, _, _ := setupTestServer(t) + defer ts.Close() + + token := loginTestUser(t, ts, db) + + userID := getTestUserID(t, db) + + deviceToken := fmt.Sprintf("dev_%s", uuid.New().String()) + device, err := db.CreateDevice(context.Background(), database.CreateDeviceParams{ + UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + DeviceName: "Test Device", + DeviceType: "koreader", + DeviceIdentifier: "test-device-123", + AuthToken: deviceToken, + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err, "Should create device") + + deviceIDBytes := device.ID.Bytes[0:16] + deviceID, err := uuid.FromBytes(deviceIDBytes) + require.NoError(t, err, "Should parse device ID") + + req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/queue/devices/%s/clear", deviceID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "Should clear device queue") +} + +func TestQueueEndpoints_Unauthorized(t *testing.T) { + ts, _, _, _ := setupTestServer(t) + defer ts.Close() + + tests := []struct { + name string + method string + endpoint string + body []byte + expectedStatus int + }{ + {"ListAllQueueItems", "GET", "/api/queue/items", nil, http.StatusUnauthorized}, + {"GetDeviceQueueStats", "GET", "/api/queue/devices/test-device-id/stats", nil, http.StatusUnauthorized}, + {"ListDeviceQueueItems", "GET", "/api/queue/devices/test-device-id/items", nil, http.StatusUnauthorized}, + {"RetryQueueItem", "POST", "/api/queue/items/test-item-id/retry", nil, http.StatusUnauthorized}, + {"DeleteQueueItem", "DELETE", "/api/queue/items/test-item-id", nil, http.StatusUnauthorized}, + {"ClearDeviceQueue", "DELETE", "/api/queue/devices/test-device-id/clear", nil, http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.endpoint, bytes.NewReader(tt.body)) + rec := httptest.NewRecorder() + ts.Config.Handler.ServeHTTP(rec, req) + + assert.Equal(t, tt.expectedStatus, rec.Code, "Should require authentication") + }) + } +} + +func loginAdminUser(t *testing.T, ts *httptest.Server, db *database.Queries) string { + passwordHash := "$2a$10$rKvZ.HZx3lLJ6IQCpH1lOukQ/xU8j5cH8mYhPY5YGfXllq5hG8y0Ou" + + adminUser, err := db.CreateUser(context.Background(), database.CreateUserParams{ + Email: "admin@example.com", + Username: "admin", + PasswordHash: passwordHash, + FirstName: pgtype.Text{String: "Admin", Valid: true}, + LastName: pgtype.Text{String: "User", Valid: true}, + Role: "admin", + Theme: pgtype.Text{String: "tokyo-night", Valid: true}, + }) + if err == nil { + userUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:]) + require.NoError(t, err, "Should parse admin user UUID") + return loginUserWithID(t, ts, db, userUUID, "admin@example.com", "Test@Pass123!") + } + + user, err := db.GetUserByEmail(context.Background(), "admin@example.com") + if err == nil { + userUUID, err := uuid.FromBytes(user.ID.Bytes[:]) + require.NoError(t, err, "Should parse admin user UUID") + return loginUserWithID(t, ts, db, userUUID, "admin@example.com", "Test@Pass123!") + } + + _, err = db.ListUsers(context.Background()) + if err == nil { + return loginTestUser(t, ts, 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") + defer resp.Body.Close() + + 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") + return token +} + +func loginUserWithID(t *testing.T, ts *httptest.Server, db *database.Queries, userID uuid.UUID, 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 +}