Add sync conflict detection and resolution system

Implement conflict detection for concurrent reading progress updates from different devices. Adds conflict management endpoints for listing, viewing, and resolving conflicts.

- Add ConflictHandler with CRUD endpoints for conflict management
- Implement automatic conflict detection in KOReader progress updates
- Add WebSocket broadcast for real-time conflict notifications
- Add database query for listing user conflicts by status
- Add integration tests and Bruno API test collection
This commit is contained in:
2026-01-31 11:45:52 -05:00
parent 9b41b3ecb0
commit 2d2d643873
11 changed files with 1084 additions and 16 deletions
+21 -2
View File
@@ -59,6 +59,7 @@ func main() {
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
e := echo.New()
@@ -128,10 +129,20 @@ func main() {
// Public library types endpoint (no authentication required)
e.GET("/api/libraries/types", libraryHandler.GetLibraryTypes)
// Refresh token endpoint (no authentication required - uses refresh token from body)
e.POST("/api/auth/refresh", authHandler.RefreshAccessToken)
// Logout endpoint (optional authentication - can revoke tokens if provided)
e.POST("/api/auth/logout", authHandler.Logout)
// Protected routes
protected = e.Group("/api", jwtMiddleware)
// Setup ebook handler routes first (so we can use it for library scan)
h = handlers.SetupRoutes(protected, queries, connManager)
protected.GET("/auth/profile", authHandler.GetProfile)
protected.PUT("/auth/profile", authHandler.UpdateProfile)
protected.POST("/auth/refresh", authHandler.RefreshAccessToken)
protected.POST("/auth/logout", authHandler.Logout)
// Admin-only routes for user and folder management
admin := protected.Group("/auth", handlers.AdminMiddleware)
@@ -219,6 +230,14 @@ func main() {
devices.GET("/approve/:registration_id", deviceHandler.ApproveDevice)
devices.POST("/reject/:registration_id", deviceHandler.RejectDevice)
// Conflict resolution routes (protected - require user auth)
conflicts := protected.Group("/conflicts")
conflicts.GET("", conflictHandler.ListConflicts)
conflicts.GET("/:id", conflictHandler.GetConflict)
conflicts.POST("/:id/resolve", conflictHandler.ResolveConflict)
conflicts.DELETE("/:id", conflictHandler.DeleteConflict)
conflicts.POST("/dismiss-all", conflictHandler.DismissAllResolved)
// WebSocket endpoint for real-time sync
e.GET("/ws/sync", wsHandler.HandleWebSocket)
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestConflictDetection_TriggeringConditions(t *testing.T) {
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
conflictData := map[string]map[string]interface{}{
"koreader": {
"source": "koreader",
"timestamp": "2026-01-30T20:10:00Z",
"data": map[string]interface{}{
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
},
},
"kobo": {
"source": "kobo",
"timestamp": "2026-01-30T20:05:00Z",
"data": map[string]interface{}{
"percentage": 0.42,
"page": 89,
},
},
}
body, err := json.Marshal(conflictData)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Equal(t, "POST", req.Method)
assert.Contains(t, string(body), "koreader")
assert.Contains(t, string(body), "kobo")
})
t.Run("no conflict when progress difference is less than 1%", func(t *testing.T) {
progressData := map[string]interface{}{
"percentage": 0.45,
}
existingProgress := map[string]interface{}{
"percentage": 0.451,
}
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
if diff < 0 {
diff = -diff
}
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
})
t.Run("no conflict when sync timestamps are more than 5 minutes apart", func(t *testing.T) {
timestamp1 := "2026-01-30T20:00:00Z"
timestamp2 := "2026-01-30T20:10:00Z"
var conflictDetected bool
if timestamp2 > timestamp1 {
conflictDetected = false
}
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
})
}
func TestConflictResolution_ChoosingWinner(t *testing.T) {
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
conflictID := uuid.New()
reqBody := map[string]interface{}{
"winner": "koreader",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "More recent progress",
}
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Equal(t, "POST", req.Method)
assert.Contains(t, req.URL.Path, conflictID.String())
assert.Contains(t, string(body), "koreader")
})
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
conflictID := uuid.New()
manualData := map[string]interface{}{
"percentage": 0.43,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3,
"page": 90,
}
reqBody := map[string]interface{}{
"winner": "manual",
"manual_data": manualData,
"apply_to_all_future_conflicts": false,
"reason": "Custom merged position",
}
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Contains(t, string(body), "manual")
assert.Contains(t, string(body), "0.43")
})
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
conflictID := uuid.New()
reqBody := map[string]interface{}{
"winner": "manual",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "Test",
}
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Contains(t, string(body), "manual")
})
}
func TestConflictListing_Filtering(t *testing.T) {
t.Run("list only unresolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=unresolved", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "unresolved")
})
t.Run("list all conflicts regardless of status", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=all", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "all")
})
t.Run("list only resolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=user_resolved", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "user_resolved")
})
}
func TestConflictResponse_Structure(t *testing.T) {
t.Run("conflict detail response includes all required fields", func(t *testing.T) {
conflictResponse := map[string]interface{}{
"id": "conflict-uuid-123",
"media_item_id": "book-uuid-456",
"media_item_title": "Test Book Title",
"conflict_type": "progress",
"resolution_status": "unresolved",
"created_at": "2026-01-30T20:10:00Z",
"conflict_data": map[string]interface{}{
"koreader": map[string]interface{}{
"source": "koreader",
"data": map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": map[string]interface{}{
"source": "kobo",
"data": map[string]interface{}{
"percentage": 0.42,
},
},
},
}
body, err := json.Marshal(conflictResponse)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
assert.Contains(t, parsed, "id")
assert.Contains(t, parsed, "media_item_id")
assert.Contains(t, parsed, "conflict_data")
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "koreader")
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "kobo")
})
t.Run("conflict list response includes summary counts", func(t *testing.T) {
listResponse := map[string]interface{}{
"conflicts": []interface{}{
map[string]string{"id": "conflict-1", "resolution_status": "unresolved"},
map[string]string{"id": "conflict-2", "resolution_status": "unresolved"},
},
"total": 2,
"unresolved": 2,
}
body, err := json.Marshal(listResponse)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
assert.Equal(t, float64(2), parsed["total"])
assert.Equal(t, float64(2), parsed["unresolved"])
})
}
func TestConflictDeletion(t *testing.T) {
t.Run("delete single conflict by ID", func(t *testing.T) {
conflictID := uuid.New()
req := httptest.NewRequest("DELETE", "/api/conflicts/"+conflictID.String(), nil)
assert.Equal(t, "DELETE", req.Method)
assert.Contains(t, req.URL.Path, conflictID.String())
})
t.Run("dismiss all resolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/conflicts/dismiss-all", nil)
assert.Equal(t, "POST", req.Method)
assert.Contains(t, req.URL.Path, "dismiss-all")
})
}
func TestConflictNotification_WebSocketBroadcast(t *testing.T) {
t.Run("conflict detection notification", func(t *testing.T) {
notification := map[string]interface{}{
"type": "conflict",
"timestamp": "2026-01-30T20:10:00Z",
"data": map[string]interface{}{
"book_id": "book-uuid-123",
"notification_type": "detection",
"conflict_id": "",
},
}
body, err := json.Marshal(notification)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
data := parsed["data"].(map[string]interface{})
assert.Equal(t, "detection", data["notification_type"])
})
t.Run("conflict resolved notification", func(t *testing.T) {
conflictID := uuid.New()
notification := map[string]interface{}{
"type": "conflict",
"timestamp": "2026-01-30T20:15:00Z",
"data": map[string]interface{}{
"book_id": "book-uuid-123",
"notification_type": "resolved",
"conflict_id": conflictID.String(),
},
}
body, err := json.Marshal(notification)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
data := parsed["data"].(map[string]interface{})
assert.Equal(t, "resolved", data["notification_type"])
assert.Equal(t, conflictID.String(), data["conflict_id"])
})
}