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
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user