Found while verifying H1's "app offline-play -> flush -> position lands"
with the emulator path: the flush has been dead code in two independent
ways, invisible because the first break made the second unreachable.
1. ListPendingSyncQueueItems matched `device_id = $1` with a NULL $1
(the processor passes DeviceID{Valid:false} to enumerate all devices;
uuid = NULL matches no rows). The ticker therefore processed nothing,
ever. The WHERE now treats NULL as "every device" (the same
narg::uuid IS NULL idiom the visibility queries use; EXPLAIN'd — seq
scan on a bounded table, idx_sync_queue_status_priority_created
fits). The processor also dropped its fake-pagination loop, which
re-fetched the same first batch forever once the backlog hit the
batch size (no OFFSET in the query).
2. dbItemToSyncQueueItem dropped SyncData — even with a working
enumerator, every flushed item arrived with an empty payload and
failed JSON unmarshal. Dead code harboring silent breakage, exactly
the plan's thesis.
Callers updated for the renamed RowLimit param (queue handler, offline
detector). TestH1_SyncQueueFlush_LandsAndPreservesAudio drives the real
flow: web PUT seeds audio 900s, a pending checkpoint-style queue row
applies percentage 0.8 through SaveProgress, audio survives, queue row
completes.
Also fixes dashboard_test.go's stale BuildSections signature (the
package's test binary has been unbuildable) — user-approved: tests
designed against old shapes are not worth keeping.
278 lines
9.7 KiB
Go
278 lines
9.7 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(ctx, database.CreateMediaItemParams{
|
|
LibraryID: libraryID,
|
|
Title: "Sync Test Book",
|
|
Author: pgtype.Text{String: "Test Author", Valid: true},
|
|
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")
|
|
}
|