Files
bookorbit/koreader-plugin/spec/bookorbit_capture_identity_test.lua
T
neon 94eca198c3 fix(stats): route KOReader hash collisions safely
Track colliding KOReader statistics rows independently and require verified explicit targets before upload. Keep unresolved history pending with a visible recovery path.

Fixes #1457
2026-09-23 03:27:52 +00:00

231 lines
10 KiB
Lua

-- The close and suspend handlers are on a hard latency budget, so the snapshot
-- they capture must not open statistics.sqlite3. Identity is primed once the
-- reader is ready instead; a cold cache still resolves, and a cached miss falls
-- back at drain time.
package.path = "koreader-plugin/spec/?.lua;" .. package.path
local FakeSqlite = require("helpers/fake_sqlite")
package.loaded["datastorage"] = {
getSettingsDir = function() return "/nonexistent" end,
}
local book_rows = { { "42", "Statistics Title", "Statistics Author", "1700000000" } }
local sqlite = FakeSqlite.install(function(sql)
if sql:find("FROM book WHERE md5", 1, true) then
return FakeSqlite.resultSet(book_rows)
end
return nil
end)
package.loaded["logger"] = {
dbg = function() end,
err = function(_, message) error(message) end,
info = function() end,
warn = function() end,
}
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function() return nil end,
saveSetting = function() end,
makeTrue = function() end,
flush = function() end,
}
end,
hasSidecarFile = function() return false end,
findSidecarFile = function() return nil end,
}
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["ui/widget/notification"] = { notify = function() end }
package.loaded["ui/trapper"] = { wrap = function(_, callback) callback() end }
package.loaded["ui/uimanager"] = {
scheduleIn = function() end,
getElapsedTimeSinceBoot = function() return 0 end,
show = function() end,
}
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["gettext"] = function(value) return value end
package.loaded["bookorbit_bookmarks"] = {
enabled = function() return false end,
markUnsupported = function() end,
canSkipExchange = function() return false end,
rememberExchanged = function() end,
exchangeBook = function() error("bookmark sync is off in this fixture") end,
}
package.loaded["bookorbit_annotations"] = {
canSkipExchange = function() return false end,
rememberExchanged = function() end,
}
package.loaded["bookorbit_api"] = { new = function() return {} end }
package.loaded["bookorbit_highlight_summary"] = {
normalize = function(value) return value end,
add = function(value) return value end,
hasCounts = function() return false end,
}
package.loaded["bookorbit_state"] = {
applyStatsAck = function() return false end,
isMatchFresh = function() return false end,
applyLibraryVersion = function() return false end,
}
package.loaded["bookorbit_state_manager"] = { session = function() return {} end }
package.loaded["bookorbit_sweep"] = { isRunning = function() return false end }
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitStatsReader = require("bookorbit_stats_reader")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookSync = require("bookorbit_book_sync")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local flushes = 0
local plugin = {
ui = {
document = { file = "/books/a.epub" },
annotation = {
annotations = {
{
drawer = "lighten",
datetime = "2026-07-08 09:00:00",
pos0 = "/body/p[1]",
pos1 = "/body/p[1].12",
text = "captured while open",
},
},
},
doc_settings = { readSetting = function() return {} end },
statistics = {
settings = { is_enabled = true },
id_curr_book = 42,
insertDB = function() flushes = flushes + 1 end,
},
},
getDocumentDigest = function() return "abc123" end,
getLastPercent = function() return 0.5 end,
getLastProgress = function() return "/6/4" end,
}
-- Primed after reader ready: one lookup, then the handler pays nothing.
BookOrbitStatsReader.forgetIdentity()
sqlite.opens = 0
BookOrbitStatsReader.primeIdentity("abc123")
assertEqual(sqlite.opens, 1, "priming performs the single identity lookup")
local snap = BookSync.capture(plugin)
assertEqual(sqlite.opens, 1, "capture opens no database when identity is primed")
assertEqual(flushes, 1, "the pending page-stat flush still runs live at capture")
assertEqual(#snap.stats_ids, 1, "the snapshot carries the statistics row ids")
assertEqual(snap.stats_ids[1], 42, "the row id comes from the primed identity")
assertEqual(snap.stats_row.id, 42, "the exact live statistics row is carried with its metadata")
assertEqual(snap.stats_row_ambiguous, false, "a single statistics row needs no explicit collision routing")
assertEqual(snap.title, "Statistics Title", "stable identity fields come from the cache")
assertEqual(snap.percentage, 0.5, "progress is read live, never cached")
assert(type(snap.ann_signature) == "string" and snap.ann_signature ~= "",
"the snapshot carries the annotation change signal")
-- The snapshot must not alias the cached identity, or a later mutation of one
-- would silently rewrite the other.
snap.stats_ids[1] = 99
local cached = BookOrbitStatsReader.cachedIdentity("abc123")
assertEqual(cached.ids[1], 42, "the snapshot holds its own copy of the row ids")
-- A cold cache still resolves, so a plugin loaded mid-session captures fully.
BookOrbitStatsReader.forgetIdentity()
sqlite.opens = 0
local cold = BookSync.capture(plugin)
assertEqual(sqlite.opens, 1, "a cold capture falls back to a live lookup")
assertEqual(cold.stats_ids[1], 42, "the cold capture carries the same row ids")
local warm = BookSync.capture(plugin)
assertEqual(sqlite.opens, 1, "the fallback primes the cache for the next handler")
assertEqual(warm.stats_ids[1], 42, "the warmed capture carries the same row ids")
-- A genuine partial-MD5 collision still has an exact live KOReader row while
-- the book is open. Capture must preserve that row rather than the whole hash
-- group so the drain can bind and replay it safely.
book_rows = {
{ "42", "Collision One", "Author A", "1700000000" },
{ "43", "Collision Two", "Author B", "1700000100" },
}
plugin.ui.statistics.id_curr_book = 43
BookOrbitStatsReader.forgetIdentity()
local collision = BookSync.capture(plugin)
assertEqual(collision.stats_row_ambiguous, true, "the exact row records that its digest collides")
assertEqual(collision.stats_row.id, 43, "capture selects the live collision row")
assertEqual(collision.stats_row.title, "Collision Two", "the selected row keeps its identity guard")
assertEqual(#collision.stats_ids, 1, "only the live collision row is eligible for this book sync")
assertEqual(collision.stats_ids[1], 43, "the other book's statistics row is never included")
-- A repaired digest can be a cached miss while KOReader still has historical
-- events attached to the current row's old digest. The live row id is exact,
-- so capture carries it under the recomputed file hash without a second query.
book_rows = {}
plugin.ui.statistics.id_curr_book = 115
plugin.bookorbit_document_digest = { file = "/books/a.epub", digest = "abc123", repaired = true }
BookOrbitStatsReader.forgetIdentity()
sqlite.opens = 0
BookOrbitStatsReader.primeIdentity("abc123")
local missing = BookSync.capture(plugin)
assertEqual(sqlite.opens, 1, "a cached miss is not re-queried by the handler")
assertEqual(#missing.stats_ids, 1, "a repaired digest retains the live statistics row")
assertEqual(missing.stats_ids[1], 115, "historical events follow the exact current-book row")
assertEqual(missing.stats_metadata_ambiguous, false, "the exact current row is not ambiguous")
assertEqual(missing.stats_identity_repaired, true, "the snapshot requests an unwatermarked history replay")
local entry, was_cached = BookOrbitStatsReader.cachedIdentity("abc123")
assertEqual(entry, nil, "the cached miss stores no row")
assertEqual(was_cached, true, "a miss is still cached, so the handler stops looking")
assertEqual(select(2, BookOrbitStatsReader.cachedIdentity("other")), false,
"another book is not answered from this book's cache")
-- Bounded state pull.
local MAX_AGE = BookOrbitSidecar.STATE_PULL_MAX_AGE
assertEqual(BookOrbitSidecar.needsStatePull({}, 1000), true,
"a book that never pulled server state pulls now")
local pulled = {}
BookOrbitSidecar.markStatePulled(pulled, 1000)
assertEqual(BookOrbitSidecar.needsStatePull(pulled, 1000 + MAX_AGE), false,
"a recently pulled book makes no state request when nothing changed locally")
assertEqual(BookOrbitSidecar.needsStatePull(pulled, 1000 + MAX_AGE + 1), true,
"the bounded refresh interval brings web-side ratings and reviews back")
assertEqual(BookOrbitSidecar.needsStatePull(pulled, 900), true,
"a stamp in the device's future is treated as expired")
local function read(path)
local file = assert(io.open(path, "rb"))
local content = file:read("*a")
file:close()
return content
end
local plugin_dir = "koreader-plugin/bookorbit.koplugin/"
local book_sync_source = read(plugin_dir .. "bookorbit_book_sync.lua")
local step_state = assert(book_sync_source:match("stepState = function%(ctx%)%s*(.-)%s*stepProgress = function"))
assert(step_state:find("BookOrbitSidecar.needsStatePull", 1, true),
"the state step must bound its forced pull instead of forcing one every time")
assert(not step_state:find("}, true)", 1, true),
"the state payload must no longer be built with an unconditional forced pull")
local main = read(plugin_dir .. "main.lua")
local reader_ready = assert(main:match(
"function BookOrbit:onReaderReady%(%)%s*(.-)%s*local function titleFromFile"))
assert(reader_ready:find("BookOrbitStatsReader.primeIdentity", 1, true),
"identity is primed after reader ready, not inside the lifecycle handlers")
for _, handler_pattern in ipairs({
"function BookOrbit:_onCloseDocument%(%)%s*(.-)%s*function BookOrbit:_onPageUpdate",
"function BookOrbit:_onSuspend%(%)%s*(.-)%s*function BookOrbit:_onNetworkConnected",
}) do
local handler = assert(main:match(handler_pattern))
assert(not handler:find("BookOrbitStatsReader", 1, true),
"lifecycle handlers must not reach for the statistics database themselves")
end
print("bookorbit_capture_identity_test.lua: ok")