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

149 lines
7.1 KiB
Lua

package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
package.loaded["datastorage"] = { getSettingsDir = function() return "/unused" end }
package.loaded["dump"] = function() return "{}" end
package.loaded["libs/libkoreader-lfs"] = {
attributes = function() return nil end,
dir = function() return function() end end,
}
package.loaded["util"] = {
makePath = function() return false end,
writeToFile = function() return false end,
}
local Outbox = require("bookorbit_lifecycle_outbox")
local TempFilesystem = require("helpers/temp_filesystem")
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 fixture = TempFilesystem.new()
local now = 1000
local outbox = Outbox.open{
settings_dir = fixture.path,
now = function() return now end,
rename = function(from, to) return fixture:rename(from, to) end,
remove = os.remove,
write_file = function(content, path) return fixture:write(content, path) end,
make_path = function(path) return fixture:makePath(path) end,
list_dir = function(path) return fixture:list(path) end,
attributes = function(path, attribute) return fixture:attributes(path, attribute) end,
serialize = function(value) return fixture:serialize(value) end,
}
local snapshot = {
digest = "abcdef1234",
ts = 990,
file = "/books/moved.epub",
percentage = 0.5,
progress = "/4/2",
annotations = { { datetime = "2026-01-01", pos0 = "/1" } },
stats_ids = { 17, 23 },
stats_identity_repaired = true,
}
local first = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
assertEqual(outbox:status().count, 1, "first entry is durable")
assertEqual(first.snapshot.annotations[1].pos0, "/1", "captured annotations are stored")
assertEqual(first.snapshot.stats_ids[2], 23, "stable statistics ids are stored")
now = 1001
local replacement_snapshot = {}
for key, value in pairs(snapshot) do replacement_snapshot[key] = value end
replacement_snapshot.stats_ids = { 23, 29 }
local replacement = assert(outbox:enqueue(replacement_snapshot, { reason = "suspend", annotation_sync = true }))
assertEqual(replacement.id, first.id, "unstarted snapshot is deduplicated by digest")
assertEqual(replacement.generation, first.generation + 1, "dedupe publishes a new generation")
assertEqual(outbox:status().count, 1, "dedupe keeps one logical entry")
assertEqual(#replacement.snapshot.stats_ids, 3, "unacknowledged statistics rows survive snapshot replacement")
assertEqual(replacement.snapshot.stats_ids[1], 17, "the oldest pending statistics row is retained")
assertEqual(replacement.snapshot.stats_ids[2], 23, "duplicate statistics rows are deduplicated")
assertEqual(replacement.snapshot.stats_ids[3], 29, "the newest statistics row is appended")
assertEqual(replacement.snapshot.stats_identity_repaired, true,
"a pending identity-repair replay survives snapshot replacement")
local collision_snapshot = {}
for key, value in pairs(snapshot) do collision_snapshot[key] = value end
collision_snapshot.file = "/books/collision.epub"
collision_snapshot.stats_ids = { 41 }
local collision = assert(outbox:enqueue(collision_snapshot, { reason = "close", annotation_sync = true }))
assert(collision.id ~= replacement.id, "colliding files keep independent outbox entries")
assertEqual(outbox:status().count, 2, "a shared digest cannot coalesce distinct files")
outbox:removeEntry(collision.id)
fixture.fail_write = true
local failed, failure = outbox:enqueue(snapshot, { reason = "close" })
fixture.fail_write = false
assertEqual(failed, nil, "injected persistence failure is reported")
assertEqual(failure, "injected_write_failure", "write failure is preserved")
assertEqual(outbox:readLatest(first.id).generation, replacement.generation,
"failed publish preserves the previous durable generation")
fixture.fail_rename = true
local rename_failed, rename_failure = outbox:enqueue(snapshot, { reason = "close" })
fixture.fail_rename = false
assertEqual(rename_failed, nil, "injected rename failure is reported")
assertEqual(rename_failure, "injected_rename_failure", "rename failure is preserved")
assertEqual(outbox:readLatest(first.id).generation, replacement.generation,
"failed atomic rename preserves the previous durable generation")
local started = assert(outbox:markStarted(replacement.id, replacement.generation))
now = 1002
local second = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
assert(second.id ~= started.id, "started entry is not overwritten by a newer snapshot")
assertEqual(outbox:status().count, 2, "started and newer snapshots coexist")
local payload_path = outbox:payloadPath(started.id, started.payload_generation)
local payload_file = assert(io.open(payload_path, "rb"))
local payload_before_ack = payload_file:read("*a")
payload_file:close()
local generation = started.generation
for _, phase in ipairs({ "match", "stats", "annotations", "state", "progress" }) do
local updated = assert(outbox:acknowledge(started.id, generation, phase))
generation = updated.generation
end
payload_file = assert(io.open(payload_path, "rb"))
local payload_after_ack = payload_file:read("*a")
payload_file:close()
assertEqual(payload_after_ack, payload_before_ack,
"phase acknowledgements do not rewrite the immutable snapshot payload")
local acknowledged = outbox:readLatest(started.id)
assertEqual(outbox:isComplete(acknowledged), true, "all phase acknowledgements complete an entry")
local pending = assert(outbox:recordRemotePending(started.id, acknowledged.generation,
"annotations", { to_apply = { add = { { serverId = "remote-1" } } } }))
assertEqual(outbox:isComplete(pending), false, "unapplied remote changes retain the entry")
local cleared = assert(outbox:clearRemotePending(started.id, pending.generation, "annotations"))
assertEqual(outbox:isComplete(cleared), true, "recorded remote outcome can complete the entry")
local stale, stale_err = outbox:acknowledge(started.id, started.generation, "progress")
assertEqual(stale, nil, "stale generation cannot overwrite newer state")
assertEqual(stale_err, "stale_generation", "stale generation is identified")
local fallback_snapshot = {
digest = "1234abcd",
ts = 1003,
annotations = {},
stats_ids = {},
}
local fallback_first = assert(outbox:enqueue(fallback_snapshot, { annotation_sync = false }))
local fallback_latest = assert(outbox:enqueue(fallback_snapshot, { annotation_sync = false }))
assert(fixture:write("not valid lua", outbox:path(fallback_latest.id, fallback_latest.generation)))
assertEqual(outbox:readLatest(fallback_latest.id).generation, fallback_first.generation,
"recovery falls back to the last readable generation")
outbox.hard_entry_limit = outbox:status().count
local blocked, blocked_err = outbox:enqueue({
digest = "9999aaaa",
annotations = {},
stats_ids = {},
}, { annotation_sync = false })
assertEqual(blocked, nil, "hard threshold applies user-visible backpressure")
assertEqual(blocked_err, "hard_limit", "hard threshold never evicts pending entries")
fixture:cleanup()
print("bookorbit_lifecycle_outbox_test.lua: ok")