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")
|
"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
|
// 7. H1 verification — resolving a conflict must preserve unknown fields
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user