diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go index 41e1ec18..2c0b64ea 100644 --- a/cmd/server/tests/contract_pins_test.go +++ b/cmd/server/tests/contract_pins_test.go @@ -894,6 +894,90 @@ func TestH1_SyncQueueFlush_LandsAndPreservesAudio(t *testing.T) { "the flush must preserve audio_offset_seconds (LWW seed), not NULL it") } +// TestH1_ConflictResolutionPreservesCFIPair is the BOOK-side twin of the +// audio test: a winner carrying epubcfi + context_text must land with its +// locator pair intact — the far-anchor doctrine's coherent pair — instead +// of the old direct-write behavior that NULLed context_text and +// character_offset (because they weren't in its param list), leaving a +// winner CFI standing next to nothing. +func TestH1_ConflictResolutionPreservesCFIPair(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + setup := setupTestServer(t) + fx := createContractFixture(t, setup) + progress := progressURL(setup.Server.URL, fx.itemID) + + // Push 1 (web): percentage + the full CFI/context anchor pair. + cfi := "epubcfi(/6/4/2:15)" + context := "the contract pin context sentence" + resp := doReq(t, "PUT", progress, map[string]interface{}{ + "percentage": 0.5, + "epubcfi": cfi, + "context_text": context, + }, setup.Token) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Push 2 within the window from a second source: percentage-only (the + // koreader-style push) -> conflict detected against push 1. + deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader") + push2Req, err := http.NewRequest("PUT", progress, + strings.NewReader(`{"percentage": 0.3}`)) + require.NoError(t, err) + push2Req.Header.Set("Content-Type", "application/json") + push2Req.Header.Set("Authorization", "Bearer "+setup.Token) + push2Req.Header.Set("X-Device-Token", deviceToken) + resp2, err := (&http.Client{}).Do(push2Req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp2.StatusCode) + resp2.Body.Close() + + // Find the detected conflict. + listResp := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token) + defer listResp.Body.Close() + require.Equal(t, http.StatusOK, listResp.StatusCode) + list := decodeJSON(t, listResp) + var conflictID string + for _, raw := range list["conflicts"].([]interface{}) { + col := raw.(map[string]interface{}) + if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" && col["resolution_status"] == "unresolved" { + conflictID, _ = col["id"].(string) + } + } + require.NotEmpty(t, conflictID, "two-source push should detect a progress conflict") + + // Resolve with the CFI-carrying side as winner. + resolve := doReq(t, "POST", setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", map[string]interface{}{ + "winner": "web", + }, setup.Token) + defer resolve.Body.Close() + require.Equal(t, http.StatusOK, resolve.StatusCode) + + // The winner's ENTIRE locator pair lands: percentage, epubcfi AND + // context_text together (the old write NULLed the context), and the + // loser's percentage does not. + get := doReq(t, "GET", progress, nil, setup.Token) + defer get.Body.Close() + require.Equal(t, http.StatusOK, get.StatusCode) + row := decodeJSON(t, get) + require.Equal(t, 0.5, getFloatField(t, row, "percentage")) + require.Equal(t, cfi, getStringField(t, row, "epubcfi")) + require.Equal(t, context, getStringField(t, row, "context_text")) + + // No duplicate pending conflict from the funneled resolution write. + get2 := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token) + defer get2.Body.Close() + require.Equal(t, http.StatusOK, get2.StatusCode) + for _, raw := range decodeJSON(t, get2)["conflicts"].([]interface{}) { + col := raw.(map[string]interface{}) + if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" { + require.Equal(t, "user_resolved", col["resolution_status"], + "resolution must not spawn a duplicate pending conflict") + } + } +} + // --------------------------------------------------------------------------- // 7. H1 verification — resolving a conflict must preserve unknown fields // ---------------------------------------------------------------------------