package main import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bytes" "context" "encoding/json" "io" "net/http" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type conflictTestEnv struct { setup *TestServerSetup mediaID string userID pgtype.UUID mediaPGID pgtype.UUID } func setupConflictTest(t *testing.T) *conflictTestEnv { t.Helper() setup := setupTestServer(t) mediaID := createTestMediaItemID(t, setup) ctx := context.Background() user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal") require.NoError(t, err) mediaUUID, err := uuid.Parse(mediaID) require.NoError(t, err) 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) return conflict } 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result handlers.ConflictListResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result handlers.ConflictListResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) var result handlers.ConflictListResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var detail handlers.ConflictDetailResponse err = json.NewDecoder(resp.Body).Decode(&detail) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result handlers.ConflictResolveResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result handlers.ConflictResolveResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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, }, "apply_to_all_future_conflicts": false, "reason": "Custom merged position", } 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result handlers.ConflictResolveResponse err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp2.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp2.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp2.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) var result map[string]interface{} err = json.NewDecoder(resp2.Body).Decode(&result) require.NoError(t, err) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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") resp, err := client.Do(req) require.NoError(t, err) defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) 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 func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) }