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"]) }) }