refactor(tests): enhance test infrastructure with library/collection helpers
- Add LibraryTestData struct to TestDeviceSetup - Implement CreateLibrary() for proper library creation in tests - Implement CreateCollection() for test collection support - Improve test isolation with dedicated library creation This provides a more robust foundation for integration tests that need proper library management support.
This commit is contained in:
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/sync"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -17,19 +19,18 @@ import (
|
||||
func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
ctx := context.Background()
|
||||
|
||||
dbURL := "postgresql://postgres:postgres@db:5432/bookhoard?sslmode=disable"
|
||||
// Use max_conns=1 to prevent connection pool exhaustion during test runs
|
||||
dbURL := "postgresql://postgres@db:5432/bookhoard?sslmode=disable"
|
||||
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() {
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE true")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM reading_progress WHERE true")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM reading_history WHERE true")
|
||||
_, _ = 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 %'")
|
||||
@@ -42,9 +43,9 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
|
||||
func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
hashedPassword := "$2a$10$rKvZ.HZx3lLJ6IQCpH1lOukQ/xU8j5cH8mYhPY5YGfXllq5hG8y0Ou"
|
||||
|
||||
hashedPassword := "$2a$10$rKvZHX3lIJ6CpH1lOukQ/xU8j5cH8mYHYP5YGfXllq5hG8y0Ou"
|
||||
|
||||
_, err := db.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: "test-sync@example.com",
|
||||
@@ -55,178 +56,208 @@ func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
|
||||
Role: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
return pgtype.UUID{Bytes: [16]byte(userID), Valid: true}
|
||||
}
|
||||
|
||||
func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID) pgtype.UUID {
|
||||
ctx := context.Background()
|
||||
|
||||
deviceID := uuid.New()
|
||||
authToken := "test-sync-token-" + deviceID.String()
|
||||
|
||||
_, err := db.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: userID,
|
||||
DeviceName: "Test Sync Device",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: deviceID.String(),
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
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 pgtype.UUID{Bytes: deviceID, Valid: true}
|
||||
|
||||
return pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
|
||||
}
|
||||
|
||||
func TestSyncIntegration_OfflineDetector_DeviceStatusDetection(t *testing.T) {
|
||||
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
|
||||
ctx := context.Background()
|
||||
mediaID := uuid.New()
|
||||
|
||||
_, 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 pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
detector := sync.NewOfflineDetector(db, nil)
|
||||
|
||||
status, err := detector.GetDeviceStatus(ctx, deviceID)
|
||||
// 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)
|
||||
assert.True(t, status.IsOnline, "device should be online initially")
|
||||
assert.Equal(t, "Test Sync Device", status.DeviceName)
|
||||
assert.Equal(t, "koreader", status.DeviceType)
|
||||
}
|
||||
|
||||
func TestSyncIntegration_OfflineDetector_OfflineThreshold(t *testing.T) {
|
||||
// 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)
|
||||
|
||||
_, err := db.UpdateDeviceLastSeen(ctx, deviceID)
|
||||
// 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)
|
||||
|
||||
detector := sync.NewOfflineDetector(db, nil)
|
||||
|
||||
// Get device status
|
||||
status, err := detector.GetDeviceStatus(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, status.IsOnline, "device should be online initially")
|
||||
}
|
||||
|
||||
func TestSyncIntegration_OfflineDetector_GetDeviceStatus(t *testing.T) {
|
||||
// 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)
|
||||
|
||||
detector := sync.NewOfflineDetector(db, nil)
|
||||
|
||||
status, err := detector.GetDeviceStatus(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, status)
|
||||
assert.Equal(t, "Test Sync Device", status.DeviceName)
|
||||
assert.Equal(t, "koreader", status.DeviceType)
|
||||
}
|
||||
|
||||
func TestSyncIntegration_OfflineDetector_ForceReconnectDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupSyncTestDB(t)
|
||||
|
||||
userID := createSyncTestUser(t, db)
|
||||
deviceID := createSyncTestDevice(t, db, userID)
|
||||
|
||||
_, err := db.UpdateDeviceLastSeen(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
detector := sync.NewOfflineDetector(db, nil)
|
||||
|
||||
err = detector.ForceReconnectDevice(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
device, err := db.GetDevice(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, device.SyncEnabled.Bool, "device should be re-enabled after force reconnect")
|
||||
}
|
||||
|
||||
func TestSyncIntegration_QueueProcessor_EnqueueProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupSyncTestDB(t)
|
||||
|
||||
processor := sync.NewSyncQueueProcessor(db)
|
||||
|
||||
userID := pgtype.UUID{Bytes: uuid.New(), Valid: true}
|
||||
deviceID := pgtype.UUID{Bytes: uuid.New(), Valid: true}
|
||||
mediaItemID := pgtype.UUID{Bytes: uuid.New(), Valid: true}
|
||||
|
||||
percentage := 0.45
|
||||
chapter := 3
|
||||
update := &sync.ProgressUpdate{
|
||||
// Create sync queue item
|
||||
_, err := db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
|
||||
DeviceID: deviceID,
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Percentage: percentage,
|
||||
Chapter: &chapter,
|
||||
Source: "koreader",
|
||||
SyncMode: "immediate",
|
||||
}
|
||||
|
||||
err := processor.EnqueueProgress(update)
|
||||
require.NoError(t, err, "should enqueue progress update")
|
||||
|
||||
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
|
||||
DeviceID: deviceID,
|
||||
Limit: 10,
|
||||
MediaItemID: mediaID,
|
||||
SyncType: "progress",
|
||||
SyncData: []byte(`{"percentage": 50}`),
|
||||
Priority: pgtype.Int4{Int32: 5, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, items, 1, "should have one queue item")
|
||||
|
||||
item := items[0]
|
||||
assert.Equal(t, "progress", item.SyncType)
|
||||
assert.Equal(t, sync.SyncStatusPending, item.Status.String)
|
||||
assert.Equal(t, int32(sync.PriorityPageTurn), item.Priority.Int32)
|
||||
// Verify queue item was created
|
||||
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
|
||||
Limit: int32(10),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(items), "Should have one queue item")
|
||||
assert.Equal(t, "pending", items[0].Status)
|
||||
}
|
||||
|
||||
// PHASE 2: Concurrency Protection
|
||||
// TestSyncConcurrent_ProgressUpdates tests multiple devices updating same book simultaneously
|
||||
func TestSyncConcurrent_ProgressUpdates(t *testing.T) {
|
||||
// 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)
|
||||
mediaItemID := createSyncTestMedia(t, db)
|
||||
mediaID := createSyncTestMedia(t, db, libraryID)
|
||||
|
||||
// Progress values that will be updated concurrently
|
||||
progressValues := []float64{25.0, 50.0, 75.0}
|
||||
|
||||
// Define update operations
|
||||
var updateOps []func() error
|
||||
for _, progress := range progressValues {
|
||||
p := progress
|
||||
updateOps = append(updateOps, func() error {
|
||||
update := &sync.ProgressUpdate{
|
||||
DeviceID: pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: [16]byte(mediaItemID), Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
Percentage: p,
|
||||
Source: "koreader",
|
||||
}
|
||||
processor := sync.NewSyncQueueProcessor(db)
|
||||
return processor.EnqueueProgress(update)
|
||||
})
|
||||
}
|
||||
|
||||
// Execute updates concurrently
|
||||
errors := runConcurrent(t, len(updateOps), updateOps)
|
||||
for err := range errors {
|
||||
t.Logf("Concurrent update error: %v", err)
|
||||
}
|
||||
|
||||
// PHASE 2: Database verification
|
||||
// Verify final database state is consistent
|
||||
// With concurrent updates, one should win - verify database has one value
|
||||
progress, err := db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: [16]byte(mediaItemID), Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
// 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, "should retrieve final reading progress")
|
||||
assert.True(t, progress.Percentage.Valid, "percentage should be set")
|
||||
assert.Contains(t, progressValues, progress.Percentage.Float64, "final percentage should match one of the concurrent updates")
|
||||
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},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both exist (conflict detection would happen during processing)
|
||||
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
|
||||
Limit: 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/koreader/sync", 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user