diff --git a/cmd/server/tests/sync_integration_test.go b/cmd/server/tests/sync_integration_test.go index bb04e5f..54059f4 100644 --- a/cmd/server/tests/sync_integration_test.go +++ b/cmd/server/tests/sync_integration_test.go @@ -183,3 +183,50 @@ func TestSyncIntegration_QueueProcessor_EnqueueProgress(t *testing.T) { assert.Equal(t, sync.SyncStatusPending, item.Status.String) assert.Equal(t, int32(sync.PriorityPageTurn), item.Priority.Int32) } + +// PHASE 2: Concurrency Protection +// TestSyncConcurrent_ProgressUpdates tests multiple devices updating same book simultaneously +func TestSyncConcurrent_ProgressUpdates(t *testing.T) { + ctx := context.Background() + db := setupSyncTestDB(t) + userID := createSyncTestUser(t, db) + deviceID := createSyncTestDevice(t, db, userID) + mediaItemID := createSyncTestMedia(t, db) + + // 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}, + }) + 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") +} diff --git a/cmd/server/tests/test_helpers_db.go b/cmd/server/tests/test_helpers_db.go index 502a7af..3ce4605 100644 --- a/cmd/server/tests/test_helpers_db.go +++ b/cmd/server/tests/test_helpers_db.go @@ -2,12 +2,14 @@ package main import ( "bookhoard/internal/database" + "bookhoard/internal/handlers" "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" + "sync" "testing" "github.com/google/uuid" @@ -122,3 +124,36 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name return libraryID } + +// runConcurrent executes functions concurrently and waits for all to complete +func runConcurrent(t *testing.T, maxConcurrent int, fns []func() error) []error { + if len(fns) == 0 { + return nil + } + if len(fns) < maxConcurrent { + maxConcurrent = len(fns) + } + + errors := make(chan error, len(fns)) + var wg sync.WaitGroup + + for i := 0; i < maxConcurrent; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + if err := fns[idx](); err != nil { + errors <- err + } + }(i) + } + + wg.Wait() + close(errors) + + var allErrors []error + for err := range errors { + allErrors = append(allErrors, err) + } + + return allErrors +}