Files
bookhoard/cmd/server/tests/sync_integration_test.go
T
John O'Keefe 8c3d919aa0 wip(rls): engine-side fail-loud + harness seed identity [pin: work interrupted]
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.
2026-10-29 08:44:12 -04:00

278 lines
9.6 KiB
Go

package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupSyncTestDB creates a database connection for sync integration tests
func setupSyncTestDB(t *testing.T) *database.Queries {
ctx := context.Background()
cfg := config.LoadConfig()
cfg.DatabaseHost = "db"
dbURL := cfg.DatabaseURL()
dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database")
db := database.New(dbPool)
t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE
// here silently destroyed a live reader's stats data on every
// suite run (2026-10-14). Sync-test sessions belong to the
// test-sync users; deleting those users cascades their rows.
// Same rule for sync_queue: a `WHERE true` wipe could discard a
// REAL device's pending offline entries — scope to the test
// devices (their cascade covers the rest).
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE device_id IN (SELECT id FROM devices WHERE device_name LIKE 'Test %')")
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
dbPool.Close()
})
return db
}
func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
ctx := context.Background()
hashedPassword := "$2a$10$rKvZHX3lIJ6CpH1lOukQ/xU8j5cH8mYHYP5YGfXllq5hG8y0Ou"
user, err := db.CreateUser(ctx, database.CreateUserParams{
Email: "test-sync@example.com",
Username: "testsyncuser",
PasswordHash: hashedPassword,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "Sync", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err)
return user.ID
}
func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
deviceID := uuid.New()
device, err := db.CreateDevice(ctx, database.CreateDeviceParams{
UserID: userID,
DeviceName: "Test Sync Device",
DeviceType: "koreader",
DeviceIdentifier: deviceID.String(),
AuthToken: "test-sync-token-" + deviceID.String(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err)
return device.ID
}
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
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)
return media.ID
}
func createSyncTestLibrary(t *testing.T, db *database.Queries, userID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
libraryType, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "Should find library type")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: "Test Sync Library",
Description: pgtype.Text{String: "Test library for sync", Valid: true},
LibraryTypeID: libraryType.ID,
CreatedByAdminID: userID,
})
require.NoError(t, err)
return library.ID
}
// TestSyncFull_Initial tests full sync with initial device state
func TestSyncFull_Initial(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create reading history entry
now := time.Now()
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 25.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 50, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 300, Valid: true},
})
require.NoError(t, err)
}
// TestSyncIncremental tests incremental sync
func TestSyncIncremental(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create initial reading history entry
now := time.Now()
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 50.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 100, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 600, Valid: true},
})
require.NoError(t, err)
}
// TestSyncQueueProcessor tests the sync queue processor
func TestSyncQueueProcessor(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create sync queue item
_, err := db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaID,
SyncType: "progress",
SyncData: []byte(`{"percentage": 50}`),
Priority: pgtype.Int4{Int32: 5, Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: "pending", Valid: true},
})
require.NoError(t, err)
// Verify queue item was created
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: int32(10),
})
require.NoError(t, err)
assert.Equal(t, 1, len(items), "Should have one queue item")
assert.Equal(t, "pending", items[0].Status.String)
}
// TestSyncConflictDetection tests conflict detection
func TestSyncConflictDetection(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create reading history entry
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 50.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: time.Now().Add(-1 * time.Hour), Valid: true},
PagesRead: pgtype.Int4{Int32: 100, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 600, Valid: true},
})
require.NoError(t, err)
// Create sync queue item with different percentage (simulating conflict)
_, err = db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaID,
SyncType: "progress",
SyncData: []byte(`{"percentage": 75}`),
Priority: pgtype.Int4{Int32: 5, Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: "pending", Valid: true},
})
require.NoError(t, err)
// Verify both exist (conflict detection would happen during processing)
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: int32(10),
})
require.NoError(t, err)
assert.Equal(t, 1, len(items), "Should have one queue item")
}
// TestSyncWithDeviceAuth tests device authentication in sync
func TestSyncWithDeviceAuth(t *testing.T) {
setup := setupDeviceTest(t)
defer setup.Server.Close()
// Create a device
device := setup.CreateDevice(t, "Test Sync Device", "koreader", "sync-test-123")
// Verify device exists in database
ctx := context.Background()
pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true}
dbDevice, err := setup.DB.GetDevice(ctx, pgDeviceID)
require.NoError(t, err)
assert.Equal(t, "Test Sync Device", dbDevice.DeviceName)
}
// TestSyncEndpoint tests sync endpoint with authentication
func TestSyncEndpoint(t *testing.T) {
setup := setupDeviceTest(t)
defer setup.Server.Close()
device := setup.CreateDevice(t, "Test Sync Device", "koreader", "sync-test-123")
// Test sync endpoint with device token
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
// Should get a response (may be success or error depending on payload)
assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "Should not be unauthorized with valid device token")
}