- Implement SyncQueueProcessor with 5-second polling interval - Add priority-based queuing (1-10 scale) - Add exponential backoff retry logic (1m, 5m, 15m, 1h, 24h) - Add stuck item detection (> 1 hour in processing state) - Add batch processing (50 items per cycle) - Add comprehensive test suite (15+ test cases) - Test enqueue/dequeue, priority ordering, retry logic, concurrent operations
138 lines
4.2 KiB
Go
138 lines
4.2 KiB
Go
package sync
|
|
|
|
import (
|
|
"bookmann/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
func TestSyncQueueProcessor_EnqueueProgress(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := setupTestDB(t)
|
|
defer teardownTestDB(t, db)
|
|
|
|
processor := 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 := &ProgressUpdate{
|
|
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,
|
|
})
|
|
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, SyncStatusPending, item.Status.String)
|
|
assert.Equal(t, int32(PriorityPageTurn), item.Priority.Int32)
|
|
|
|
var syncData map[string]interface{}
|
|
err = json.Unmarshal(item.SyncData, &syncData)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, percentage, syncData["percentage"])
|
|
assert.Equal(t, "koreader", syncData["source"])
|
|
}
|
|
|
|
func TestCalculateNextRetry(t *testing.T) {
|
|
processor := &SyncQueueProcessor{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
attempts int32
|
|
minDelay time.Duration
|
|
maxDelay time.Duration
|
|
}{
|
|
{"Attempt 0", 0, 0, 1 * time.Second},
|
|
{"Attempt 1", 1, 59 * time.Second, 61 * time.Second},
|
|
{"Attempt 2", 2, 4*time.Minute + 59*time.Second, 5*time.Minute + 1*time.Second},
|
|
{"Attempt 3", 3, 14*time.Minute + 59*time.Second, 15*time.Minute + 1*time.Second},
|
|
{"Attempt 4", 4, 59*time.Minute + 59*time.Second, 60*time.Minute + 1*time.Second},
|
|
{"Attempt 5", 5, 23*time.Hour + 59*time.Minute, 24*time.Hour + 1*time.Minute},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
nextRetry := processor.calculateNextRetry(tt.attempts)
|
|
delay := nextRetry.Sub(time.Now())
|
|
|
|
assert.GreaterOrEqual(t, delay, tt.minDelay, "delay should be at least minDelay")
|
|
assert.LessOrEqual(t, delay, tt.maxDelay, "delay should be at most maxDelay")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSyncTypeConstants(t *testing.T) {
|
|
assert.Equal(t, "progress", SyncTypeProgress)
|
|
assert.Equal(t, "note", SyncTypeNote)
|
|
assert.Equal(t, "highlight", SyncTypeHighlight)
|
|
assert.Equal(t, "bookmark", SyncTypeBookmark)
|
|
}
|
|
|
|
func TestSyncStatusConstants(t *testing.T) {
|
|
assert.Equal(t, "pending", SyncStatusPending)
|
|
assert.Equal(t, "processing", SyncStatusProcessing)
|
|
assert.Equal(t, "completed", SyncStatusCompleted)
|
|
assert.Equal(t, "failed", SyncStatusFailed)
|
|
}
|
|
|
|
func TestPriorityConstants(t *testing.T) {
|
|
assert.Equal(t, int32(1), PriorityUserInitiated)
|
|
assert.Equal(t, int32(2), PriorityBookCompletion)
|
|
assert.Equal(t, int32(3), PriorityCriticalNote)
|
|
assert.Equal(t, int32(5), PriorityPageTurn)
|
|
assert.Equal(t, int32(7), PriorityCheckpoint)
|
|
assert.Equal(t, int32(10), PriorityBackgroundSync)
|
|
}
|
|
|
|
func setupTestDB(t *testing.T) *database.Queries {
|
|
ctx := context.Background()
|
|
|
|
dbURL := "postgresql://postgres:postgres@localhost:5432/bookmann?sslmode=disable"
|
|
dbPool, err := pgxpool.New(ctx, dbURL)
|
|
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 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.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test%'")
|
|
dbPool.Close()
|
|
})
|
|
|
|
return db
|
|
}
|
|
|
|
func teardownTestDB(t *testing.T, db *database.Queries) {
|
|
// Cleanup is handled in setupTestDB via t.Cleanup
|
|
}
|