From 3cecb04e8d539f9e38615d672e6852007fcad833 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 20 Apr 2026 20:43:16 -0400 Subject: [PATCH] test(sync): rewrite conflict tests as real HTTP integration tests Replace the previous mock/httptest-based conflict tests with integration tests that exercise the full HTTP stack against a live test server with a real database. Changes include: - Add shared test helpers (setupConflictTest, createTestConflict, makeConflictData) to reduce boilerplate across test files - Split monolithic TestConflictDetection and TestConflictsBulkOperations into focused test functions per scenario - Test conflict detection, bulk resolution (most_recent, highest_progress, manual strategies), and edge cases (empty IDs, invalid UUIDs, unauthorized access) - Verify actual database state after resolution, not just HTTP response --- cmd/server/tests/conflict_test.go | 750 ++++++++++++++-------- cmd/server/tests/conflicts_bulk_test.go | 815 +++++++++++++----------- 2 files changed, 924 insertions(+), 641 deletions(-) diff --git a/cmd/server/tests/conflict_test.go b/cmd/server/tests/conflict_test.go index f3e0cc1..6965bf5 100644 --- a/cmd/server/tests/conflict_test.go +++ b/cmd/server/tests/conflict_test.go @@ -1,289 +1,525 @@ package main import ( + "bookhoard/internal/database" + "bookhoard/internal/handlers" "bytes" + "context" "encoding/json" - "net/http/httptest" + "net/http" "testing" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -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") - }) +type conflictTestEnv struct { + setup *TestServerSetup + mediaID string + userID pgtype.UUID + mediaPGID pgtype.UUID } -func TestConflictResolution_ChoosingWinner(t *testing.T) { - t.Run("resolve conflict by choosing koreader source", func(t *testing.T) { - conflictID := uuid.New() +func setupConflictTest(t *testing.T) *conflictTestEnv { + t.Helper() - reqBody := map[string]interface{}{ - "winner": "koreader", - "manual_data": nil, - "apply_to_all_future_conflicts": false, - "reason": "More recent progress", - } + setup := setupTestServer(t) + mediaID := createTestMediaItemID(t, setup) - body, err := json.Marshal(reqBody) - assert.NoError(t, err) + ctx := context.Background() + user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") + require.NoError(t, err) - req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + mediaUUID, err := uuid.Parse(mediaID) + require.NoError(t, err) - assert.Equal(t, "POST", req.Method) - assert.Contains(t, req.URL.Path, conflictID.String()) - assert.Contains(t, string(body), "koreader") + return &conflictTestEnv{ + setup: setup, + mediaID: mediaID, + userID: user.ID, + mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true}, + } +} + +func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts { + t.Helper() + ctx := context.Background() + + dataJSON, err := json.Marshal(conflictData) + require.NoError(t, err) + + conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{ + MediaItemID: env.mediaPGID, + UserID: env.userID, + ConflictType: "progress", + ConflictData: dataJSON, }) + require.NoError(t, err) - t.Run("resolve conflict with manual merge data", func(t *testing.T) { - conflictID := uuid.New() + return conflict +} - manualData := map[string]interface{}{ +func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} { + return map[string]interface{}{ + "koreader": map[string]interface{}{ + "source": "koreader", + "timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC), + "data": map[string]interface{}{ + "percentage": koreaderPct, + }, + }, + "kobo": map[string]interface{}{ + "source": "kobo", + "timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC), + "data": map[string]interface{}{ + "percentage": koboPct, + }, + }, + } +} + +func TestConflictList_Empty(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.ConflictListResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 0, result.Total) + assert.Empty(t, result.Conflicts) +} + +func TestConflictList_WithConflicts(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + createTestConflict(t, env, makeConflictData(0.45, 0.42)) + + req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil) + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.ConflictListResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.GreaterOrEqual(t, result.Total, 1) + require.NotEmpty(t, result.Conflicts) + + conflict := result.Conflicts[0] + assert.Equal(t, "progress", conflict.ConflictType) + assert.Equal(t, "unresolved", conflict.ResolutionStatus) + assert.Contains(t, conflict.ConflictData, "koreader") + assert.Contains(t, conflict.ConflictData, "kobo") +} + +func TestConflictList_UnresolvedCount(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + createTestConflict(t, env, makeConflictData(0.45, 0.42)) + + req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil) + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + var result handlers.ConflictListResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.GreaterOrEqual(t, result.Unresolved, 1) +} + +func TestConflictGet_ByID(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil) + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var detail handlers.ConflictDetailResponse + json.NewDecoder(resp.Body).Decode(&detail) + + assert.Equal(t, conflictID, detail.ID) + assert.Equal(t, env.mediaID, detail.MediaItemID) + assert.Equal(t, "progress", detail.ConflictType) + assert.Contains(t, detail.ConflictData, "koreader") + assert.Contains(t, detail.ConflictData, "kobo") +} + +func TestConflictGet_NotFound(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestConflictGet_InvalidID(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestConflictResolve_ByKOReader(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "koreader", + "manual_data": nil, + "apply_to_all_future_conflicts": false, + "reason": "More recent progress", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.ConflictResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.True(t, result.ConflictResolved) + assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo) +} + +func TestConflictResolve_ByKobo(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "kobo", + "apply_to_all_future_conflicts": false, + "reason": "Higher progress", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.ConflictResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.True(t, result.ConflictResolved) + assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo) +} + +func TestConflictResolve_WithManualData(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "manual", + "manual_data": map[string]interface{}{ "percentage": 0.43, "epubcfi": "epubcfi(/6/4/2:20)", "chapter": 3, - "page": 90, - } + }, + "apply_to_all_future_conflicts": false, + "reason": "Custom merged position", + } + body, _ := json.Marshal(resolveReq) - reqBody := map[string]interface{}{ - "winner": "manual", - "manual_data": manualData, - "apply_to_all_future_conflicts": false, - "reason": "Custom merged position", - } + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) - body, err := json.Marshal(reqBody) - assert.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() - req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body)) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.ConflictResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.True(t, result.ConflictResolved) +} + +func TestConflictResolve_ManualWithoutData(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "manual", + "manual_data": nil, + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestConflictResolve_AlreadyResolved(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "koreader", + "reason": "First resolution", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp2, err := client.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp2.StatusCode) +} + +func TestConflictResolve_InvalidWinner(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "nonexistent_source", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestConflictResolve_NotFound(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + resolveReq := map[string]interface{}{ + "winner": "koreader", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestConflictDelete(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil) + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil) + req2.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp2, err := client.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp2.StatusCode) +} + +func TestConflictDelete_NotFound(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestConflictDismissAllResolved(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + resolveReq := map[string]interface{}{ + "winner": "koreader", + } + body, _ := json.Marshal(resolveReq) + + req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil) + req2.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp2, err := client.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&result) + + deleted, ok := result["deleted"].(float64) + assert.True(t, ok) + assert.GreaterOrEqual(t, int(deleted), 1) +} + +func TestConflictEndpoints_RequireAuth(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + t.Run("list conflicts requires auth", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("get conflict requires auth", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("resolve conflict requires auth", func(t *testing.T) { + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") - - assert.Contains(t, string(body), "manual") - assert.Contains(t, string(body), "0.43") + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) - t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) { - conflictID := uuid.New() + t.Run("delete conflict requires auth", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) - 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"]) + t.Run("dismiss-all requires auth", func(t *testing.T) { + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) } diff --git a/cmd/server/tests/conflicts_bulk_test.go b/cmd/server/tests/conflicts_bulk_test.go index cb3238e..64088a5 100644 --- a/cmd/server/tests/conflicts_bulk_test.go +++ b/cmd/server/tests/conflicts_bulk_test.go @@ -12,414 +12,461 @@ import ( "github.com/stretchr/testify/require" ) -// TestConflictsBulkOperations tests bulk conflict resolution operations -func TestConflictsBulkOperations(t *testing.T) { +func TestBulkResolve_MostRecentStrategy(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55)) + + id1 := uuid.UUID(conflict1.ID.Bytes).String() + id2 := uuid.UUID(conflict2.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{id1, id2}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 2, result.Total) + assert.Equal(t, 2, result.Success) + assert.Equal(t, 0, result.Failed) + require.Len(t, result.Results, 2) + + for _, r := range result.Results { + assert.Equal(t, "success", r.Status) + assert.Equal(t, "koreader", r.Winner) + } +} + +func TestBulkResolve_HighestProgressStrategy(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflictDataHighKobo := makeConflictData(0.30, 0.90) + + conflict := createTestConflict(t, env, conflictDataHighKobo) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{conflictID}, + Strategy: "highest_progress", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 1, result.Total) + assert.Equal(t, 1, result.Success) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, "kobo", result.Results[0].Winner) +} + +func TestBulkResolve_ManualStrategy(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{conflictID}, + Strategy: "manual", + WinningSource: "koreader", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 1, result.Total) + assert.Equal(t, 1, result.Success) + assert.Equal(t, "koreader", result.Results[0].Winner) +} + +func TestBulkResolve_ManualStrategy_WithoutWinner(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{conflictID}, + Strategy: "manual", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 1, result.Failed) + assert.Equal(t, "error", result.Results[0].Status) + assert.Contains(t, result.Results[0].Error, "winning_source") +} + +func TestBulkResolve_ManualStrategy_InvalidWinner(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflictID := uuid.UUID(conflict.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{conflictID}, + Strategy: "manual", + WinningSource: "nonexistent_device", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 1, result.Failed) + assert.Contains(t, result.Results[0].Error, "invalid winning source") +} + +func TestBulkResolve_ConflictNotFound(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} - t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String()}, - Strategy: "most_recent", - } - body, _ := json.Marshal(req) + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{uuid.New().String()}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) + assert.Equal(t, http.StatusOK, resp.StatusCode) - t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{}, - Strategy: "most_recent", - } - body, _ := json.Marshal(req) + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{"invalid-uuid"}, - Strategy: "most_recent", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result handlers.BulkResolveResponse - json.NewDecoder(resp.Body).Decode(&result) - - assert.NotEmpty(t, result.Results, "Should have results") - assert.Equal(t, 1, result.Total) - assert.Equal(t, 0, result.Success) - assert.Greater(t, result.Failed, 0) - - firstResult := result.Results[0] - assert.Equal(t, "error", firstResult.Status) - }) - - t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String()}, - Strategy: "invalid_strategy", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - // Bulk operations return 200 OK with individual error results - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result handlers.BulkResolveResponse - json.NewDecoder(resp.Body).Decode(&result) - - assert.NotEmpty(t, result.Results, "Should have results") - assert.Greater(t, result.Total, 0) - assert.Greater(t, result.Failed, 0) - - firstResult := result.Results[0] - assert.Equal(t, "error", firstResult.Status) - // The error will be "conflict not found" since we're using a random UUID - // The invalid strategy would be caught for valid conflict IDs - assert.Contains(t, firstResult.Error, "conflict") - }) - - t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String(), uuid.New().String()}, - Strategy: "most_recent", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result handlers.BulkResolveResponse - json.NewDecoder(resp.Body).Decode(&result) - - assert.NotEmpty(t, result.Results, "Should have results") - assert.Equal(t, 2, result.Total) - }) - - t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String(), uuid.New().String()}, - Strategy: "highest_progress", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result handlers.BulkResolveResponse - json.NewDecoder(resp.Body).Decode(&result) - - assert.NotEmpty(t, result.Results, "Should have results") - assert.Equal(t, 2, result.Total) - }) - - t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String()}, - Strategy: "manual", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result handlers.BulkResolveResponse - json.NewDecoder(resp.Body).Decode(&result) - - assert.NotEmpty(t, result.Results, "Should have results") - }) - - t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) { - req := handlers.BulkResolveRequest{ - ConflictIDs: []string{uuid.New().String()}, - Strategy: "manual", - WinningSource: "device", - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) { - // Send invalid JSON - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json"))) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) + assert.Equal(t, 1, result.Total) + assert.Equal(t, 0, result.Success) + assert.Equal(t, 1, result.Failed) + assert.Contains(t, result.Results[0].Error, "conflict not found") } -// TestConflictsBulkDismiss tests bulk dismiss operations -func TestConflictsBulkDismiss(t *testing.T) { +func TestBulkResolve_EmptyConflictIDs(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} - t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{uuid.New().String()}, - } - body, _ := json.Marshal(req) + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) - - t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{}, - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{"invalid-uuid", uuid.New().String()}, - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - - assert.Contains(t, result, "results") - assert.Contains(t, result, "total") - assert.Contains(t, result, "success") - assert.Contains(t, result, "failed") - - results := result["results"].([]interface{}) - firstResult := results[0].(map[string]interface{}) - assert.Equal(t, "error", firstResult["status"]) - }) - - t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{ - uuid.New().String(), - uuid.New().String(), - uuid.New().String(), - }, - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - - assert.Contains(t, result, "results") - assert.Equal(t, float64(3), result["total"]) - }) - - t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) { - // Send invalid JSON - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json"))) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } -// TestConflictsBulkEscalate tests bulk escalate operations -// NOTE: This test is commented out because the /api/conflicts/bulk-escalate endpoint -// does not exist yet. It was planned in TEST_RELIABILITY_PLAN.md but never implemented. -// Uncomment and update when the endpoint is added. -/* -func TestConflictsBulkEscalate(t *testing.T) { +func TestBulkResolve_InvalidConflictID(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} - t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{uuid.New().String()}, - } - body, _ := json.Marshal(req) + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{"not-a-uuid"}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - }) + assert.Equal(t, http.StatusOK, resp.StatusCode) - t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) { - req := map[string]interface{}{ - "conflict_ids": []string{}, - } - body, _ := json.Marshal(req) + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - // Allow queue processor to process the item before querying for conflicts - time.Sleep(3 * time.Second) - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - - assert.Contains(t, result, "results") - assert.Contains(t, result, "total") - assert.Contains(t, result, "failed") - - results := result["results"].([]interface{}) - firstResult := results[0].(map[string]interface{}) - assert.Equal(t, "error", firstResult["status"]) - }) - - t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) { - conflictIDs := []string{ - uuid.New().String(), - uuid.New().String(), - } - req := map[string]interface{}{ - "conflict_ids": conflictIDs, - } - body, _ := json.Marshal(req) - - httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body)) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+setup.Token) - - resp, err := client.Do(httpReq) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - - assert.Contains(t, result, "results") - assert.Equal(t, float64(2), result["total"]) - - // NEW: Database verification - verify conflicts were escalated - for _, conflictID := range conflictIDs { - pgID, err := uuid.Parse(conflictID) - if err != nil { - continue // Skip invalid UUIDs - } - - conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true}) - if err == nil { - // If conflict exists, verify it was escalated - assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated") - } - } - }) + assert.Equal(t, 1, result.Failed) + assert.Contains(t, result.Results[0].Error, "invalid conflict ID") +} + +func TestBulkResolve_InvalidRequestBody(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestBulkResolve_RequiresAuth(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{uuid.New().String()}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestBulkDismiss_RealConflicts(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55)) + + id1 := uuid.UUID(conflict1.ID.Bytes).String() + id2 := uuid.UUID(conflict2.ID.Bytes).String() + + req := map[string]interface{}{ + "conflict_ids": []string{id1, id2}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, float64(2), result["total"]) + assert.Equal(t, float64(2), result["success"]) + assert.Equal(t, float64(0), result["failed"]) + + results := result["results"].([]interface{}) + require.Len(t, results, 2) + for _, r := range results { + entry := r.(map[string]interface{}) + assert.Equal(t, "success", entry["status"]) + } +} + +func TestBulkDismiss_NotFoundConflict(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, float64(1), result["total"]) + assert.Equal(t, float64(0), result["success"]) + assert.Equal(t, float64(1), result["failed"]) +} + +func TestBulkDismiss_InvalidConflictID(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req := map[string]interface{}{ + "conflict_ids": []string{"invalid-uuid"}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, float64(1), result["total"]) + assert.Equal(t, float64(1), result["failed"]) + assert.Contains(t, result["results"].([]interface{})[0].(map[string]interface{})["error"], "invalid conflict ID") +} + +func TestBulkDismiss_EmptyConflictIDs(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req := map[string]interface{}{ + "conflict_ids": []string{}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestBulkDismiss_InvalidRequestBody(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json"))) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestBulkDismiss_RequiresAuth(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + req := map[string]interface{}{ + "conflict_ids": []string{uuid.New().String()}, + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestBulkResolve_MixedSuccessAndFailure(t *testing.T) { + env := setupConflictTest(t) + client := &http.Client{} + + conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42)) + realID := uuid.UUID(conflict.ID.Bytes).String() + + req := handlers.BulkResolveRequest{ + ConflictIDs: []string{realID, uuid.New().String()}, + Strategy: "most_recent", + } + body, _ := json.Marshal(req) + + httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token) + + resp, err := client.Do(httpReq) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result handlers.BulkResolveResponse + json.NewDecoder(resp.Body).Decode(&result) + + assert.Equal(t, 2, result.Total) + assert.Equal(t, 1, result.Success) + assert.Equal(t, 1, result.Failed) } -*/