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
290 lines
8.5 KiB
Go
290 lines
8.5 KiB
Go
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"])
|
|
})
|
|
}
|