test(contracts): CFI/context winner must land intact through conflict resolution

Book-side twin of the audio-survival test, closing H1's verification
list. Scenario: web push carries percentage + epubcfi + context_text;
a koreader-style percentage-only push from a second source within the
window detects a conflict; the user resolves with the CFI-carrying
side. The funneled resolution must land the winner's ENTIRE locator
pair — percentage 0.5, epubcfi, context_text together (the old direct
writer NULLed context_text and character_offset, breaking the coherent
pair the far-anchor work established) — and must not spawn a duplicate
pending conflict.
This commit is contained in:
John O'Keefe committed 2026-10-17 12:22:36 -04:00
1 parent 09b6693505
commit 14cdbe70eb
1 file changed
+84
+84
View File
@@ -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
// ---------------------------------------------------------------------------