Files
bookorbit/koreader-plugin/spec/bookorbit_api_download_test.lua
T
neon edc2a2ccfc feat(reader): make KOReader plugin sync non-blocking and resumable
Lifecycle handlers previously blocked on network work, so closing or suspending a book stalled the
device on slow or offline links. Persist snapshots first and drain them through a lifecycle outbox
with connected-state gates and phase-aware retries, so the UI stays responsive and pending work
survives restarts.

Move persistence behind a state manager with atomic flushes, and chunk sweeps, bulk downloads, and
annotation exchange through a background queue with checkpoints and transfer policies, so large
libraries make progress incrementally instead of restarting from scratch after an interruption.

Server side, add bulk manifest and page stats endpoints with batched queries and catalog query
filtering so the plugin can reconcile many books per round trip rather than one request per book.
2026-07-28 02:06:50 +00:00

341 lines
12 KiB
Lua

-- Proves BookOrbitApi transfers files through a subprocess-capable path that
-- publishes atomically, enforces response bounds and never lets two nested
-- workers own the same request.
local response = {
code = 200,
headers = { ["content-type"] = "image/jpeg" },
chunks = { "JFIF-part-one", "JFIF-part-two" },
on_chunk = nil,
}
local requests = {}
package.loaded["socket.http"] = {
request = function(request)
table.insert(requests, request)
local active = response
if response.queue and #response.queue > 0 then
active = table.remove(response.queue, 1)
end
for _, chunk in ipairs(active.chunks or {}) do
if response.on_chunk then response.on_chunk(chunk) end
local ok, err = request.sink(chunk)
if not ok then return nil, err end
end
request.sink(nil)
return 1, active.code, active.headers, active.status or "OK"
end,
}
package.loaded["ltn12"] = {
sink = {
file = function(handle)
return function(chunk, err)
if err then return nil, err end
if not chunk then
handle:close()
return 1
end
if chunk ~= "" then handle:write(chunk) end
return 1
end
end,
table = function(parts)
return function(chunk)
if chunk and chunk ~= "" then table.insert(parts, chunk) end
return 1
end
end,
},
source = {
string = function(value)
return value
end,
},
}
local encoded_subprocess_result
package.loaded["rapidjson"] = {
null = setmetatable({}, { __tostring = function() return "null" end }),
encode = function(value)
if type(value) == "table"
and (value.body ~= nil or value.err ~= nil or value.errbody ~= nil) then
encoded_subprocess_result = value
return "__subprocess_result__"
end
return "{}"
end,
decode = function(raw)
if raw == "__subprocess_result__" then return encoded_subprocess_result end
return { raw = raw }
end,
}
package.loaded["socket"] = {
skip = function(count, ...)
return select(count + 1, ...)
end,
}
package.loaded["socketutil"] = {
LARGE_BLOCK_TIMEOUT = 10,
LARGE_TOTAL_TIMEOUT = 30,
FILE_BLOCK_TIMEOUT = 15,
FILE_TOTAL_TIMEOUT = 60,
set_timeout = function() end,
reset_timeout = function() end,
}
package.loaded["logger"] = {
dbg = function() end,
warn = function() end,
}
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
urlEncode = function(value)
return tostring(value)
end,
removeFile = function(path)
os.remove(path)
end,
partialMD5 = function(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local content = handle:read("*a")
handle:close()
return "md5-" .. tostring(#content)
end,
}
local forks = 0
local wrapped = false
package.loaded["ui/trapper"] = {
isWrapped = function()
return wrapped
end,
dismissableRunInSubprocess = function(_, task, _, task_returns_simple_string)
if task_returns_simple_string ~= true then
error("subprocess result must use the string envelope")
end
forks = forks + 1
return true, task()
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitApi = require("bookorbit_api")
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 readFile(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local content = handle:read("*a")
handle:close()
return content
end
local function exists(path)
local handle = io.open(path, "rb")
if not handle then return false end
handle:close()
return true
end
local temp_root = os.tmpname()
os.remove(temp_root)
assert(os.execute("mkdir -p '" .. temp_root .. "'") == 0)
local final_path = temp_root .. "/cover.jpg"
local temp_path = temp_root .. "/cover.part"
local api = BookOrbitApi.new{
server_url = "https://books.example.com/api/v1",
username = "reader",
userkey = "key",
background_requests = true,
}
-- A complete response publishes the final file and leaves no temporary behind.
response.on_chunk = function()
assert(not exists(final_path), "destination must not exist while the transfer is in flight")
end
local ok = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
response.on_chunk = nil
assertEqual(ok, true, "successful download reports ok")
assertEqual(readFile(final_path), "JFIF-part-oneJFIF-part-two", "published file holds the whole response")
assertEqual(exists(temp_path), false, "temporary file is consumed by the publish")
os.remove(final_path)
-- A server error publishes nothing and cleans up its temporary file.
response.code = 404
local failed, err = api:downloadBlocking("/thumb", final_path, { temp_path = temp_path })
assertEqual(failed, nil, "http error reports failure")
assertEqual(err, 404, "http error surfaces the status code")
assertEqual(exists(final_path), false, "http error publishes no file")
assertEqual(exists(temp_path), false, "http error removes the temporary file")
response.code = 200
-- An oversized body is cut off before it can be published.
local too_large, size_err = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
max_bytes = 4,
})
assertEqual(too_large, nil, "oversized response fails")
assertEqual(size_err, "response_too_large", "oversized response reports why")
assertEqual(exists(final_path), false, "oversized response publishes no file")
assertEqual(exists(temp_path), false, "oversized response removes the temporary file")
-- A wrong content type never reaches the cover cache.
response.headers = { ["content-type"] = "text/html" }
local wrong_type, type_err = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
assertEqual(wrong_type, nil, "unexpected content type fails")
assertEqual(type_err, "unexpected_content_type", "unexpected content type reports why")
assertEqual(exists(final_path), false, "unexpected content type publishes no file")
assertEqual(exists(temp_path), false, "unexpected content type removes the temporary file")
response.headers = { ["content-type"] = "image/jpeg" }
-- Same-origin redirects are followed explicitly without giving LuaSocket a
-- chance to forward credentials to an arbitrary host.
requests = {}
response.queue = {
{
code = 302,
headers = { location = "/api/v1/redirected" },
chunks = { "redirect body" },
},
{
code = 200,
headers = { ["content-type"] = "image/jpeg" },
chunks = { "redirected image" },
},
}
local redirected = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
assertEqual(redirected, true, "same-origin redirect succeeds")
assertEqual(#requests, 2, "same-origin redirect performs a bounded second request")
assertEqual(requests[1].redirect, false, "LuaSocket automatic redirects are disabled")
assertEqual(readFile(final_path), "redirected image", "redirect response body is not published")
os.remove(final_path)
response.queue = nil
-- Cross-origin redirects are rejected before credentials reach the target.
requests = {}
response.queue = {
{
code = 302,
headers = { location = "https://attacker.example/cover" },
chunks = {},
},
}
local unsafe, redirect_err = api:downloadBlocking("/thumb", final_path, { temp_path = temp_path })
assertEqual(unsafe, nil, "cross-origin redirect fails")
assertEqual(redirect_err, "unsafe_redirect", "cross-origin redirect reports why")
assertEqual(#requests, 1, "cross-origin target is never requested")
assertEqual(exists(final_path), false, "unsafe redirect publishes nothing")
response.queue = nil
-- Outside a Trapper coroutine nothing forks; the call stays blocking.
wrapped = false
forks = 0
assertEqual(api:canForkSubprocess(), false, "no fork without a Trapper coroutine")
api:download("/thumb", final_path, { temp_path = temp_path })
assertEqual(forks, 0, "unwrapped download does not fork")
os.remove(final_path)
-- Inside one, a cover transfer runs in exactly one subprocess.
wrapped = true
forks = 0
assertEqual(api:download("/thumb", final_path, { temp_path = temp_path }), true, "wrapped download succeeds")
assertEqual(forks, 1, "wrapped download forks exactly one worker")
os.remove(final_path)
-- A caller that needs live byte progress cannot use a child, so it stays on
-- the blocking path rather than silently losing its callback.
forks = 0
local progressed = 0
api:download("/thumb", final_path, {
temp_path = temp_path,
progress_cb = function(received)
progressed = received
end,
})
assertEqual(forks, 0, "progress-reporting download does not fork")
assertEqual(progressed, 26, "progress callback still runs")
os.remove(final_path)
-- A background transfer that publishes progress through a snapshot file keeps
-- its worker: the snapshot, not an in-process callback, is the channel back.
local TransferProgress = require("bookorbit_transfer_progress")
local progress_path = temp_root .. "/cover.progress"
forks = 0
assertEqual(api:download("/thumb", final_path, {
temp_path = temp_path,
progress_path = progress_path,
progress_generation = 7,
expected_bytes = 26,
}), true, "snapshot-reporting download succeeds")
assertEqual(forks, 1, "snapshot-reporting download still forks a worker")
local snapshot = TransferProgress.read(progress_path, { generation = 7 })
assertEqual(snapshot ~= nil, true, "the child published a progress snapshot")
assertEqual(snapshot.done, true, "completion is published")
assertEqual(snapshot.received, 26, "the snapshot carries the transferred byte count")
assertEqual(TransferProgress.read(progress_path, { generation = 8 }), nil, "another generation ignores the snapshot")
TransferProgress.cleanup(progress_path)
os.remove(final_path)
-- Parent-owned publishing hands the complete temporary file back instead of
-- renaming it, so a cancelled generation cannot be published by a late child.
forks = 0
local handed_back = api:download("/thumb", final_path, {
temp_path = temp_path,
publish = "parent",
hash = "partial_md5",
})
assertEqual(type(handed_back), "table", "parent publishing returns the transfer result")
assertEqual(handed_back.temp_path, temp_path, "the complete temporary file is handed back")
assertEqual(handed_back.bytes, 26, "the transferred byte count is reported")
assertEqual(handed_back.hash, "md5-26", "the child hashes the file it just wrote")
assertEqual(exists(final_path), false, "parent publishing never renames in the child")
assertEqual(exists(temp_path), true, "the temporary file survives for the parent to publish")
os.remove(temp_path)
-- An owned subprocess covers everything inside it: nested requests run inline
-- in the child instead of forking a second worker.
forks = 0
local completed, result = api:runInSubprocess(function()
api:request("GET", "/koreader/plugin/version")
api:request("GET", "/koreader/plugin/catalog/dashboard")
api:download("/thumb", final_path, { temp_path = temp_path })
return "done"
end)
assertEqual(completed, true, "owned subprocess completes")
assertEqual(result.body, "done", "owned subprocess returns its result")
assertEqual(forks, 1, "one worker owns every call inside the boundary")
assertEqual(api.subprocess_depth, 0, "ownership depth unwinds")
os.remove(final_path)
-- A dismissed request reports cancellation and publishes nothing.
package.loaded["ui/trapper"].dismissableRunInSubprocess = function()
forks = forks + 1
return false
end
forks = 0
local cancelled, cancel_err = api:download("/thumb", final_path, { temp_path = temp_path })
assertEqual(cancelled, nil, "dismissed download fails")
assertEqual(cancel_err, "cancelled", "dismissed download reports cancellation")
assertEqual(exists(final_path), false, "dismissed download publishes no file")
os.execute("rm -rf '" .. temp_root .. "'")
print("bookorbit_api_download_test.lua: ok")