Files
bookorbit/koreader-plugin/spec/bookorbit_stats_reader_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

183 lines
7.6 KiB
Lua

-- The statistics reader now binds parameters into prepared statements instead
-- of formatting SQL, and one-shot helpers run on a short-lived session. These
-- assertions pin the binding order, the one-connection-per-call lifecycle and
-- the single reopen a busy database gets before the query is given up on.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local FakeSqlite = require("helpers/fake_sqlite")
package.loaded["datastorage"] = { getSettingsDir = function() return "/nonexistent" end }
package.loaded["logger"] = { dbg = function() end, info = function() end, warn = function() end, err = function() end }
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 function load(respond, opts)
local stats = FakeSqlite.install(respond, opts)
package.loaded["bookorbit_stats_reader"] = nil
return require("bookorbit_stats_reader"), stats
end
-- Row ids and the digest guard.
do
local seen
local Reader, stats = load(function(sql, params)
seen = { sql = sql, params = params }
if sql:find("SELECT id FROM book", 1, true) then
return FakeSqlite.resultSet{ { "7" }, { "9" } }
end
return nil
end)
local ids = Reader.getBookIds("abc123")
assertEqual(#ids, 2, "both statistics rows of one digest come back")
assertEqual(ids[1], 7, "row ids are numbers")
assertEqual(seen.params[1], "abc123", "the digest is bound, not formatted into the SQL")
assertEqual(stats.opens, 1, "a one-shot helper opens one connection")
assertEqual(stats.closes, 1, "and closes it again")
stats.queries = 0
assertEqual(#Reader.getBookIds("not-hex"), 0, "a non-hex digest returns nothing")
assertEqual(stats.queries, 0, "and never reaches the database")
end
-- Collision groups retain each row's metadata, not just the combined id list,
-- so callers can persist a guarded per-row server identity and cursor.
do
local Reader = load(function(sql)
if sql:find("FROM book WHERE md5", 1, true) then
return FakeSqlite.resultSet{
{ "7", "Collision One", "Author A", "100" },
{ "9", "Collision Two", "Author B", "200" },
}
end
return nil
end)
local book = Reader.getBook("abc123")
assertEqual(book.metadata_ambiguous, true, "different metadata marks the digest ambiguous")
assertEqual(book.stats_ambiguous, true, "multiple rows mark statistics routing ambiguous")
assertEqual(#book.rows, 2, "both guarded row identities are retained")
assertEqual(book.rows[1].id, 7, "the first row id is numeric")
assertEqual(book.rows[2].title, "Collision Two", "row metadata stays attached to its id")
assertEqual(book.rows[2].last_open, 200, "row activity remains available independently")
end
-- SQLite UNIQUE constraints permit repeated NULL values, so the collision
-- guard must count rows rather than relying on distinct metadata variants.
do
local Reader = load(function(sql)
if sql:find("FROM book WHERE md5", 1, true) then
return FakeSqlite.resultSet{
{ "7", "Same Title", nil, "100" },
{ "9", "Same Title", nil, "200" },
}
end
return nil
end)
local book = Reader.getBook("abc123")
assertEqual(book.metadata_ambiguous, false, "identical metadata remains usable for matching")
assertEqual(book.stats_ambiguous, true, "two rows still require independent statistics routing")
end
-- Event batching binds the row ids, then the watermark, then the limit.
do
local seen
local Reader = load(function(sql, params)
seen = { sql = sql, params = params }
return FakeSqlite.resultSet{ { "12", "1700000000", "90000", "300" } }
end)
local events = Reader.getEventsAfter({ 3, 4 }, 1500, 500)
assertEqual(#events, 1, "the batch comes back")
assertEqual(events[1].page, 12, "page is numeric")
assertEqual(events[1].durationSeconds, 86400, "an implausible duration is still clamped to a day")
assertEqual(seen.params[1], 3, "the first row id is bound first")
assertEqual(seen.params[2], 4, "then the second")
assertEqual(seen.params[3], 1500, "then the watermark")
assertEqual(seen.params[4], 500, "then the limit")
assertEqual(select(2, seen.sql:gsub("%?", "")), 4, "the statement has one placeholder per bound value")
end
-- Dashboard reading summary.
do
local today = os.date("%Y-%m-%d")
local yesterday = os.date("%Y-%m-%d", os.time() - 86400)
local Reader = load(function(sql)
if sql:find("GROUP BY 1", 1, true) then
return FakeSqlite.resultSet{ { yesterday, "600" }, { today, "1800" } }
end
if sql:find("COALESCE(SUM(", 1, true) then
return FakeSqlite.resultSet{ { "1800" } }
end
if sql:find("SELECT DISTINCT date(", 1, true) then
return FakeSqlite.resultSet{ { today }, { yesterday } }
end
return nil
end)
local summary = Reader.getReadingSummary()
assertEqual(summary.today_seconds, 1800, "today's reading time is summed")
assertEqual(summary.week_seconds, 1800, "the week window is summed too")
assertEqual(summary.streak_days, 2, "consecutive reading days build the streak")
assertEqual(summary.day_seconds[7], 1800, "today lands in the last per-day bucket")
assertEqual(summary.day_seconds[6], 600, "yesterday lands in the bucket before it")
assertEqual(summary.day_seconds[1], 0, "days without reading stay zero")
end
-- A busy database gets one reopen before the query is given up on.
do
local Reader, stats = load(function(sql)
if sql:find("SELECT id FROM book", 1, true) then
return FakeSqlite.resultSet{ { "5" } }
end
return nil
end, { fail_queries = 1 })
local ids = Reader.getBookIds("abc123")
assertEqual(#ids, 1, "the retry after a reopen succeeds")
assertEqual(stats.opens, 2, "exactly one reopen is attempted")
end
-- A statistics session reuses one connection and one statement per shape.
do
local Reader, stats = load(function(sql, params)
if sql:find("FROM book WHERE md5 IS NOT NULL", 1, true) then
local after_id = params[1] or 0
if after_id >= 2 then return nil end
return FakeSqlite.resultSet{
{ "1", "aaa", "A", "", "10" },
{ "2", "bbb", "B", "", "20" },
}
end
if sql:find("MAX(start_time)", 1, true) then
return FakeSqlite.resultSet{ { "1", "500" } }
end
return nil
end)
local session = Reader.openSession()
local rows, last_id = session:bookRowsAfter(0, 50)
assertEqual(#rows, 2, "a page of statistics rows comes back")
assertEqual(last_id, 2, "the cursor advances to the highest id in the page")
assertEqual(#session:bookRowsAfter(last_id, 50), 0, "the next page is empty")
local latest = session:latestEventTimes()
assertEqual(latest[1], 500, "the grouped query reports the newest event per row")
assertEqual(latest[2], nil, "a row with no events is absent rather than zero")
assertEqual(stats.opens, 1, "the session opens exactly one connection")
assertEqual(stats.prepares, 2, "one prepared statement per query shape, reused across pages")
session:close()
assertEqual(stats.closes, 1, "closing the session releases the connection")
assertEqual(session:latestEventTimes(), nil, "a closed session answers nothing rather than reopening")
end
print("bookorbit_stats_reader_test.lua: ok")