feat(server): progress locator coherence — percentage-only writes clear stale anchors
The verify/heal pipeline verified only INBOUND context-bearing writes;
stored-row coherence was never enforced. SaveProgress seeds missing
fields from the existing row, so every percentage-only write (KOReader
low-confidence conversion, KOReader no-pointer payloads, Kobo markup
ReadingSync / analytics / sync-from-server, locator-less queue entries)
silently re-paired its NEW percentage with the PREVIOUS writer's stale
CFI+context — the incoherent row shape that fed the client-side
far-anchor restore floor (and, via the read-side anchor handles, could
even hand a first-party client the OLD position's scroll address).
KOReader's converter already declared the right intent ("storing
percentage only — the row's percentage restores approximately"); the
seeding betrayed it. SaveProgressRequest gains ClearLocator, set by the
percentage-only call sites, and the seeding rule honors it: stale
epubcfi/context/character_offset clear (the upsert assigns columns
directly, so NULL sticks) unless the request carries its own. A
request-carried context survives so the verify/heal pass can still mint
a fresh anchor from it; the HTTP merge path (field-absent PUTs
preserve) and all anchor-bearing writes are untouched. The fixed-layout
branch already cleared locators unconditionally — unchanged.
Consumer audit: percentage — the only thing every sync round-trip
exchanges — is untouched on all paths; KOReader pulls on locator-less
rows already support the nil-CFI shape (builder guards
progress.Epubcfi.Valid); first-party restores go percentage-first
instead of floor-fighting a stale anchor.
Tests: TestProgressWeb_LocatorCoherence pins clear-on-declared,
request-carried-context survival, and merge-path preservation. Full
suite with the change: 218 groups, zero failures.
This commit is contained in:
1 parent
f24a2d6c22
commit
69516ae165
5 files changed
+223
-69
No files matched your search
@@ -656,3 +656,117 @@ func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
|
||||
// percentage-only write that DECLARES no locator (ClearLocator) must clear
|
||||
// any inherited stale anchor instead of re-pairing the new percentage with
|
||||
// the previous writer's CFI/context — the incoherent row shape that fed the
|
||||
// client-side far-anchor restore floor. Request-carried fields always win.
|
||||
func TestProgressWeb_LocatorCoherence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "web",
|
||||
Percentage: pFloat64(0.3),
|
||||
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
|
||||
ContextText: pStr("some anchor context sentence"),
|
||||
DeviceType: "web",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
|
||||
return mediaUUID
|
||||
}
|
||||
|
||||
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
Percentage: pFloat64(0.6),
|
||||
DeviceType: "kobo",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
|
||||
assert.False(t, progress.ContextText.Valid, "stale context cleared")
|
||||
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||
})
|
||||
|
||||
t.Run("request-carried context survives the clear", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "koreader",
|
||||
Percentage: pFloat64(0.7),
|
||||
ContextText: pStr("a brand new context sentence"),
|
||||
DeviceType: "koreader",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
|
||||
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
|
||||
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
|
||||
// The verify gate is inert in the test harness (no media path
|
||||
// resolver wired), so no anchor can be minted here; the rule
|
||||
// under test is that the STALE one is gone.
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
|
||||
})
|
||||
|
||||
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
|
||||
// path (field absent = inherit): the anchor must survive.
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "queue",
|
||||
Percentage: pFloat64(0.4),
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
|
||||
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user