Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
271 lines
9.6 KiB
Go
271 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bookhoard/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"
|
|
)
|
|
|
|
// createTestQueueItem creates a complete test fixture: device, library, media item, and sync queue item
|
|
func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (database.SyncQueue, database.Devices) {
|
|
ctx := context.Background()
|
|
|
|
// 1. Create device
|
|
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
device, err := db.CreateDevice(ctx, database.CreateDeviceParams{
|
|
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
|
DeviceName: "Test Device",
|
|
DeviceType: "koreader",
|
|
DeviceIdentifier: "test-device-" + uuid.New().String(),
|
|
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")
|
|
|
|
// 2. Get library type
|
|
libraryType, err := db.GetLibraryTypeByName(ctx, "ebooks")
|
|
require.NoError(t, err, "Should find library type")
|
|
|
|
// 3. Create library
|
|
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
|
Name: "Test Queue Library",
|
|
Description: pgtype.Text{String: "Test library", Valid: true},
|
|
LibraryTypeID: libraryType.ID,
|
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
|
})
|
|
require.NoError(t, err, "Should create library")
|
|
|
|
// 4. Create media item
|
|
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
|
LibraryID: library.ID,
|
|
Title: "Test Book for Queue",
|
|
Authors: []string{"Test Author"},
|
|
FilePath: "/tmp/test.epub",
|
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
|
})
|
|
require.NoError(t, err, "Should create media item")
|
|
|
|
// 5. Create sync queue item
|
|
queueItem, err := db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
|
|
DeviceID: device.ID,
|
|
MediaItemID: mediaItem.ID,
|
|
SyncType: "progress",
|
|
SyncData: []byte(`{"percentage": 50}`),
|
|
Priority: pgtype.Int4{Int32: 5, Valid: true},
|
|
})
|
|
require.NoError(t, err, "Should create sync queue item")
|
|
|
|
return queueItem, device
|
|
}
|
|
|
|
func TestListAllQueueItems_Admin(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
token := setup.Token
|
|
|
|
req := httptest.NewRequest("GET", "/api/queue/items", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should list all queue items")
|
|
|
|
var response map[string]interface{}
|
|
json.Unmarshal(rec.Body.Bytes(), &response)
|
|
assert.NotNil(t, response["items"], "Should have queue items")
|
|
}
|
|
|
|
func TestGetDeviceQueueStats(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
token := setup.Token
|
|
|
|
userID := getTestUserID(t, setup.DB)
|
|
|
|
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
device, err := setup.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()
|
|
setup.Server.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.Contains(t, response, "total_count", "Should have total_count")
|
|
assert.Contains(t, response, "pending_count", "Should have pending_count")
|
|
assert.Contains(t, response, "processing_count", "Should have processing_count")
|
|
assert.Contains(t, response, "completed_count", "Should have completed_count")
|
|
assert.Contains(t, response, "failed_count", "Should have failed_count")
|
|
}
|
|
|
|
func TestListDeviceQueueItems(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
token := setup.Token
|
|
|
|
userID := getTestUserID(t, setup.DB)
|
|
|
|
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
device, err := setup.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()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should list device queue items")
|
|
|
|
var response map[string]interface{}
|
|
json.Unmarshal(rec.Body.Bytes(), &response)
|
|
items, ok := response["items"].([]interface{})
|
|
assert.True(t, ok, "Should have items array")
|
|
assert.NotNil(t, items, "Should have queue items")
|
|
}
|
|
|
|
func TestRetryQueueItem(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
token := setup.Token
|
|
|
|
// Get the current test user's ID from database
|
|
user, err := setup.DB.GetUserByEmail(context.Background(), "testuser@tests.bookhoard.internal")
|
|
require.NoError(t, err, "Should get test user")
|
|
userID, err := uuid.FromBytes(user.ID.Bytes[:])
|
|
require.NoError(t, err, "Should parse user UUID")
|
|
|
|
queueItem, _ := createTestQueueItem(t, setup.DB, userID)
|
|
itemID := uuid.UUID(queueItem.ID.Bytes).String()
|
|
|
|
req := httptest.NewRequest("POST", fmt.Sprintf("/api/queue/items/%s/retry", itemID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should retry queue item")
|
|
}
|
|
|
|
func TestDeleteQueueItem(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
token := setup.Token
|
|
|
|
// Get the current test user's ID from database
|
|
user, err := setup.DB.GetUserByEmail(context.Background(), "testuser@tests.bookhoard.internal")
|
|
require.NoError(t, err, "Should get test user")
|
|
userID, err := uuid.FromBytes(user.ID.Bytes[:])
|
|
require.NoError(t, err, "Should parse user UUID")
|
|
|
|
queueItem, _ := createTestQueueItem(t, setup.DB, userID)
|
|
itemID := uuid.UUID(queueItem.ID.Bytes).String()
|
|
|
|
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/queue/items/%s", itemID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusNoContent, rec.Code, "Should delete queue item")
|
|
}
|
|
|
|
func TestClearDeviceQueue(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
token := setup.Token
|
|
|
|
userID := getTestUserID(t, setup.DB)
|
|
|
|
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
device, err := setup.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()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code, "Should clear device queue")
|
|
}
|
|
|
|
func TestQueueEndpoints_Unauthorized(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
|
|
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()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, tt.expectedStatus, rec.Code, "Should require authentication")
|
|
})
|
|
}
|
|
}
|