mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
94eca198c3
Track colliding KOReader statistics rows independently and require verified explicit targets before upload. Keep unresolved history pending with a visible recovery path. Fixes #1457
183 lines
7.6 KiB
Lua
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")
|