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:
1 parent
09b6693505
commit
14cdbe70eb
1 file changed
+84
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user