0b180da6a6
The KOReader plugin's incremental books pull went permanently stale: after a while it stopped receiving any updates made from other devices, and only deleting readest_library.sqlite3 + "Pull books now" recovered it (until it re-broke). The iOS/web library was unaffected. Root cause: since #4678 the server keys the books pull on the server-stamped synced_at column, and the web client (computeMaxTimestamp) advances its cursor from synced_at. The koplugin was left on updated_at: pullBooks advanced last_books_pulled_at from max(updated_at, deleted_at). updated_at is client-supplied, and the koplugin bumps it from the device clock (touchBook = os.time()*1000). An e-reader clock ahead of the server (or any row anywhere carrying a future updated_at) drove the cursor past server-now, so the server's synced_at > since filter returned nothing forever. The cursor was also shared between the pull side (compared vs server synced_at) and push-delta detection (getChangedBooks vs local updated_at), so it could not simply be retargeted. Fix: - parseSyncRow reads synced_at; new row_pull_cursor() prefers it and falls back to max(updated_at, deleted_at) for a pre-synced_at server, mirroring computeMaxTimestamp. - Split the cursor: last_books_pulled_at now tracks server synced_at (pull only); new last_books_pushed_at tracks local updated_at for getChangedBooks and is advanced on both pull and push, preserving push dedup. - v2 -> v3 migration seeds the push watermark from the old shared value and resets the pull cursor to 0, auto-healing already-stale installs with one full re-pull (no manual sqlite deletion) and no re-push storm. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1202 lines
57 KiB
Lua
1202 lines
57 KiB
Lua
-- librarystore_spec.lua
|
|
-- Defines the contract for library/librarystore.lua. Written before the
|
|
-- implementation so the spec is the source of truth for "how the store
|
|
-- should behave".
|
|
|
|
local helper = require("spec_helper")
|
|
|
|
-- Convenient time stamps for sorting tests (unix ms)
|
|
local T_OLD = 1700000000000 -- 2023-11-14
|
|
local T_MID = 1750000000000 -- 2025-06-15
|
|
local T_RECENT = 1770000000000 -- 2026-02-01
|
|
|
|
-- Minimal book row factory; tests override fields they care about.
|
|
--
|
|
-- The store only shows a book whose file is reachable: uploaded to cloud
|
|
-- (uploaded_at set) or present locally (local_present = 1). A real
|
|
-- cloud-present book always has its file uploaded — that's what makes it
|
|
-- downloadable — so the factory keeps cloud_present and uploaded_at
|
|
-- consistent: cloud_present = 1 implies an uploaded_at unless the test
|
|
-- pins one. To model a *phantom* cloud record (the book row exists but
|
|
-- the file was never uploaded), pass uploaded_at = false.
|
|
local function book(over)
|
|
over = over or {}
|
|
local row = {
|
|
hash = "h" .. tostring(math.random(2 ^ 31)),
|
|
meta_hash = nil,
|
|
title = "Untitled",
|
|
source_title = nil,
|
|
author = "Anon",
|
|
format = "EPUB",
|
|
metadata_json = nil,
|
|
series = nil,
|
|
series_index = nil,
|
|
group_id = nil,
|
|
group_name = nil,
|
|
cover_path = nil,
|
|
file_path = nil,
|
|
cloud_present = 0,
|
|
local_present = 0,
|
|
uploaded_at = nil,
|
|
progress_lib = nil,
|
|
reading_status = nil,
|
|
last_read_at = nil,
|
|
created_at = T_OLD,
|
|
updated_at = T_OLD,
|
|
deleted_at = nil,
|
|
}
|
|
for k, v in pairs(over) do row[k] = v end
|
|
if row.uploaded_at == false then
|
|
row.uploaded_at = nil -- explicit phantom: cloud record, no file
|
|
elseif row.cloud_present == 1 and row.uploaded_at == nil then
|
|
row.uploaded_at = row.updated_at or T_OLD
|
|
end
|
|
return row
|
|
end
|
|
|
|
describe("LibraryStore", function()
|
|
local LibraryStore
|
|
|
|
before_each(function()
|
|
helper.reset()
|
|
package.loaded["library.librarystore"] = nil
|
|
LibraryStore = require("library.librarystore")
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- Schema construction
|
|
-- =====================================================================
|
|
describe("schema", function()
|
|
it("opens an in-memory DB and creates the books + sync_state tables", function()
|
|
local store = LibraryStore.new({ user_id = "alice" })
|
|
assert.is_not_nil(store)
|
|
assert.are.equal(0, #store:listBooks({}))
|
|
store:close()
|
|
end)
|
|
|
|
it("survives being constructed twice (CREATE IF NOT EXISTS)", function()
|
|
local s1 = LibraryStore.new({ user_id = "alice" })
|
|
s1:upsertBook(book({ hash = "h1", title = "A", cloud_present = 1 }))
|
|
s1:close()
|
|
-- Re-open the same on-disk file: state survives.
|
|
local DataStorage = require("datastorage")
|
|
local path = DataStorage:getSettingsDir() .. "/test.sqlite3"
|
|
local s2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
s2:upsertBook(book({ hash = "h2", title = "B", cloud_present = 1 }))
|
|
s2:close()
|
|
local s3 = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
assert.are.equal(1, #s3:listBooks({})) -- only h2; h1 was in-memory
|
|
s3:close()
|
|
end)
|
|
|
|
it("sets PRAGMA user_version = 3", function()
|
|
local store = LibraryStore.new({ user_id = "alice" })
|
|
assert.are.equal(3, store:getUserVersion())
|
|
store:close()
|
|
end)
|
|
|
|
-- -----------------------------------------------------------------
|
|
-- v1 → v2 migration: ALTER TABLE adds reading_status_updated_at
|
|
-- -----------------------------------------------------------------
|
|
describe("v1 -> v2 migration", function()
|
|
local db_path
|
|
local SQ3 = require("lua-ljsqlite3/init")
|
|
|
|
after_each(function()
|
|
if db_path then
|
|
os.remove(db_path)
|
|
db_path = nil
|
|
end
|
|
end)
|
|
|
|
it("migrates an existing v1 DB: column added, row preserved, user_version=current", function()
|
|
-- Build a temp file path; os.tmpname() creates the file, remove it
|
|
-- so sqlite.open() starts fresh instead of opening an existing file.
|
|
db_path = os.tmpname()
|
|
os.remove(db_path)
|
|
|
|
-- Create a v1 DB: books table WITHOUT reading_status_updated_at
|
|
-- (all other v2 columns present so SCHEMA_SQL indexes succeed),
|
|
-- user_version = 1, one existing row.
|
|
local v1db = SQ3.open(db_path)
|
|
v1db:exec([[
|
|
CREATE TABLE books (
|
|
user_id TEXT NOT NULL,
|
|
hash TEXT NOT NULL,
|
|
meta_hash TEXT,
|
|
title TEXT NOT NULL,
|
|
source_title TEXT,
|
|
author TEXT,
|
|
format TEXT,
|
|
metadata_json TEXT,
|
|
series TEXT,
|
|
series_index REAL,
|
|
group_id TEXT,
|
|
group_name TEXT,
|
|
cover_path TEXT,
|
|
file_path TEXT,
|
|
cloud_present INTEGER NOT NULL DEFAULT 0,
|
|
local_present INTEGER NOT NULL DEFAULT 0,
|
|
uploaded_at INTEGER,
|
|
progress_lib TEXT,
|
|
reading_status TEXT,
|
|
last_read_at INTEGER,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
deleted_at INTEGER,
|
|
PRIMARY KEY (user_id, hash)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS books_user_updated ON books(user_id, updated_at DESC);
|
|
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
|
|
CREATE INDEX IF NOT EXISTS books_user_meta ON books(user_id, meta_hash);
|
|
CREATE INDEX IF NOT EXISTS books_user_group ON books(user_id, group_name);
|
|
CREATE INDEX IF NOT EXISTS books_user_author ON books(user_id, author);
|
|
]])
|
|
v1db:exec("PRAGMA user_version = 1;")
|
|
local ins = v1db:prepare(
|
|
"INSERT INTO books (user_id, hash, title, cloud_present, local_present, reading_status) VALUES (?, ?, ?, ?, ?, ?)")
|
|
ins:reset():bind("alice", "h-v1", "OldBook", 1, 1, "reading"):step()
|
|
ins:close()
|
|
v1db:close()
|
|
|
|
-- Open the v1 file via LibraryStore; migration should run silently.
|
|
local store = LibraryStore.new({ user_id = "alice", db_path = db_path })
|
|
|
|
-- 1. Opening lands at the current schema (migrations are
|
|
-- cumulative: v1 -> v2 adds the column, v2 -> v3 splits cursors).
|
|
assert.are.equal(3, store:getUserVersion())
|
|
|
|
-- 2. The pre-existing row is still readable (no error, title intact)
|
|
local row = store:_getRowRaw("h-v1")
|
|
assert.is_not_nil(row)
|
|
assert.are.equal("OldBook", row.title)
|
|
assert.are.equal("reading", row.reading_status)
|
|
|
|
-- 3. reading_status_updated_at round-trips (column exists and is writable)
|
|
store:upsertBook({
|
|
hash = "h-v1",
|
|
title = "OldBook",
|
|
reading_status_updated_at = 1750000000000,
|
|
})
|
|
local row2 = store:_getRowRaw("h-v1")
|
|
assert.are.equal(1750000000000, row2.reading_status_updated_at)
|
|
|
|
store:close()
|
|
end)
|
|
end)
|
|
|
|
-- -----------------------------------------------------------------
|
|
-- v2 -> v3 migration: split the shared books cursor into a pull
|
|
-- cursor (server synced_at) and a push watermark (local updated_at).
|
|
-- The old shared `last_books_pulled_at` was advanced from client
|
|
-- updated_at, which a fast device clock could push into the future,
|
|
-- starving every later pull (issue #4934). The migration seeds the
|
|
-- new push watermark from the old value (so push dedup is unbroken)
|
|
-- and resets the pull cursor to 0, forcing one full re-pull that
|
|
-- re-establishes it on the server's synced_at clock.
|
|
-- -----------------------------------------------------------------
|
|
describe("v2 -> v3 migration", function()
|
|
local db_path
|
|
local SQ3 = require("lua-ljsqlite3/init")
|
|
|
|
after_each(function()
|
|
if db_path then
|
|
os.remove(db_path)
|
|
db_path = nil
|
|
end
|
|
end)
|
|
|
|
it("resets the pull cursor to 0 and seeds the push watermark from it", function()
|
|
db_path = os.tmpname()
|
|
os.remove(db_path)
|
|
|
|
-- Build a DB carrying a poisoned (future) shared cursor, then
|
|
-- force user_version back to 2 so reopening runs the migration.
|
|
local seed = LibraryStore.new({ user_id = "alice", db_path = db_path })
|
|
seed:setLastPulledAt(4102444800000) -- ~2100-01-01, a future value
|
|
seed:close()
|
|
local raw = SQ3.open(db_path)
|
|
raw:exec("PRAGMA user_version = 2;")
|
|
raw:close()
|
|
|
|
local store = LibraryStore.new({ user_id = "alice", db_path = db_path })
|
|
|
|
assert.are.equal(3, store:getUserVersion())
|
|
-- Pull cursor reset → next sync does a full re-pull.
|
|
assert.are.equal(0, store:getLastPulledAt())
|
|
-- Push watermark seeded from the old shared cursor → no re-push storm.
|
|
assert.are.equal(4102444800000, store:getLastPushedAt())
|
|
store:close()
|
|
end)
|
|
|
|
it("is per-user (only migrates the rows it finds)", function()
|
|
db_path = os.tmpname()
|
|
os.remove(db_path)
|
|
|
|
local a = LibraryStore.new({ user_id = "alice", db_path = db_path })
|
|
a:setLastPulledAt(111)
|
|
a:close()
|
|
local b = LibraryStore.new({ user_id = "bob", db_path = db_path })
|
|
b:setLastPulledAt(222)
|
|
b:close()
|
|
local raw = SQ3.open(db_path)
|
|
raw:exec("PRAGMA user_version = 2;")
|
|
raw:close()
|
|
|
|
local alice = LibraryStore.new({ user_id = "alice", db_path = db_path })
|
|
assert.are.equal(0, alice:getLastPulledAt())
|
|
assert.are.equal(111, alice:getLastPushedAt())
|
|
alice:close()
|
|
local bob = LibraryStore.new({ user_id = "bob", db_path = db_path })
|
|
assert.are.equal(0, bob:getLastPulledAt())
|
|
assert.are.equal(222, bob:getLastPushedAt())
|
|
bob:close()
|
|
end)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- reading_status_updated_at: new column added in schema v2
|
|
-- =====================================================================
|
|
describe("reading_status_updated_at", function()
|
|
it("round-trips reading_status_updated_at through upsert + read", function()
|
|
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
|
|
store:upsertBook({ hash = "h1", title = "T", reading_status = "finished",
|
|
reading_status_updated_at = 1750000000000, local_present = 1 })
|
|
local row = store:_getRowRaw("h1")
|
|
assert.are.equal("finished", row.reading_status)
|
|
assert.are.equal(1750000000000, row.reading_status_updated_at)
|
|
store:close()
|
|
end)
|
|
|
|
it("parseSyncRow reads reading_status_updated_at from the server ISO field", function()
|
|
local parsed = LibraryStore.parseSyncRow({
|
|
book_hash = "h2", title = "T", reading_status = "abandoned",
|
|
reading_status_updated_at = "2026-06-18T00:00:00+00:00", updated_at = "2026-06-18T00:00:00+00:00",
|
|
})
|
|
-- 2026-06-18T00:00:00Z = 1781740800 unix seconds = 1781740800000 ms
|
|
assert.are.equal(1781740800000, parsed.reading_status_updated_at)
|
|
assert.are.equal("abandoned", parsed.reading_status)
|
|
end)
|
|
|
|
it("parseSyncRow reads synced_at from the server ISO field (issue #4934)", function()
|
|
-- synced_at is the server-authoritative pull cursor. Even when a
|
|
-- book carries a future updated_at (a device with a fast clock),
|
|
-- synced_at reflects real server time.
|
|
local parsed = LibraryStore.parseSyncRow({
|
|
book_hash = "h3", title = "T",
|
|
updated_at = "2099-01-01T00:00:00+00:00",
|
|
synced_at = "2026-06-18T00:00:00+00:00",
|
|
})
|
|
assert.are.equal(1781740800000, parsed.synced_at)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- upsertBook
|
|
-- =====================================================================
|
|
describe("upsertBook", function()
|
|
local store
|
|
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("inserts a new row", function()
|
|
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(1, #rows)
|
|
assert.are.equal("Foundation", rows[1].title)
|
|
assert.are.equal("h1", rows[1].hash)
|
|
end)
|
|
|
|
it("updates an existing row by hash", function()
|
|
store:upsertBook(book({ hash = "h1", title = "Foundation", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "h1", title = "Foundation (Revised)", cloud_present = 1 }))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(1, #rows)
|
|
assert.are.equal("Foundation (Revised)", rows[1].title)
|
|
end)
|
|
|
|
it("OR-merges cloud_present and local_present flags", function()
|
|
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 0 }))
|
|
-- Local scanner discovers the file and upserts with local_present=1.
|
|
-- It doesn't know about cloud state, so it omits cloud_present (or sends 0).
|
|
store:upsertBook(book({ hash = "h1", title = "X", local_present = 1 }))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(1, rows[1].cloud_present)
|
|
assert.are.equal(1, rows[1].local_present)
|
|
end)
|
|
|
|
it("explicit cloud_present=0 from a deleted_at sync update DOES set it to 0", function()
|
|
store:upsertBook(book({ hash = "h1", title = "X", cloud_present = 1, local_present = 1 }))
|
|
-- Tombstone update from sync: explicit cloud_present=0 + deleted_at set
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "X",
|
|
cloud_present = 0, local_present = 1,
|
|
deleted_at = T_RECENT,
|
|
_force_cloud_present = true, -- explicit override sentinel
|
|
}))
|
|
local row = store:_getRowRaw("h1")
|
|
assert.are.equal(0, row.cloud_present)
|
|
assert.are.equal(T_RECENT, row.deleted_at)
|
|
end)
|
|
|
|
it("_clear_fields explicitly nulls listed columns even when existing has them set", function()
|
|
-- Tombstoned existing row
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "X",
|
|
cloud_present = 0, local_present = 0,
|
|
deleted_at = T_OLD,
|
|
_force_cloud_present = true,
|
|
}))
|
|
assert.are.equal(T_OLD, store:_getRowRaw("h1").deleted_at)
|
|
-- Re-add with _clear_fields → deleted_at goes to NULL
|
|
store:upsertBook({
|
|
hash = "h1",
|
|
title = "X",
|
|
local_present = 1,
|
|
updated_at = T_RECENT,
|
|
_clear_fields = { "deleted_at" },
|
|
})
|
|
local row = store:_getRowRaw("h1")
|
|
assert.is_nil(row.deleted_at)
|
|
assert.are.equal(1, row.local_present)
|
|
assert.are.equal(T_RECENT, row.updated_at)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- touchBook: bumps updated_at + last_read_at to "now", merges other
|
|
-- fields. Used after the user closes a book in the reader so the
|
|
-- next /sync push carries the up-to-date timestamps + progress.
|
|
-- =====================================================================
|
|
describe("touchBook", function()
|
|
local store
|
|
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("returns nil for an unknown hash (no insert)", function()
|
|
local out = store:touchBook("nope")
|
|
assert.is_nil(out)
|
|
assert.are.equal(0, #store:listBooks({}))
|
|
end)
|
|
|
|
it("updates updated_at + last_read_at to current time", function()
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "X",
|
|
cloud_present = 1,
|
|
updated_at = T_OLD, last_read_at = T_OLD,
|
|
}))
|
|
local before = os.time() * 1000
|
|
local touched = store:touchBook("h1")
|
|
local after = os.time() * 1000
|
|
|
|
assert.is_not_nil(touched)
|
|
assert.is_true(touched.updated_at >= before, "updated_at not bumped: " .. tostring(touched.updated_at))
|
|
assert.is_true(touched.updated_at <= after, "updated_at exceeds now")
|
|
assert.is_true(touched.last_read_at >= before)
|
|
end)
|
|
|
|
it("merges extra fields (e.g. progress_lib) into the row", function()
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "X", cloud_present = 1,
|
|
}))
|
|
local touched = store:touchBook("h1", { progress_lib = "[42,250]" })
|
|
assert.are.equal("[42,250]", touched.progress_lib)
|
|
end)
|
|
|
|
it("preserves cloud_present + local_present (no flag downgrade)", function()
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "X",
|
|
cloud_present = 1, local_present = 1,
|
|
}))
|
|
local touched = store:touchBook("h1")
|
|
assert.are.equal(1, touched.cloud_present)
|
|
assert.are.equal(1, touched.local_present)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- getChangedBooks: returns rows with updated_at > since OR deleted_at
|
|
-- > since. Mirrors useBooksSync.getNewBooks at
|
|
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:22-35.
|
|
-- =====================================================================
|
|
describe("getChangedBooks", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
store:upsertBook(book({ hash = "old", title = "Old", cloud_present = 1, updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "mid", title = "Mid", cloud_present = 1, updated_at = T_MID }))
|
|
store:upsertBook(book({ hash = "new", title = "New", cloud_present = 1, updated_at = T_RECENT }))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("returns ALL books when since=0 (initial sync)", function()
|
|
local out = store:getChangedBooks(0)
|
|
assert.are.equal(3, #out)
|
|
end)
|
|
|
|
it("returns only books changed since the watermark", function()
|
|
local out = store:getChangedBooks(T_MID)
|
|
assert.are.equal(1, #out)
|
|
assert.are.equal("New", out[1].title)
|
|
end)
|
|
|
|
it("returns empty when nothing has changed since the watermark", function()
|
|
local out = store:getChangedBooks(T_RECENT + 1)
|
|
assert.are.equal(0, #out)
|
|
end)
|
|
|
|
it("includes rows where deleted_at > since (tombstone push)", function()
|
|
-- Mark "old" as deleted at T_RECENT — its updated_at is T_OLD
|
|
-- (< since=T_MID), but deleted_at (T_RECENT > T_MID) means
|
|
-- this push needs to carry the tombstone to the server.
|
|
store:upsertBook(book({
|
|
hash = "old", title = "Old",
|
|
cloud_present = 0, _force_cloud_present = true,
|
|
deleted_at = T_RECENT,
|
|
}))
|
|
local out = store:getChangedBooks(T_MID)
|
|
local hashes = {}
|
|
for _, r in ipairs(out) do hashes[r.hash] = true end
|
|
assert.is_true(hashes["old"], "deleted row missing from changed set")
|
|
assert.is_true(hashes["new"], "recent row missing")
|
|
end)
|
|
|
|
it("scopes by user_id", function()
|
|
local DataStorage = require("datastorage")
|
|
local path = DataStorage:getSettingsDir() .. "/changed_multi.sqlite3"
|
|
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
alice:upsertBook(book({ hash = "a1", title = "A", cloud_present = 1, updated_at = T_RECENT }))
|
|
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
|
bob:upsertBook(book({ hash = "b1", title = "B", cloud_present = 1, updated_at = T_RECENT }))
|
|
assert.are.equal(1, #alice:getChangedBooks(0))
|
|
assert.are.equal(1, #bob:getChangedBooks(0))
|
|
alice:close(); bob:close()
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- listBooks: filters, sort, hides ghosts and tombstones
|
|
-- =====================================================================
|
|
describe("listBooks", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "Foundation", author = "Asimov",
|
|
cloud_present = 1, updated_at = T_OLD, last_read_at = T_OLD,
|
|
}))
|
|
store:upsertBook(book({
|
|
hash = "h2", title = "Dune", author = "Herbert",
|
|
cloud_present = 1, local_present = 1,
|
|
updated_at = T_RECENT, last_read_at = T_RECENT,
|
|
}))
|
|
store:upsertBook(book({
|
|
hash = "h3", title = "Hyperion", author = "Simmons",
|
|
local_present = 1, updated_at = T_MID, last_read_at = T_MID,
|
|
}))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("returns all visible books by default sorted by updated_at desc", function()
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(3, #rows)
|
|
assert.are.equal("Dune", rows[1].title) -- T_RECENT
|
|
assert.are.equal("Hyperion", rows[2].title) -- T_MID
|
|
assert.are.equal("Foundation", rows[3].title) -- T_OLD
|
|
end)
|
|
|
|
it("hides rows with deleted_at != null even when local_present=1", function()
|
|
-- Cloud delete arrives; local file still on disk
|
|
store:upsertBook(book({
|
|
hash = "h2", title = "Dune", author = "Herbert",
|
|
cloud_present = 0, local_present = 1,
|
|
deleted_at = T_RECENT,
|
|
_force_cloud_present = true,
|
|
}))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(2, #rows)
|
|
for _, r in ipairs(rows) do assert.is_not.equal("Dune", r.title) end
|
|
end)
|
|
|
|
it("hides rows where neither flag is set (ghost rows)", function()
|
|
-- Manually craft a ghost via direct upsert without flags
|
|
store:upsertBook(book({ hash = "h-ghost", title = "Ghost" }))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(3, #rows)
|
|
for _, r in ipairs(rows) do assert.is_not.equal("Ghost", r.title) end
|
|
end)
|
|
|
|
it("hides a phantom cloud record — cloud_present=1 but file never uploaded", function()
|
|
-- A book row synced from cloud whose file was never uploaded
|
|
-- (uploaded_at NULL) has no cover and cannot be opened. It is
|
|
-- not local either, so it must not appear in the Library.
|
|
store:upsertBook(book({
|
|
hash = "h-phantom", title = "Phantom",
|
|
cloud_present = 1, uploaded_at = false, local_present = 0,
|
|
}))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(3, #rows)
|
|
for _, r in ipairs(rows) do assert.is_not.equal("Phantom", r.title) end
|
|
end)
|
|
|
|
it("shows a phantom cloud record once its file is present locally", function()
|
|
-- Same row as above, but the local scanner found the file.
|
|
-- local_present = 1 makes the book openable, so it shows.
|
|
store:upsertBook(book({
|
|
hash = "h-phantom", title = "Phantom",
|
|
cloud_present = 1, uploaded_at = false, local_present = 1,
|
|
}))
|
|
local rows = store:listBooks({})
|
|
assert.are.equal(4, #rows)
|
|
local found = false
|
|
for _, r in ipairs(rows) do
|
|
if r.title == "Phantom" then found = true end
|
|
end
|
|
assert.is_true(found)
|
|
end)
|
|
|
|
it("filters by case-insensitive substring search across title and author", function()
|
|
local r1 = store:listBooks({ search = "asimov" })
|
|
assert.are.equal(1, #r1)
|
|
assert.are.equal("Foundation", r1[1].title)
|
|
|
|
local r2 = store:listBooks({ search = "DUNE" })
|
|
assert.are.equal(1, #r2)
|
|
assert.are.equal("Dune", r2[1].title)
|
|
|
|
local r3 = store:listBooks({ search = "z-no-match" })
|
|
assert.are.equal(0, #r3)
|
|
end)
|
|
|
|
it("sorts by last_read_at desc", function()
|
|
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
|
assert.are.equal("Dune", rows[1].title)
|
|
end)
|
|
|
|
it("'last_read_at' sort uses COALESCE(updated_at, last_read_at) — bumped updated_at floats the row", function()
|
|
-- Foundation has the OLDEST last_read_at + updated_at in
|
|
-- the fixture, so it sorts last under the default. Now
|
|
-- simulate "Add to Readest" dedupe bumping ONLY its
|
|
-- updated_at: the row should jump to the top because
|
|
-- COALESCE(T_FUTURE, …) = T_FUTURE > all others.
|
|
local T_FUTURE = T_RECENT + 60000 -- one minute after the freshest row
|
|
store:upsertBook({
|
|
hash = "h1", title = "Foundation",
|
|
updated_at = T_FUTURE,
|
|
})
|
|
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
|
assert.are.equal("Foundation", rows[1].title)
|
|
end)
|
|
|
|
it("'last_read_at' sort still falls back to updated_at when last_read_at is NULL", function()
|
|
-- Cloud-only book: last_read_at NULL, updated_at = the freshest.
|
|
store:upsertBook(book({
|
|
hash = "h-cloud", title = "Anathem", author = "Stephenson",
|
|
cloud_present = 1, last_read_at = nil,
|
|
updated_at = T_RECENT + 1000,
|
|
}))
|
|
local rows = store:listBooks({ sort_by = "last_read_at", sort_asc = false })
|
|
assert.are.equal("Anathem", rows[1].title)
|
|
end)
|
|
|
|
it("sorts by title ascending", function()
|
|
local rows = store:listBooks({ sort_by = "title", sort_asc = true })
|
|
assert.are.equal("Dune", rows[1].title)
|
|
assert.are.equal("Foundation", rows[2].title)
|
|
assert.are.equal("Hyperion", rows[3].title)
|
|
end)
|
|
|
|
it("sorts by author descending", function()
|
|
local rows = store:listBooks({ sort_by = "author", sort_asc = false })
|
|
assert.are.equal("Simmons", rows[1].author)
|
|
assert.are.equal("Herbert", rows[2].author)
|
|
assert.are.equal("Asimov", rows[3].author)
|
|
end)
|
|
|
|
it("filters by group when group_by + group_filter are set", function()
|
|
local rows = store:listBooks({ group_by = "author", group_filter = "Asimov" })
|
|
assert.are.equal(1, #rows)
|
|
assert.are.equal("Foundation", rows[1].title)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- listCloudOnlyBooks — the bulk-download candidate set (#4751)
|
|
-- =====================================================================
|
|
-- A cloud-only book is downloadable but not yet on this device:
|
|
-- cloud_present=1, local_present=0, deleted_at IS NULL, and uploaded_at
|
|
-- set (the file actually exists in storage — a phantom record has none).
|
|
describe("listCloudOnlyBooks", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
-- cloud-only, downloadable → included
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "Foundation", author = "Asimov",
|
|
cloud_present = 1, local_present = 0, updated_at = T_OLD,
|
|
}))
|
|
-- on device already (cloud + local) → excluded
|
|
store:upsertBook(book({
|
|
hash = "h2", title = "Dune", author = "Herbert",
|
|
cloud_present = 1, local_present = 1, updated_at = T_RECENT,
|
|
}))
|
|
-- local-only (never uploaded) → excluded
|
|
store:upsertBook(book({
|
|
hash = "h3", title = "Hyperion", author = "Simmons",
|
|
local_present = 1, updated_at = T_MID,
|
|
}))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("returns only cloud-present, not-local books", function()
|
|
local rows = store:listCloudOnlyBooks()
|
|
assert.are.equal(1, #rows)
|
|
assert.are.equal("Foundation", rows[1].title)
|
|
end)
|
|
|
|
it("excludes a phantom cloud record with no uploaded file", function()
|
|
store:upsertBook(book({
|
|
hash = "h-phantom", title = "Phantom",
|
|
cloud_present = 1, uploaded_at = false, local_present = 0,
|
|
}))
|
|
local rows = store:listCloudOnlyBooks()
|
|
assert.are.equal(1, #rows)
|
|
assert.are.equal("Foundation", rows[1].title)
|
|
end)
|
|
|
|
it("excludes cloud-deleted rows even when not yet local", function()
|
|
store:upsertBook(book({
|
|
hash = "h1", title = "Foundation",
|
|
cloud_present = 0, local_present = 0,
|
|
deleted_at = T_RECENT, _force_cloud_present = true,
|
|
}))
|
|
local rows = store:listCloudOnlyBooks()
|
|
assert.are.equal(0, #rows)
|
|
end)
|
|
|
|
it("scopes to the current user", function()
|
|
local bob = LibraryStore.new({ user_id = "bob" })
|
|
assert.are.equal(0, #bob:listCloudOnlyBooks())
|
|
bob:close()
|
|
end)
|
|
|
|
it("returns rows carrying the fields downloadBook needs", function()
|
|
local rows = store:listCloudOnlyBooks()
|
|
assert.are.equal("h1", rows[1].hash)
|
|
assert.are.equal("EPUB", rows[1].format)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- getGroups
|
|
-- =====================================================================
|
|
describe("getGroups", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
store:upsertBook(book({ hash = "a1", title = "F1", author = "Asimov", cloud_present = 1, updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "a2", title = "F2", author = "Asimov", cloud_present = 1, updated_at = T_MID }))
|
|
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1, updated_at = T_RECENT }))
|
|
store:upsertBook(book({ hash = "g1", title = "G1", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "g2", title = "G2", author = "Anon", cloud_present = 1, group_name = "Sci-Fi", updated_at = T_MID }))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("group by author returns {name, count, latest_updated_at}", function()
|
|
local groups = store:getGroups("author")
|
|
local by_name = {}
|
|
for _, g in ipairs(groups) do by_name[g.name] = g end
|
|
assert.are.equal(2, by_name["Asimov"].count)
|
|
assert.are.equal(T_MID, by_name["Asimov"].latest_updated_at)
|
|
assert.are.equal(1, by_name["Herbert"].count)
|
|
assert.are.equal(2, by_name["Anon"].count)
|
|
end)
|
|
|
|
it("group by group_name skips rows with null group_name", function()
|
|
local groups = store:getGroups("group_name")
|
|
assert.are.equal(1, #groups)
|
|
assert.are.equal("Sci-Fi", groups[1].name)
|
|
assert.are.equal(2, groups[1].count)
|
|
end)
|
|
|
|
it("excludes phantom cloud records from group counts", function()
|
|
-- A phantom row (cloud record, no uploaded file, not local) in
|
|
-- the Sci-Fi group must not inflate the group's book count.
|
|
store:upsertBook(book({
|
|
hash = "g-phantom", title = "GP", author = "Anon",
|
|
cloud_present = 1, uploaded_at = false, local_present = 0,
|
|
group_name = "Sci-Fi", updated_at = T_RECENT,
|
|
}))
|
|
local groups = store:getGroups("group_name")
|
|
assert.are.equal(1, #groups)
|
|
assert.are.equal("Sci-Fi", groups[1].name)
|
|
assert.are.equal(2, groups[1].count)
|
|
end)
|
|
|
|
it("memoizes results across calls", function()
|
|
local g1 = store:getGroups("author")
|
|
local g2 = store:getGroups("author")
|
|
-- Same Lua table reference (cache hit, not just equal contents)
|
|
assert.are.equal(g1, g2)
|
|
end)
|
|
|
|
it("invalidates cache after upsertBook", function()
|
|
local g1 = store:getGroups("author")
|
|
store:upsertBook(book({ hash = "h2", title = "D2", author = "Herbert", cloud_present = 1 }))
|
|
local g2 = store:getGroups("author")
|
|
assert.is_not.equal(g1, g2)
|
|
local by_name = {}
|
|
for _, g in ipairs(g2) do by_name[g.name] = g end
|
|
assert.are.equal(2, by_name["Herbert"].count)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- listBookshelfGroups: nested folders for group_name, flat for others
|
|
-- =====================================================================
|
|
describe("listBookshelfGroups", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
-- Top-level folders Fantasy + SciFi, plus a nested Fantasy/Tolkien
|
|
-- and Fantasy/Tolkien/LOTR. One book at root (no group_name).
|
|
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_MID }))
|
|
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, updated_at = T_RECENT }))
|
|
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, updated_at = T_RECENT }))
|
|
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1, updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "u1", title = "Loose", cloud_present = 1, updated_at = T_OLD }))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("at root: returns top-level segments with aggregate counts and per-sort latest_*", function()
|
|
local groups = store:listBookshelfGroups("group_name", nil)
|
|
assert.are.equal(2, #groups) -- Fantasy + SciFi (Loose has no group)
|
|
local by_name = {}
|
|
for _, g in ipairs(groups) do by_name[g.display_name] = g end
|
|
-- Fantasy aggregates Fantasy + Fantasy/Tolkien*2 + Fantasy/Tolkien/LOTR
|
|
assert.are.equal(4, by_name["Fantasy"].count)
|
|
assert.are.equal("Fantasy", by_name["Fantasy"].name)
|
|
assert.are.equal(1, by_name["SciFi"].count)
|
|
-- Per-sort aggregates: Fantasy's max updated = T_RECENT (from
|
|
-- the Tolkien/Silmarillion + LOTR rows). SciFi only has Dune
|
|
-- with T_OLD. Created defaults to T_OLD via the test factory.
|
|
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_updated_at)
|
|
assert.are.equal(T_RECENT, by_name["Fantasy"].latest_last_read_at)
|
|
assert.are.equal(T_OLD, by_name["Fantasy"].latest_created_at)
|
|
assert.are.equal(T_OLD, by_name["SciFi"].latest_updated_at)
|
|
end)
|
|
|
|
it("drilled in: returns immediate children (not descendants)", function()
|
|
local groups = store:listBookshelfGroups("group_name", "Fantasy")
|
|
assert.are.equal(1, #groups) -- only "Tolkien" is a folder; Fantasy itself is a direct-child book
|
|
assert.are.equal("Tolkien", groups[1].display_name)
|
|
assert.are.equal("Fantasy/Tolkien", groups[1].name)
|
|
-- Tolkien aggregate = Tolkien*2 + Tolkien/LOTR
|
|
assert.are.equal(3, groups[1].count)
|
|
end)
|
|
|
|
it("two levels deep: returns LOTR sub-folder only", function()
|
|
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien")
|
|
assert.are.equal(1, #groups)
|
|
assert.are.equal("LOTR", groups[1].display_name)
|
|
assert.are.equal("Fantasy/Tolkien/LOTR", groups[1].name)
|
|
assert.are.equal(1, groups[1].count)
|
|
end)
|
|
|
|
it("at leaf folder: returns no further sub-folders", function()
|
|
local groups = store:listBookshelfGroups("group_name", "Fantasy/Tolkien/LOTR")
|
|
assert.are.equal(0, #groups)
|
|
end)
|
|
|
|
-- BookshelfItem.tsx parity: a single-segment path like "Fantasy"
|
|
-- is itself a folder; the book living at that path is a direct
|
|
-- child of that folder, not a root-level book.
|
|
it("creates a folder for a single-segment group_name", function()
|
|
local s = LibraryStore.new({ user_id = "single" })
|
|
s:upsertBook(book({ hash = "x1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
|
|
local root_groups = s:listBookshelfGroups("group_name", nil)
|
|
assert.are.equal(1, #root_groups)
|
|
assert.are.equal("Fantasy", root_groups[1].name)
|
|
assert.are.equal(1, root_groups[1].count)
|
|
-- And the book itself isn't visible at root, only inside Fantasy
|
|
assert.are.equal(0, #s:listBookshelfBooks({}, "group_name", nil))
|
|
assert.are.equal(1, #s:listBookshelfBooks({}, "group_name", "Fantasy"))
|
|
s:close()
|
|
end)
|
|
|
|
-- BookshelfItem.tsx:43-44 — slashIndex > 0, so a leading slash
|
|
-- doesn't split. "/foo" stays as one segment named "/foo".
|
|
it("leading-slash group_name keeps the slash as part of the segment", function()
|
|
local s = LibraryStore.new({ user_id = "ls" })
|
|
s:upsertBook(book({ hash = "x1", title = "X", group_name = "/foo", cloud_present = 1 }))
|
|
local groups = s:listBookshelfGroups("group_name", nil)
|
|
assert.are.equal(1, #groups)
|
|
assert.are.equal("/foo", groups[1].name)
|
|
assert.are.equal("/foo", groups[1].display_name)
|
|
s:close()
|
|
end)
|
|
|
|
it("author/series: parent_path ignored at root, returns flat groups", function()
|
|
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "h1", title = "D", author = "Herbert", cloud_present = 1 }))
|
|
local groups = store:listBookshelfGroups("author", nil)
|
|
local names = {}
|
|
for _, g in ipairs(groups) do names[g.display_name] = true end
|
|
assert.is_true(names["Asimov"])
|
|
assert.is_true(names["Herbert"])
|
|
end)
|
|
|
|
it("author/series: returns empty when called with non-nil parent_path", function()
|
|
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
|
local groups = store:listBookshelfGroups("author", "Asimov")
|
|
assert.are.equal(0, #groups)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- listBookshelfBooks: ungrouped at root, exact match when drilled in
|
|
-- =====================================================================
|
|
describe("listBookshelfBooks", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "u1", title = "Loose", author = "", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "a1", title = "F", author = "Asimov", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "n1", title = "NoAuth", author = "", cloud_present = 1 }))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("group_name root: returns books with null/empty group_name", function()
|
|
local books = store:listBookshelfBooks({}, "group_name", nil)
|
|
local titles = {}
|
|
for _, b in ipairs(books) do titles[b.title] = true end
|
|
-- Loose, F, NoAuth all lack group_name; Conan/Hobbit don't appear.
|
|
assert.is_true(titles["Loose"])
|
|
assert.is_true(titles["F"])
|
|
assert.is_true(titles["NoAuth"])
|
|
assert.is_nil(titles["Conan"])
|
|
assert.is_nil(titles["Hobbit"])
|
|
end)
|
|
|
|
it("group_name drill-in: returns only direct-child books, not descendants", function()
|
|
local books = store:listBookshelfBooks({}, "group_name", "Fantasy")
|
|
assert.are.equal(1, #books)
|
|
assert.are.equal("Conan", books[1].title)
|
|
-- Hobbit is in Fantasy/Tolkien — listed by drilling further
|
|
end)
|
|
|
|
it("author root: returns books with no author", function()
|
|
local books = store:listBookshelfBooks({}, "author", nil)
|
|
local titles = {}
|
|
for _, b in ipairs(books) do titles[b.title] = true end
|
|
-- Only Loose and NoAuth have empty author; F has Asimov,
|
|
-- Conan/Hobbit default to "Anon" via the factory.
|
|
assert.is_nil(titles["F"])
|
|
assert.is_true(titles["Loose"])
|
|
assert.is_true(titles["NoAuth"])
|
|
end)
|
|
|
|
it("author drill-in: returns books matching the author", function()
|
|
local books = store:listBookshelfBooks({}, "author", "Asimov")
|
|
assert.are.equal(1, #books)
|
|
assert.are.equal("F", books[1].title)
|
|
end)
|
|
|
|
it("group_by=nil: delegates to listBooks (no ungrouped filter)", function()
|
|
local books = store:listBookshelfBooks({}, nil, nil)
|
|
assert.are.equal(5, #books)
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- listBooksInGroup: feeds the group-cover composer
|
|
-- =====================================================================
|
|
describe("listBooksInGroup", function()
|
|
local store
|
|
before_each(function()
|
|
store = LibraryStore.new({ user_id = "alice" })
|
|
-- Same fixture as listBookshelfGroups: nested Fantasy tree.
|
|
store:upsertBook(book({ hash = "f1", title = "Conan", group_name = "Fantasy", cloud_present = 1, last_read_at = T_OLD, updated_at = T_OLD }))
|
|
store:upsertBook(book({ hash = "t1", title = "Hobbit", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
|
|
store:upsertBook(book({ hash = "t2", title = "Silmarillion", group_name = "Fantasy/Tolkien", cloud_present = 1, last_read_at = T_MID, updated_at = T_MID }))
|
|
store:upsertBook(book({ hash = "l1", title = "FotR", group_name = "Fantasy/Tolkien/LOTR", cloud_present = 1, last_read_at = T_RECENT, updated_at = T_RECENT }))
|
|
store:upsertBook(book({ hash = "s1", title = "Dune", group_name = "SciFi", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "a1", title = "Foundation", author = "Asimov", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "a2", title = "Robots", author = "Asimov", cloud_present = 1 }))
|
|
store:upsertBook(book({ hash = "a3", title = "Empire", author = "Asimov", cloud_present = 1 }))
|
|
end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("group_name root: includes books across the whole subtree", function()
|
|
local books = store:listBooksInGroup("group_name", "Fantasy", 4)
|
|
local titles = {}
|
|
for _, b in ipairs(books) do titles[b.title] = true end
|
|
-- All four Fantasy-subtree books visible (Conan + Hobbit + Silmarillion + FotR)
|
|
assert.is_true(titles["Conan"])
|
|
assert.is_true(titles["Hobbit"])
|
|
assert.is_true(titles["Silmarillion"])
|
|
assert.is_true(titles["FotR"])
|
|
end)
|
|
|
|
it("group_name: respects limit", function()
|
|
local books = store:listBooksInGroup("group_name", "Fantasy", 2)
|
|
assert.are.equal(2, #books)
|
|
-- Sorted by COALESCE(updated_at, last_read_at) DESC,
|
|
-- so the two T_RECENT books come first (Hobbit + FotR).
|
|
local titles = {}
|
|
for _, b in ipairs(books) do titles[b.title] = true end
|
|
assert.is_true(titles["Hobbit"] or titles["FotR"])
|
|
end)
|
|
|
|
it("author: matches just that author", function()
|
|
local books = store:listBooksInGroup("author", "Asimov", 4)
|
|
assert.are.equal(3, #books)
|
|
end)
|
|
|
|
it("group_name nested path: only its own subtree", function()
|
|
local books = store:listBooksInGroup("group_name", "Fantasy/Tolkien", 4)
|
|
local titles = {}
|
|
for _, b in ipairs(books) do titles[b.title] = true end
|
|
-- Hobbit + Silmarillion (direct) + FotR (descendant) = 3
|
|
assert.are.equal(3, #books)
|
|
assert.is_true(titles["Hobbit"])
|
|
assert.is_true(titles["Silmarillion"])
|
|
assert.is_true(titles["FotR"])
|
|
assert.is_nil(titles["Conan"]) -- direct child of Fantasy, not Tolkien
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- Multi-account scoping
|
|
-- =====================================================================
|
|
describe("multi-account", function()
|
|
it("scopes books by user_id; alice's books invisible to bob", function()
|
|
local DataStorage = require("datastorage")
|
|
local path = DataStorage:getSettingsDir() .. "/multi.sqlite3"
|
|
|
|
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
alice:upsertBook(book({ hash = "h1", title = "Alice's Book", cloud_present = 1 }))
|
|
assert.are.equal(1, #alice:listBooks({}))
|
|
alice:close()
|
|
|
|
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
|
assert.are.equal(0, #bob:listBooks({}))
|
|
bob:upsertBook(book({ hash = "h1", title = "Bob's Book", cloud_present = 1 }))
|
|
assert.are.equal(1, #bob:listBooks({}))
|
|
assert.are.equal("Bob's Book", bob:listBooks({})[1].title)
|
|
bob:close()
|
|
|
|
-- Re-open as alice; her copy of h1 still says "Alice's Book"
|
|
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
assert.are.equal("Alice's Book", alice2:listBooks({})[1].title)
|
|
alice2:close()
|
|
end)
|
|
|
|
it("scopes sync_state by user_id", function()
|
|
local DataStorage = require("datastorage")
|
|
local path = DataStorage:getSettingsDir() .. "/state.sqlite3"
|
|
|
|
local alice = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
alice:setLastPulledAt(T_RECENT)
|
|
alice:close()
|
|
|
|
local bob = LibraryStore.new({ user_id = "bob", db_path = path })
|
|
assert.is_nil(bob:getLastPulledAt())
|
|
bob:setLastPulledAt(T_OLD)
|
|
assert.are.equal(T_OLD, bob:getLastPulledAt())
|
|
bob:close()
|
|
|
|
local alice2 = LibraryStore.new({ user_id = "alice", db_path = path })
|
|
assert.are.equal(T_RECENT, alice2:getLastPulledAt())
|
|
alice2:close()
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- Sync state
|
|
-- =====================================================================
|
|
describe("sync state", function()
|
|
local store
|
|
before_each(function() store = LibraryStore.new({ user_id = "alice" }) end)
|
|
after_each(function() store:close() end)
|
|
|
|
it("getLastPulledAt returns nil before any setLastPulledAt", function()
|
|
assert.is_nil(store:getLastPulledAt())
|
|
end)
|
|
|
|
it("setLastPulledAt round-trips", function()
|
|
store:setLastPulledAt(T_RECENT)
|
|
assert.are.equal(T_RECENT, store:getLastPulledAt())
|
|
end)
|
|
|
|
it("getLastPushedAt returns nil before any setLastPushedAt", function()
|
|
assert.is_nil(store:getLastPushedAt())
|
|
end)
|
|
|
|
it("setLastPushedAt round-trips independently of the pull cursor", function()
|
|
store:setLastPulledAt(T_OLD)
|
|
store:setLastPushedAt(T_RECENT)
|
|
assert.are.equal(T_OLD, store:getLastPulledAt())
|
|
assert.are.equal(T_RECENT, store:getLastPushedAt())
|
|
end)
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- parseSyncRow: pure helper, no DB access required
|
|
-- =====================================================================
|
|
describe("parseSyncRow", function()
|
|
local DUMMY_HASH = "00000000000000000000000000000000"
|
|
|
|
it("returns nil for the dummy initial-sync hash", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = DUMMY_HASH,
|
|
title = "Dummy", format = "EPUB", author = "",
|
|
deleted_at = "2026-01-01T00:00:00Z",
|
|
})
|
|
assert.is_nil(out)
|
|
end)
|
|
|
|
it("maps snake_case fields to our internal row shape", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1",
|
|
meta_hash = "m1",
|
|
title = "Foundation",
|
|
source_title = "Foundation (Original)",
|
|
author = "Asimov",
|
|
format = "EPUB",
|
|
group_id = "gid1",
|
|
group_name = "Sci-Fi/Classics",
|
|
uploaded_at = "2026-01-01T00:00:00Z",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
created_at = "2025-12-01T00:00:00Z",
|
|
deleted_at = nil,
|
|
})
|
|
assert.are.equal("h1", out.hash)
|
|
assert.are.equal("m1", out.meta_hash)
|
|
assert.are.equal("Foundation", out.title)
|
|
assert.are.equal("Foundation (Original)", out.source_title)
|
|
assert.are.equal("Asimov", out.author)
|
|
assert.are.equal("EPUB", out.format)
|
|
assert.are.equal("gid1", out.group_id)
|
|
assert.are.equal("Sci-Fi/Classics", out.group_name)
|
|
assert.are.equal(1, out.cloud_present)
|
|
assert.is_nil(out.deleted_at)
|
|
end)
|
|
|
|
it("converts ISO timestamps to unix ms", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
uploaded_at = "2026-01-01T00:00:00Z",
|
|
deleted_at = nil,
|
|
})
|
|
-- 2026-02-01T00:00:00Z = 1769904000 unix seconds
|
|
-- 2026-01-01T00:00:00Z = 1767225600 unix seconds
|
|
assert.are.equal(1769904000000, out.updated_at)
|
|
assert.are.equal(1767225600000, out.uploaded_at)
|
|
end)
|
|
|
|
it("accepts Supabase / Postgres timestamps with +HH:MM offset", function()
|
|
-- This is the format Supabase actually emits in /sync responses;
|
|
-- discovered when 1221 cloud books all came back with
|
|
-- updated_at=NULL because the parser only matched Z-suffix.
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00+00:00",
|
|
uploaded_at = "2024-07-13T16:00:00.123456+00:00",
|
|
})
|
|
assert.are.equal(1769904000000, out.updated_at)
|
|
-- 2024-07-13T16:00:00.123 UTC = 1720886400.123 sec → 1720886400123 ms
|
|
assert.are.equal(1720886400123, out.uploaded_at)
|
|
end)
|
|
|
|
it("accepts non-UTC offsets and shifts back to UTC", function()
|
|
-- "+05:30" means the wall-clock is 5h30m ahead of UTC; subtract
|
|
-- to get unix epoch (which is always UTC).
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T05:30:00+05:30",
|
|
})
|
|
assert.are.equal(1769904000000, out.updated_at)
|
|
end)
|
|
|
|
it("accepts Postgres native (space separator, no T)", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01 00:00:00+00",
|
|
})
|
|
assert.are.equal(1769904000000, out.updated_at)
|
|
end)
|
|
|
|
it("JSON-parses metadata string and extracts series/series_index", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
metadata = '{"series":"Foundation","seriesIndex":1.0,"description":"hello"}',
|
|
})
|
|
assert.are.equal("Foundation", out.series)
|
|
assert.are.equal(1.0, out.series_index)
|
|
assert.is_string(out.metadata_json)
|
|
-- Round-trip the JSON and check the description survives
|
|
local json = require("json")
|
|
assert.are.equal("hello", json.decode(out.metadata_json).description)
|
|
end)
|
|
|
|
it("accepts metadata as an already-parsed table (not just a string)", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
metadata = { series = "Dune", seriesIndex = 2 },
|
|
})
|
|
assert.are.equal("Dune", out.series)
|
|
assert.are.equal(2, out.series_index)
|
|
end)
|
|
|
|
it("handles missing metadata gracefully", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
})
|
|
assert.is_nil(out.series)
|
|
assert.is_nil(out.series_index)
|
|
assert.is_nil(out.metadata_json)
|
|
end)
|
|
|
|
it("non-null deleted_at sets cloud_present=0 and preserves deleted_at", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
deleted_at = "2026-02-15T00:00:00Z",
|
|
})
|
|
assert.are.equal(0, out.cloud_present)
|
|
assert.are.equal(1771113600000, out.deleted_at)
|
|
assert.is_true(out._force_cloud_present)
|
|
end)
|
|
|
|
it("preserves null group_name without crashing", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
group_name = nil,
|
|
})
|
|
assert.is_nil(out.group_name)
|
|
end)
|
|
|
|
it("stringifies progress tuple to progress_lib JSON", function()
|
|
local out = LibraryStore.parseSyncRow({
|
|
book_hash = "h1", title = "T", format = "EPUB", author = "A",
|
|
updated_at = "2026-02-01T00:00:00Z",
|
|
progress = { 42, 250 },
|
|
})
|
|
local json = require("json")
|
|
local p = json.decode(out.progress_lib)
|
|
assert.are.equal(42, p[1])
|
|
assert.are.equal(250, p[2])
|
|
end)
|
|
end)
|
|
end)
|