Files
bookorbit/server/test/reading-attempts.e2e-spec.ts
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

1343 lines
59 KiB
TypeScript

import { randomUUID } from 'crypto';
import { hash } from 'bcryptjs';
import { and, count, eq, isNull } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { ReadingAttemptBackfillService } from '../src/modules/user-book-status/reading-attempt-backfill.service';
import { ReadingAttemptService } from '../src/modules/user-book-status/reading-attempt.service';
import { UserBookStatusService } from '../src/modules/user-book-status/user-book-status.service';
import { KoboReadingStateService } from '../src/modules/kobo/services/kobo-reading-state.service';
import { DashboardWidgetRepository } from '../src/modules/dashboard/dashboard-widget.repository';
import { HardcoverRepository } from '../src/modules/hardcover/hardcover.repository';
import { closeE2EContext, createE2EContext, seedLibrary, type E2EContext } from './e2e/app-harness';
const TIMEOUT = 120_000;
const E2E_ROOT = `/tmp/bookorbit-reading-attempts-${randomUUID()}`;
type BookFixture = { bookId: number; fileId: number };
function auth(token: string) {
return { authorization: `Bearer ${token}` };
}
describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOUT }, () => {
let ctx: E2EContext;
let adminUserId: number;
let libraryId: number;
let libraryFolderId: number;
let bookSequence = 0;
async function createBook(): Promise<BookFixture> {
bookSequence++;
const [book] = await ctx.db
.insert(schema.books)
.values({
libraryId,
libraryFolderId,
folderPath: `reading-attempt-${bookSequence}`,
})
.returning({ id: schema.books.id });
const [file] = await ctx.db
.insert(schema.bookFiles)
.values({
bookId: book.id,
libraryFolderId,
absolutePath: `${E2E_ROOT}/reading-attempt-${bookSequence}.epub`,
relPath: `reading-attempt-${bookSequence}.epub`,
ino: BigInt(100_000 + bookSequence),
sizeBytes: 1024,
format: 'epub',
role: 'content',
})
.returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: file.id }).where(eq(schema.books.id, book.id));
return { bookId: book.id, fileId: file.id };
}
async function patchStatus(bookId: number, payload: Record<string, unknown>) {
return ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/status`,
headers: auth(ctx.adminToken),
payload,
});
}
async function listAttempts(bookId: number, token = ctx.adminToken, page = 1, pageSize = 100) {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${bookId}/reading-attempts?page=${page}&pageSize=${pageSize}`,
headers: auth(token),
});
expect(response.statusCode).toBe(200);
return response.json() as { items: Array<typeof schema.readingAttempts.$inferSelect>; total: number };
}
beforeAll(async () => {
ctx = await createE2EContext({ isolateAppData: true });
const [admin] = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.username, 'scanner-e2e-admin')).limit(1);
adminUserId = admin.id;
const library = await seedLibrary(ctx.db, { rootPath: E2E_ROOT, mode: 'book_per_file', watch: false });
libraryId = library.libraryId;
libraryFolderId = library.libraryFolderId;
await ctx.db.insert(schema.userLibraryAccess).values({ userId: adminUserId, libraryId, accessLevel: 'owner' }).onConflictDoNothing();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
it('1. migrates legacy statuses once and preserves dates', async () => {
const book = await createBook();
await ctx.db.insert(schema.userBookStatus).values({
userId: adminUserId,
bookId: book.bookId,
status: 'read',
source: 'manual',
startedAt: new Date('2024-01-01T00:00:00.000Z'),
finishedAt: new Date('2024-01-10T00:00:00.000Z'),
});
const backfill = ctx.app.get(ReadingAttemptBackfillService);
await backfill.onApplicationBootstrap();
await backfill.onApplicationBootstrap();
const history = await listAttempts(book.bookId);
expect(history.total).toBe(1);
expect(history.items[0]).toMatchObject({ startedOn: '2024-01-01', endedOn: '2024-01-10', outcome: 'completed', origin: 'migration' });
});
it('2. runs the first-reading, hold, resume, completion, and idempotency lifecycle', async () => {
const book = await createBook();
expect((await patchStatus(book.bookId, { status: 'reading' })).json()).toMatchObject({ status: 'reading' });
expect((await patchStatus(book.bookId, { status: 'on_hold' })).json()).toMatchObject({ status: 'on_hold' });
expect((await patchStatus(book.bookId, { status: 'reading' })).json()).toMatchObject({ status: 'reading' });
expect((await patchStatus(book.bookId, { status: 'read' })).json()).toMatchObject({ status: 'read' });
await patchStatus(book.bookId, { status: 'read' });
const history = await listAttempts(book.bookId);
expect(history.total).toBe(1);
expect(history.items[0]?.outcome).toBe('completed');
});
it('uses a historical KOReader finish day for the book and completion statistics without overwriting later manual edits', async () => {
const book = await createBook();
const fileHash = randomUUID().replaceAll('-', '');
await ctx.db.update(schema.bookFiles).set({ fileHash }).where(eq(schema.bookFiles.id, book.fileId));
const username = `attempt-date-${randomUUID()}`;
const password = 'HistoricalFinish123';
const credentials = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: auth(ctx.adminToken),
payload: { username, password },
});
expect(credentials.statusCode).toBe(201);
const year = new Date().getUTCFullYear() - 1;
const finishedOn = `${year}-08-22`;
const startTime = Date.parse(`${finishedOn}T18:00:00.000Z`) / 1000;
const deviceFields = { deviceId: 'historical-finish-e2e', deviceModel: 'E2E', pluginVersion: '1.7.0' };
const deviceHeaders = { 'x-auth-user': username, 'x-auth-key': password };
const stats = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/page-stats',
headers: deviceHeaders,
payload: {
...deviceFields,
books: [{ hash: fileHash, events: [{ page: 100, totalPages: 100, startTime, durationSeconds: 60 }] }],
},
});
expect(stats.statusCode).toBe(201);
expect(stats.json().results[0].accepted).toBe(1);
const timeline = async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/completion-timeline?days=3650',
headers: auth(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
return response.json() as Array<{ year: number; month: number; count: number }>;
};
const before = await timeline();
const uploadState = () =>
ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/plugin/book-states',
headers: deviceHeaders,
payload: { ...deviceFields, books: [{ hash: fileHash, status: 'complete', statusModified: finishedOn }] },
});
expect((await uploadState()).statusCode).toBe(201);
expect((await uploadState()).statusCode).toBe(201);
const history = await listAttempts(book.bookId);
expect(history.total).toBe(1);
expect(history.items[0]).toMatchObject({ outcome: 'completed', endedOn: finishedOn });
const detail = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${book.bookId}`, headers: auth(ctx.adminToken) });
expect(detail.statusCode).toBe(200);
expect(detail.json().readStatus.finishedAt).toBe(`${finishedOn}T00:00:00.000Z`);
const after = await timeline();
expect(after.find((point) => point.year === year && point.month === 8)?.count).toBe(
(before.find((point) => point.year === year && point.month === 8)?.count ?? 0) + 1,
);
const now = new Date();
expect(after.find((point) => point.year === now.getUTCFullYear() && point.month === now.getUTCMonth() + 1)?.count).toBe(
before.find((point) => point.year === now.getUTCFullYear() && point.month === now.getUTCMonth() + 1)?.count,
);
expect((await patchStatus(book.bookId, { finishedAt: `${year}-08-23` })).statusCode).toBe(200);
expect((await uploadState()).statusCode).toBe(201);
const editedHistory = await listAttempts(book.bookId);
expect(editedHistory.total).toBe(1);
expect(editedHistory.items[0]?.endedOn).toBe(`${year}-08-23`);
});
it('3. starts a manual reread and resets progress without deleting history', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read', startedAt: '2024-01-01', finishedAt: '2024-01-10' });
await ctx.db.insert(schema.readingProgress).values({ userId: adminUserId, bookFileId: book.fileId, percentage: 100 });
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts/start-reread`,
headers: auth(ctx.adminToken),
payload: { resetProgress: true },
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({ status: 'rereading' });
expect(await ctx.db.select().from(schema.readingProgress).where(eq(schema.readingProgress.bookFileId, book.fileId))).toHaveLength(0);
const history = await listAttempts(book.bookId);
expect(history.total).toBe(2);
expect(history.items.filter((attempt) => attempt.outcome === null)).toHaveLength(1);
});
it('4. starts a reread without resetting existing progress', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read' });
await ctx.db.insert(schema.readingProgress).values({ userId: adminUserId, bookFileId: book.fileId, percentage: 100 });
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts/start-reread`,
headers: auth(ctx.adminToken),
payload: { resetProgress: false },
});
expect(response.json()).toMatchObject({ status: 'rereading' });
const [progress] = await ctx.db.select().from(schema.readingProgress).where(eq(schema.readingProgress.bookFileId, book.fileId));
expect(progress.percentage).toBe(100);
});
// A reread on a book never finished here records the read it implies as an undated completed attempt,
// created after the read in progress. Taken as the latest by id, its missing dates replaced the dates
// of that read once finished, the next time a session landed; a past read added later did the same.
it('4a. keeps the latest read dates over an undated earlier read and a past read added later', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'reading', startedAt: '2026-08-17' });
const reread = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts/start-reread`,
headers: auth(ctx.adminToken),
payload: { resetProgress: false },
});
expect(reread.json()).toMatchObject({ status: 'rereading' });
await patchStatus(book.bookId, { status: 'read', finishedAt: '2026-10-17' });
const bookDates = async () => {
const [row] = await ctx.db
.select()
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, adminUserId), eq(schema.userBookStatus.bookId, book.bookId)));
return { status: row?.status, startedOn: row?.startedAt?.toISOString().slice(0, 10), finishedOn: row?.finishedAt?.toISOString().slice(0, 10) };
};
const finished = { status: 'read', startedOn: '2026-08-17', finishedOn: '2026-10-17' };
expect(await bookDates()).toEqual(finished);
const session = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${book.fileId}/sessions`,
headers: auth(ctx.adminToken),
payload: {
sessionId: `reread-dates-${book.bookId}`,
startedAt: '2026-10-17T18:12:00.000Z',
endedAt: '2026-10-17T18:19:30.000Z',
durationSeconds: 450,
endProgress: 100,
},
});
expect(session.statusCode).toBe(204);
expect(await bookDates()).toEqual(finished);
const past = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2019-03-01', endedOn: '2019-03-20', outcome: 'completed' },
});
expect(past.statusCode).toBe(201);
expect(await bookDates()).toEqual(finished);
});
it('5. adds and edits partial historical attempts without duplicating them', async () => {
const book = await createBook();
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: null, endedOn: '2023-04-05', outcome: 'completed' },
});
expect(created.statusCode).toBe(201);
const attempt = created.json() as { id: number };
const updated = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${book.bookId}/reading-attempts/${attempt.id}`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2023-04-01' },
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({ startedOn: '2023-04-01', endedOn: '2023-04-05' });
expect((await listAttempts(book.bookId)).total).toBe(1);
const invalid = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2023-05-10', endedOn: '2023-05-01', outcome: 'completed' },
});
expect(invalid.statusCode).toBe(400);
});
it('6. soft-deletes attempts and prevents external resurrection', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
await attempts.importExternalRead(adminUserId, book.bookId, {
provider: 'hardcover',
externalId: 'delete-77',
startedOn: '2022-01-01',
endedOn: '2022-01-10',
});
const history = await listAttempts(book.bookId);
const attemptId = history.items[0]!.id;
const deleted = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${book.bookId}/reading-attempts/${attemptId}`,
headers: auth(ctx.adminToken),
});
expect(deleted.statusCode).toBe(204);
await attempts.importExternalRead(adminUserId, book.bookId, {
provider: 'hardcover',
externalId: 'delete-77',
startedOn: '2022-01-01',
endedOn: '2022-01-11',
});
expect((await listAttempts(book.bookId)).total).toBe(0);
});
it('7. closes active attempts as abandoned when moving to unread or want-to-read', async () => {
for (const status of ['unread', 'want_to_read'] as const) {
const book = await createBook();
await patchStatus(book.bookId, { status: 'reading' });
expect((await patchStatus(book.bookId, { status })).json()).toMatchObject({ status });
const history = await listAttempts(book.bookId);
expect(history.items[0]?.outcome).toBe('abandoned');
}
});
it('8. explicitly resets attempts, sessions, progress, and status', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'reading' });
await ctx.db.insert(schema.readingProgress).values({ userId: adminUserId, bookFileId: book.fileId, percentage: 25 });
const reset = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reset-reading-state`,
headers: auth(ctx.adminToken),
});
expect(reset.statusCode).toBe(201);
expect(reset.json()).toMatchObject({ readStatus: { status: 'unread' } });
expect((await listAttempts(book.bookId)).total).toBe(0);
});
it('9. detects web rereads from a substantial progress rollback and completes the same attempt', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read', startedAt: '2024-01-01', finishedAt: '2024-01-10' });
await ctx.db.insert(schema.readingProgress).values({ userId: adminUserId, bookFileId: book.fileId, percentage: 100 });
const rollback = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${book.fileId}/progress`,
headers: auth(ctx.adminToken),
payload: { percentage: 20 },
});
expect(rollback.statusCode).toBe(201);
expect((await ctx.db.select().from(schema.userBookStatus).where(eq(schema.userBookStatus.bookId, book.bookId)))[0]?.status).toBe('rereading');
await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${book.fileId}/progress`,
headers: auth(ctx.adminToken),
payload: { percentage: 99 },
});
const history = await listAttempts(book.bookId);
expect(history.items.filter((attempt) => attempt.outcome === 'completed')).toHaveLength(2);
});
it('10. simulates KOReader strong and repeated progress signals idempotently', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read' });
const statuses = ctx.app.get(UserBookStatusService);
await statuses.autoUpdate(adminUserId, book.bookId, 20, 1, 98, {
origin: 'koreader',
occurredOn: '2026-02-01',
strongRereadEvidence: true,
});
await statuses.autoUpdate(adminUserId, book.bookId, 20, 1, 98, {
origin: 'koreader',
occurredOn: '2026-02-01',
strongRereadEvidence: false,
});
const history = await listAttempts(book.bookId);
expect(history.items.filter((attempt) => attempt.outcome === null)).toHaveLength(1);
expect(history.items[0]?.origin).toBe('koreader');
});
it('11. simulates the Kobo issue payload and preserves the original completion', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read', startedAt: '2024-01-01', finishedAt: '2024-01-10' });
const kobo = ctx.app.get(KoboReadingStateService);
const device = await ctx.app.inject({
method: 'POST',
url: '/api/v1/kobo/devices',
headers: auth(ctx.adminToken),
payload: { name: 'Attempt simulation Kobo' },
});
expect(device.statusCode).toBe(201);
const deviceId = (device.json() as { id: number }).id;
// The device holds this copy, so the positions it reports are its reader's, not an unknown copy's.
await ctx.db.insert(schema.koboDeviceDeliveries).values({
userId: adminUserId,
deviceId,
bookId: book.bookId,
sourceFileId: book.fileId,
metadata: {
artifactId: 'simulated',
sourceRevision: 'simulated',
requestedVariant: null,
actualVariant: 'original',
format: 'epub',
sizeBytes: 1024,
sha256: 'simulated',
fileHash: 'simulated',
outcome: 'original',
},
options: { hyphenate: false, converterVersion: null },
state: 'transfer_observed',
});
await kobo.upsertState(
adminUserId,
book.bookId,
{
LastModified: '2026-03-01T00:00:00.000Z',
CurrentBookmark: { LastModified: '2026-03-01T00:00:00.000Z', ProgressPercent: 100 },
StatusInfo: { LastModified: '2026-03-01T00:00:00.000Z', Status: 'ReadyToRead', TimesStartedReading: 1 },
},
1,
98,
false,
deviceId,
);
await kobo.upsertState(
adminUserId,
book.bookId,
{
LastModified: '2026-03-02T00:00:00.000Z',
CurrentBookmark: { LastModified: '2026-03-02T00:00:00.000Z', ProgressPercent: 20 },
StatusInfo: { LastModified: '2026-03-02T00:00:00.000Z', Status: 'Reading', TimesStartedReading: 2 },
},
1,
98,
false,
deviceId,
);
const history = await listAttempts(book.bookId);
expect(history.total).toBe(2);
expect(history.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ startedOn: '2024-01-01', endedOn: '2024-01-10', outcome: 'completed' }),
expect.objectContaining({ startedOn: '2026-03-02', outcome: null, origin: 'kobo' }),
]),
);
});
it('12. converges concurrent multi-device reread signals to one active attempt', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read' });
const attempts = ctx.app.get(ReadingAttemptService);
await Promise.all([
attempts.recordActivity({
userId: adminUserId,
bookId: book.bookId,
occurredOn: '2026-04-01',
origin: 'kobo',
progress: 10,
finishThreshold: 98,
strongRereadEvidence: true,
meaningfulActivity: false,
}),
attempts.recordActivity({
userId: adminUserId,
bookId: book.bookId,
occurredOn: '2026-04-01',
origin: 'kobo',
progress: 12,
finishThreshold: 98,
strongRereadEvidence: true,
meaningfulActivity: false,
}),
]);
const [row] = await ctx.db
.select({ value: count() })
.from(schema.readingAttempts)
.where(
and(
eq(schema.readingAttempts.userId, adminUserId),
eq(schema.readingAttempts.bookId, book.bookId),
isNull(schema.readingAttempts.outcome),
isNull(schema.readingAttempts.deletedAt),
),
);
expect(row.value).toBe(1);
});
it('13. imports every Hardcover read idempotently', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
for (const read of [
{ externalId: 'hc-101', startedOn: '2020-01-01', endedOn: '2020-01-15' },
{ externalId: 'hc-102', startedOn: '2022-02-01', endedOn: '2022-02-10' },
]) {
await attempts.importExternalRead(adminUserId, book.bookId, { provider: 'hardcover', ...read });
await attempts.importExternalRead(adminUserId, book.bookId, { provider: 'hardcover', ...read });
}
const history = await listAttempts(book.bookId);
expect(history.total).toBe(2);
expect(new Set(history.items.map((attempt) => attempt.externalId))).toEqual(new Set(['hc-101', 'hc-102']));
});
it('14. links outbound Hardcover reads without replacing attempt identity', async () => {
const book = await createBook();
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2021-01-01', endedOn: '2021-01-05', outcome: 'completed' },
});
const attemptId = (response.json() as { id: number }).id;
const hardcover = ctx.app.get(HardcoverRepository);
await hardcover.linkReadingAttempt(adminUserId, attemptId, 9001);
await hardcover.linkReadingAttempt(adminUserId, attemptId, 9001);
const [row] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, attemptId));
expect(row).toMatchObject({ externalProvider: 'hardcover', externalId: '9001' });
});
it('15. counts completed rereads in goals while excluding skimmed and undated attempts', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const dashboard = ctx.app.get(DashboardWidgetRepository);
const year = new Date().getUTCFullYear();
const countThisYear = () => dashboard.countCompletedBooks(adminUserId, [libraryId], `${year}-01-01`, `${year + 1}-01-01`);
const completedBefore = await countThisYear();
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: `${year}-01-01`,
endedOn: `${year}-01-10`,
outcome: 'completed',
});
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: `${year}-02-01`,
endedOn: `${year}-02-10`,
outcome: 'completed',
});
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: `${year}-03-01`,
endedOn: `${year}-03-10`,
outcome: 'skimmed',
});
await attempts.createHistorical(adminUserId, book.bookId, { startedOn: null, endedOn: null, outcome: 'completed' });
await expect(countThisYear()).resolves.toBe(completedBefore + 2);
});
it('16. isolates attempt history between users sharing a library', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read' });
const username = `attempt-user-${randomUUID()}`;
const password = 'AttemptUser123!';
const [user] = await ctx.db
.insert(schema.users)
.values({
username,
name: 'Attempt User',
passwordHash: await hash(password, 4),
isDefaultPassword: false,
provisioningMethod: 'local',
})
.returning({ id: schema.users.id });
await ctx.db.insert(schema.userLibraryAccess).values({ userId: user.id, libraryId, accessLevel: 'viewer' });
const login = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password } });
const token = (login.json() as { accessToken: string }).accessToken;
const userHistory = await listAttempts(book.bookId, token);
expect(userHistory.total).toBe(0);
const adminHistory = await listAttempts(book.bookId);
const forbiddenPatch = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${book.bookId}/reading-attempts/${adminHistory.items[0]!.id}`,
headers: auth(token),
payload: { startedOn: '2020-01-01' },
});
expect(forbiddenPatch.statusCode).toBe(404);
});
it('17. paginates long histories newest-first without overlap', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
for (let index = 1; index <= 12; index++) {
const day = String(index).padStart(2, '0');
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: `2020-01-${day}`,
endedOn: `2020-01-${day}`,
outcome: 'completed',
});
}
const first = await listAttempts(book.bookId, ctx.adminToken, 1, 10);
const second = await listAttempts(book.bookId, ctx.adminToken, 2, 10);
expect(first.items).toHaveLength(10);
expect(second.items).toHaveLength(2);
expect(new Set([...first.items, ...second.items].map((attempt) => attempt.id)).size).toBe(12);
expect(first.items[0]!.id).toBeGreaterThan(first.items.at(-1)!.id);
});
it('18. preserves database invariants and associates sessions to the active attempt', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'reading' });
const session = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/sessions`,
headers: auth(ctx.adminToken),
payload: {
startedAt: '2026-05-01T10:00:00.000Z',
durationMinutes: 10,
endProgress: 15,
format: 'epub',
},
});
expect(session.statusCode).toBe(201);
const [savedSession] = await ctx.db.select().from(schema.readingSessions).where(eq(schema.readingSessions.bookId, book.bookId));
expect(savedSession.attemptId).not.toBeNull();
const duplicates = await ctx.db
.select({ value: count() })
.from(schema.readingAttempts)
.where(
and(
eq(schema.readingAttempts.userId, adminUserId),
eq(schema.readingAttempts.bookId, book.bookId),
isNull(schema.readingAttempts.outcome),
isNull(schema.readingAttempts.deletedAt),
),
);
expect(duplicates[0]?.value).toBe(1);
await expect(
ctx.db.insert(schema.readingAttempts).values({
userId: adminUserId,
bookId: book.bookId,
startedOn: '2026-05-02',
endedOn: null,
outcome: null,
origin: 'manual',
}),
).rejects.toThrow();
});
it('19. reports an ownership conflict instead of throwing when another attempt holds the read', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const owner = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2022-01-01',
endedOn: '2022-01-05',
outcome: 'completed',
});
const rival = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2022-03-01',
endedOn: '2022-03-05',
outcome: 'completed',
});
await expect(hardcover.linkReadingAttempt(adminUserId, owner.id, 9101)).resolves.toBe('linked');
await expect(hardcover.linkReadingAttempt(adminUserId, rival.id, 9101)).resolves.toBe('conflict');
const [rivalRow] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, rival.id));
expect(rivalRow?.externalId).toBeNull();
const [ownerRow] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, owner.id));
expect(ownerRow).toMatchObject({ externalId: '9101', deletedAt: null });
});
it('20. keeps a soft-deleted attempt tombstone reserved against reuse', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const removed = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2022-06-01',
endedOn: '2022-06-05',
outcome: 'completed',
});
await hardcover.linkReadingAttempt(adminUserId, removed.id, 9202);
await attempts.delete(adminUserId, book.bookId, removed.id);
const survivor = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2022-09-01',
endedOn: '2022-09-05',
outcome: 'completed',
});
await expect(hardcover.linkReadingAttempt(adminUserId, survivor.id, 9202)).resolves.toBe('conflict');
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toContain(9202);
});
it('21. lists claimed read ids across live and soft-deleted attempts', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const live = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2023-01-01',
endedOn: '2023-01-05',
outcome: 'completed',
});
const deleted = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2023-04-01',
endedOn: '2023-04-05',
outcome: 'completed',
});
await hardcover.linkReadingAttempt(adminUserId, live.id, 9301);
await hardcover.linkReadingAttempt(adminUserId, deleted.id, 9302);
await attempts.delete(adminUserId, book.bookId, deleted.id);
const claimed = await hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId);
expect(new Set(claimed)).toEqual(new Set([9301, 9302]));
});
it('22. lets an attempt restamp the same read but rejects reassignment', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const attempt = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2023-07-01',
endedOn: '2023-07-05',
outcome: 'completed',
});
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9401)).resolves.toBe('linked');
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9401)).resolves.toBe('linked');
await expect(hardcover.linkReadingAttempt(adminUserId, attempt.id, 9402)).resolves.toBe('conflict');
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toEqual([9401]);
});
it('23. lets exactly one concurrent claim establish attempt ownership', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const attempt = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2023-08-01',
endedOn: '2023-08-05',
outcome: 'completed',
});
const outcomes = await Promise.all([
hardcover.linkReadingAttempt(adminUserId, attempt.id, 9501),
hardcover.linkReadingAttempt(adminUserId, attempt.id, 9502),
]);
expect(outcomes.sort()).toEqual(['conflict', 'linked']);
const claimed = await hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId);
expect(claimed).toHaveLength(1);
expect([9501, 9502]).toContain(claimed[0]);
});
it('24. surfaces attempt timestamps as Dates so sync change detection can compare them', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const before = new Date(Date.now() - 60_000);
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2024-02-01',
endedOn: '2024-02-05',
outcome: 'completed',
});
const sync = await hardcover.findBookSyncData(adminUserId, book.bookId);
const updatedAt = sync?.attemptsUpdatedAt ?? null;
// A raw driver timestamp string compares false against a Date, which silently disables the
// attempt-history branch of hasChanges and stops attempt edits from ever re-triggering a sync.
expect(updatedAt).toBeInstanceOf(Date);
expect(updatedAt instanceof Date && updatedAt > before).toBe(true);
});
it('25. leaves the attempt timestamp null for a book with no attempts', async () => {
const book = await createBook();
const hardcover = ctx.app.get(HardcoverRepository);
const sync = await hardcover.findBookSyncData(adminUserId, book.bookId);
expect(sync?.attemptsUpdatedAt ?? null).toBeNull();
});
it('25a. moves a Hardcover link to another book and releases only its live read links', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const finished = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-01-01',
endedOn: '2025-01-09',
outcome: 'completed',
});
const removed = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-02-01',
endedOn: '2025-02-09',
outcome: 'completed',
});
const otherProvider = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-03-01',
endedOn: '2025-03-09',
outcome: 'completed',
});
await hardcover.linkReadingAttempt(adminUserId, finished.id, 9601);
await hardcover.linkReadingAttempt(adminUserId, removed.id, 9602);
await attempts.delete(adminUserId, book.bookId, removed.id);
await ctx.db
.update(schema.readingAttempts)
.set({ externalProvider: 'storygraph', externalId: 'sg-1' })
.where(eq(schema.readingAttempts.id, otherProvider.id));
await hardcover.upsertBookState({
userId: adminUserId,
bookId: book.bookId,
hardcoverBookId: 1554702,
hardcoverEditionId: 31616496,
hardcoverUserBookId: 19544253,
hardcoverReadId: 9601,
matchMethod: 'cached',
matchBasis: '["hell-be-ok",null,null]',
syncOverride: 'included',
lastSyncedAt: new Date('2026-10-01T00:00:00Z'),
lastSyncedProgress: 13.82,
});
const released = await hardcover.relinkBook({
userId: adminUserId,
bookId: book.bookId,
hardcoverBookId: 2973670,
hardcoverEditionId: 33314466,
matchMethod: 'metadata_id',
matchBasis: '["ok-amen","9789146223856",null]',
});
expect(released).toBe(1);
await expect(hardcover.findBookState(adminUserId, book.bookId)).resolves.toMatchObject({
hardcoverBookId: 2973670,
hardcoverEditionId: 33314466,
hardcoverUserBookId: null,
hardcoverReadId: null,
matchMethod: 'metadata_id',
matchBasis: '["ok-amen","9789146223856",null]',
syncOverride: 'included',
lastSyncedAt: null,
lastSyncedProgress: 13.82,
});
const rows = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.bookId, book.bookId));
const byId = new Map(rows.map((row) => [row.id, row]));
expect(byId.get(finished.id)).toMatchObject({ externalProvider: null, externalId: null });
// The soft-deleted attempt stays a tombstone, and another provider's link is not ours to drop.
expect(byId.get(removed.id)).toMatchObject({ externalProvider: 'hardcover', externalId: '9602' });
expect(byId.get(otherProvider.id)).toMatchObject({ externalProvider: 'storygraph', externalId: 'sg-1' });
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toEqual([9602]);
await expect(hardcover.linkReadingAttempt(adminUserId, finished.id, 9603)).resolves.toBe('linked');
});
it('25b. releases one attempt link only while it still points at the rejected read', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const attempt = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-04-01',
endedOn: '2025-04-09',
outcome: 'completed',
});
await hardcover.linkReadingAttempt(adminUserId, attempt.id, 9701);
await expect(hardcover.unlinkReadingAttempt(adminUserId, attempt.id, 9799)).resolves.toBe(false);
await expect(hardcover.findClaimedHardcoverReadIds(adminUserId, book.bookId)).resolves.toEqual([9701]);
await expect(hardcover.unlinkReadingAttempt(adminUserId, attempt.id, 9701)).resolves.toBe(true);
const [row] = await ctx.db.select().from(schema.readingAttempts).where(eq(schema.readingAttempts.id, attempt.id));
expect(row).toMatchObject({ externalProvider: null, externalId: null });
});
it('25c. lets a Hardcover link cleared down to its provider be claimed again', async () => {
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const hardcover = ctx.app.get(HardcoverRepository);
const halfCleared = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-05-01',
endedOn: '2025-05-09',
outcome: 'completed',
});
const otherProvider = await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: '2025-06-01',
endedOn: '2025-06-09',
outcome: 'completed',
});
await ctx.db
.update(schema.readingAttempts)
.set({ externalProvider: 'hardcover', externalId: null })
.where(eq(schema.readingAttempts.id, halfCleared.id));
await ctx.db
.update(schema.readingAttempts)
.set({ externalProvider: 'storygraph', externalId: null })
.where(eq(schema.readingAttempts.id, otherProvider.id));
await expect(hardcover.linkReadingAttempt(adminUserId, halfCleared.id, 9801)).resolves.toBe('linked');
await expect(hardcover.linkReadingAttempt(adminUserId, otherProvider.id, 9802)).resolves.toBe('conflict');
});
it('25d. carries the metadata Hardcover edition into the sync snapshot', async () => {
const book = await createBook();
const hardcover = ctx.app.get(HardcoverRepository);
await ctx.db
.insert(schema.bookMetadata)
.values({ bookId: book.bookId, title: 'ok, amen', hardcoverId: 'ok-amen', hardcoverEditionId: '33314466' });
await expect(hardcover.findBookSyncData(adminUserId, book.bookId)).resolves.toMatchObject({
hardcoverMetadataId: 'ok-amen',
hardcoverEditionId: '33314466',
});
});
it('26. lets lifecycle dates create and complete attempts through the status API', async () => {
const startedOnly = await createBook();
const startResponse = await patchStatus(startedOnly.bookId, { startedAt: '2026-01-10' });
expect(startResponse.statusCode).toBe(200);
expect(startResponse.json()).toMatchObject({ status: 'reading', startedAt: '2026-01-10', finishedAt: null });
expect((await listAttempts(startedOnly.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-01-10', endedOn: null, outcome: null, origin: 'manual' }),
]);
const bothDates = await createBook();
const bothResponse = await patchStatus(bothDates.bookId, { startedAt: '2026-02-01', finishedAt: '2026-02-10' });
expect(bothResponse.statusCode).toBe(200);
expect(bothResponse.json()).toMatchObject({ status: 'read', startedAt: '2026-02-01', finishedAt: '2026-02-10' });
expect((await listAttempts(bothDates.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-02-01', endedOn: '2026-02-10', outcome: 'completed', origin: 'manual' }),
]);
const sameDay = await createBook();
const sameDayResponse = await patchStatus(sameDay.bookId, { startedAt: '2026-02-15', finishedAt: '2026-02-15' });
expect(sameDayResponse.statusCode).toBe(200);
expect(sameDayResponse.json()).toMatchObject({ status: 'read', startedAt: '2026-02-15', finishedAt: '2026-02-15' });
expect((await listAttempts(sameDay.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-02-15', endedOn: '2026-02-15', outcome: 'completed', origin: 'manual' }),
]);
});
it('27. closes active lifecycle states with a date-only finish patch', async () => {
for (const status of ['reading', 'rereading', 'on_hold'] as const) {
const book = await createBook();
if (status === 'rereading') {
await patchStatus(book.bookId, { status: 'read', startedAt: '2025-01-01', finishedAt: '2025-01-10' });
}
await patchStatus(book.bookId, { status, startedAt: '2026-03-01' });
const response = await patchStatus(book.bookId, { finishedAt: '2026-03-10' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ status: 'read', startedAt: '2026-03-01', finishedAt: '2026-03-10' });
const history = await listAttempts(book.bookId);
expect(history.items[0]).toMatchObject({ startedOn: '2026-03-01', endedOn: '2026-03-10', outcome: 'completed' });
expect(history.items.filter((attempt) => attempt.outcome === null)).toHaveLength(0);
}
});
it('28. preserves completion when its finish date is cleared', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read', startedAt: '2026-04-01', finishedAt: '2026-04-10' });
const response = await patchStatus(book.bookId, { finishedAt: null });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ status: 'read', startedAt: '2026-04-01', finishedAt: null });
expect((await listAttempts(book.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-04-01', endedOn: null, outcome: 'completed' }),
]);
});
it('30. keeps a lifecycle-clearing status free of dates it cannot own', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'unread' });
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2026-06-01', endedOn: '2026-06-10', outcome: 'completed' },
});
expect(created.statusCode).toBe(201);
const [projected] = await ctx.db
.select({ status: schema.userBookStatus.status, startedAt: schema.userBookStatus.startedAt, finishedAt: schema.userBookStatus.finishedAt })
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, adminUserId), eq(schema.userBookStatus.bookId, book.bookId)))
.limit(1);
expect(projected).toMatchObject({ status: 'unread', startedAt: null, finishedAt: null });
expect((await listAttempts(book.bookId)).items[0]).toMatchObject({ startedOn: '2026-06-01', endedOn: '2026-06-10', outcome: 'completed' });
});
it('29. rejects conflicting lifecycle dates without reaching a database constraint', async () => {
const book = await createBook();
const response = await patchStatus(book.bookId, { status: 'reading', startedAt: '2026-05-01', finishedAt: '2026-05-10' });
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ errorCode: 'READING_DATES_STATUS_CONFLICT' });
expect((await listAttempts(book.bookId)).total).toBe(0);
});
it('31. counts completed attempts in completion analytics regardless of session end progress', async () => {
const username = `attempt-stats-${randomUUID()}`;
const password = 'AttemptStats123!';
const [user] = await ctx.db
.insert(schema.users)
.values({
username,
name: 'Attempt Stats User',
passwordHash: await hash(password, 4),
isDefaultPassword: false,
provisioningMethod: 'local',
})
.returning({ id: schema.users.id });
await ctx.db.insert(schema.userLibraryAccess).values({ userId: user.id, libraryId, accessLevel: 'viewer' });
const attempts = ctx.app.get(ReadingAttemptService);
const endProgressValues = [99.7, 94.7, 86.8];
for (const [index, endProgress] of endProgressValues.entries()) {
const book = await createBook();
const endedAt = new Date();
endedAt.setUTCDate(endedAt.getUTCDate() - (index + 1) * 3);
const startedAt = new Date(endedAt);
startedAt.setUTCDate(startedAt.getUTCDate() - 2);
const startedOn = startedAt.toISOString().slice(0, 10);
const endedOn = endedAt.toISOString().slice(0, 10);
const attempt = await attempts.createHistorical(user.id, book.bookId, { startedOn, endedOn, outcome: 'completed' });
const sessionStartedAt = new Date(`${startedOn}T10:00:00.000Z`);
const sessionEndedAt = new Date(sessionStartedAt.getTime() + 60 * 60 * 1000);
await ctx.db.insert(schema.readingSessions).values({
userId: user.id,
bookFileId: book.fileId,
bookId: book.bookId,
attemptId: attempt.id,
sessionId: `attempt-stats-${randomUUID()}`,
source: 'koreader',
startedAt: sessionStartedAt,
endedAt: sessionEndedAt,
durationSeconds: 3600,
progressDelta: 10,
endProgress,
});
}
const excludedBook = await createBook();
const excluded = await attempts.createHistorical(user.id, excludedBook.bookId, {
startedOn: '2026-01-01',
endedOn: '2026-01-02',
outcome: 'completed',
});
await attempts.delete(user.id, excludedBook.bookId, excluded.id);
await attempts.createHistorical(user.id, excludedBook.bookId, {
startedOn: '2026-01-03',
endedOn: '2026-01-04',
outcome: 'skimmed',
});
await attempts.createHistorical(user.id, excludedBook.bookId, { startedOn: null, endedOn: null, outcome: 'completed' });
const login = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password } });
expect(login.statusCode).toBe(200);
const token = (login.json() as { accessToken: string }).accessToken;
const timelineResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/completion-timeline?days=365',
headers: auth(token),
});
expect(timelineResponse.statusCode).toBe(200);
const timeline = timelineResponse.json() as Array<{ count: number }>;
expect(timeline.reduce((sum, point) => sum + point.count, 0)).toBe(3);
const goalResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/goal-trajectory?days=365&goalBooks=12',
headers: auth(token),
});
expect(goalResponse.statusCode).toBe(200);
const goal = goalResponse.json() as { points: Array<{ actualCumulative: number }> };
expect(goal.points.at(-1)?.actualCumulative).toBe(3);
const latencyResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/completion-latency?days=365',
headers: auth(token),
});
expect(latencyResponse.statusCode).toBe(200);
expect(latencyResponse.json()).toMatchObject({ totalCompletions: 3, medianDays: 2 });
});
it('32. reports the same completed-book count on the reading-goal and year-projection widgets and the activity overview', async () => {
const username = `activity-goal-${randomUUID()}`;
const password = 'ActivityGoal123!';
const [user] = await ctx.db
.insert(schema.users)
.values({
username,
name: 'Activity Goal User',
passwordHash: await hash(password, 4),
isDefaultPassword: false,
provisioningMethod: 'local',
})
.returning({ id: schema.users.id });
await ctx.db.insert(schema.userLibraryAccess).values({ userId: user.id, libraryId, accessLevel: 'viewer' });
const attempts = ctx.app.get(ReadingAttemptService);
const thisYear = new Date().getUTCFullYear();
const todayKey = new Date().toISOString().slice(0, 10);
// Every seeded date has to be in the current year and not in the future, on Jan 1 as much as
// in December, so a fixed day in January is clamped to today rather than landing ahead of it.
const dayThisYear = (monthDay: string) => {
const candidate = `${thisYear}-${monthDay}`;
return candidate > todayKey ? todayKey : candidate;
};
async function addSession(book: BookFixture, attemptId: number, endProgress: number, day: string) {
const startedAt = new Date(`${day}T10:00:00.000Z`);
await ctx.db.insert(schema.readingSessions).values({
userId: user.id,
bookFileId: book.fileId,
bookId: book.bookId,
attemptId,
sessionId: `activity-goal-${randomUUID()}`,
source: 'koreader',
startedAt,
endedAt: new Date(startedAt.getTime() + 60 * 60 * 1000),
durationSeconds: 3600,
progressDelta: 10,
endProgress,
});
}
// Finished in the app, with a session that ran to the end. Both sources always agreed here.
const finishedInApp = await createBook();
const finishedInAppAttempt = await attempts.createHistorical(user.id, finishedInApp.bookId, {
startedOn: dayThisYear('02-01'),
endedOn: dayThisYear('02-10'),
outcome: 'completed',
});
await addSession(finishedInApp, finishedInAppAttempt.id, 100, dayThisYear('02-10'));
// Marked read by hand or imported from another client: no session exists at all.
const markedRead = await createBook();
await attempts.createHistorical(user.id, markedRead.bookId, {
startedOn: dayThisYear('03-01'),
endedOn: dayThisYear('03-05'),
outcome: 'completed',
});
// Finished elsewhere, so the last session this server saw stops short of the end.
const finishedElsewhere = await createBook();
const finishedElsewhereAttempt = await attempts.createHistorical(user.id, finishedElsewhere.bookId, {
startedOn: dayThisYear('04-01'),
endedOn: dayThisYear('04-08'),
outcome: 'completed',
});
await addSession(finishedElsewhere, finishedElsewhereAttempt.id, 97.4, dayThisYear('04-08'));
// Read twice this year. Each finish counts, rather than collapsing into the first one.
const reRead = await createBook();
const firstRead = await attempts.createHistorical(user.id, reRead.bookId, {
startedOn: dayThisYear('05-01'),
endedOn: dayThisYear('05-10'),
outcome: 'completed',
});
await addSession(reRead, firstRead.id, 100, dayThisYear('05-10'));
const secondRead = await attempts.createHistorical(user.id, reRead.bookId, {
startedOn: dayThisYear('06-01'),
endedOn: dayThisYear('06-10'),
outcome: 'completed',
});
await addSession(reRead, secondRead.id, 100, dayThisYear('06-10'));
// Finished last year, so it belongs to last year's total and must not reach this one.
const lastYearBook = await createBook();
const lastYearAttempt = await attempts.createHistorical(user.id, lastYearBook.bookId, {
startedOn: `${thisYear - 1}-11-01`,
endedOn: `${thisYear - 1}-11-20`,
outcome: 'completed',
});
await addSession(lastYearBook, lastYearAttempt.id, 100, `${thisYear - 1}-11-20`);
// Deleted, skimmed and undated attempts stay out of every count.
const excludedBook = await createBook();
const deleted = await attempts.createHistorical(user.id, excludedBook.bookId, {
startedOn: dayThisYear('07-01'),
endedOn: dayThisYear('07-02'),
outcome: 'completed',
});
await attempts.delete(user.id, excludedBook.bookId, deleted.id);
await attempts.createHistorical(user.id, excludedBook.bookId, {
startedOn: dayThisYear('07-03'),
endedOn: dayThisYear('07-04'),
outcome: 'skimmed',
});
await attempts.createHistorical(user.id, excludedBook.bookId, { startedOn: null, endedOn: null, outcome: 'completed' });
const login = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { username, password } });
expect(login.statusCode).toBe(200);
const token = (login.json() as { accessToken: string }).accessToken;
// The twice-read book is now on its third read. Its status row holds no finish while that
// runs, which once dropped both of this year's finishes from the year projection.
const reread = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${reRead.bookId}/reading-attempts/start-reread`,
headers: auth(token),
payload: { resetProgress: false },
});
expect(reread.statusCode).toBe(201);
expect(reread.json()).toMatchObject({ status: 'rereading' });
const widgetResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/dashboard/widgets/reading-goal',
headers: auth(token),
});
expect(widgetResponse.statusCode).toBe(200);
const widget = widgetResponse.json() as { completedBooks: number; year: number };
const projectionResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/dashboard/widgets/year-projection',
headers: auth(token),
});
expect(projectionResponse.statusCode).toBe(200);
const projection = projectionResponse.json() as { booksCompletedYtd: number };
const overviewResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/activity-overview',
headers: auth(token),
});
expect(overviewResponse.statusCode).toBe(200);
const overview = overviewResponse.json() as {
snapshot: { completedBooksYtd: number };
goal: { year: number; completedBooks: number; points: Array<{ actualCumulative: number }> };
completion: { months: Array<{ year: number; month: number; count: number }> };
};
// Four books finished this year, one of them twice: every surface has to say the same
// thing, and say 5, rather than the 2 a session-derived count would have reported.
expect(widget.completedBooks).toBe(5);
expect(projection.booksCompletedYtd).toBe(widget.completedBooks);
expect(overview.goal.completedBooks).toBe(widget.completedBooks);
expect(overview.snapshot.completedBooksYtd).toBe(widget.completedBooks);
expect(overview.goal.points.at(-1)?.actualCumulative).toBe(5);
expect(overview.goal.year).toBe(widget.year);
const monthsThisYear = overview.completion.months.filter((month) => month.year === thisYear);
expect(monthsThisYear.reduce((sum, month) => sum + month.count, 0)).toBe(5);
const monthsLastYear = overview.completion.months.filter((month) => month.year === thisYear - 1);
expect(monthsLastYear.reduce((sum, month) => sum + month.count, 0)).toBe(1);
});
it('refreshes cached dashboard and statistics counts immediately after reading history and status date edits', async () => {
const book = await createBook();
let dashboardBaseline: number | undefined;
let statisticsBaseline: number | undefined;
async function readingGoal() {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/dashboard/widgets/reading-goal',
headers: auth(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
const goal = response.json() as { completedBooks: number; year: number };
const statistics = await ctx.app.inject({ method: 'GET', url: '/api/v1/user-statistics/activity-overview', headers: auth(ctx.adminToken) });
expect(statistics.statusCode).toBe(200);
const completed = statistics.json().goal.completedBooks as number;
dashboardBaseline ??= goal.completedBooks;
statisticsBaseline ??= completed;
expect(completed - statisticsBaseline).toBe(goal.completedBooks - dashboardBaseline);
return goal;
}
const baseline = await readingGoal();
const thisYearEnd = `${baseline.year}-01-15`;
const lastYearEnd = `${baseline.year - 1}-12-15`;
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { endedOn: thisYearEnd, outcome: 'completed' },
});
expect(created.statusCode).toBe(201);
const attempt = created.json() as { id: number };
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks + 1);
const backdated = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${book.bookId}/reading-attempts/${attempt.id}`,
headers: auth(ctx.adminToken),
payload: { endedOn: lastYearEnd },
});
expect(backdated.statusCode).toBe(200);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks);
expect((await patchStatus(book.bookId, { finishedAt: thisYearEnd })).statusCode).toBe(200);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks + 1);
expect((await patchStatus(book.bookId, { finishedAt: null })).statusCode).toBe(200);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks);
expect((await patchStatus(book.bookId, { finishedAt: thisYearEnd })).statusCode).toBe(200);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks + 1);
const deleted = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${book.bookId}/reading-attempts/${attempt.id}`,
headers: auth(ctx.adminToken),
});
expect(deleted.statusCode).toBe(204);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks);
expect((await patchStatus(book.bookId, { status: 'read', finishedAt: thisYearEnd })).statusCode).toBe(200);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks + 1);
const reset = await ctx.app.inject({ method: 'POST', url: `/api/v1/books/${book.bookId}/reset-reading-state`, headers: auth(ctx.adminToken) });
expect(reset.statusCode).toBe(201);
expect((await readingGoal()).completedBooks).toBe(baseline.completedBooks);
});
it('refreshes currently-reading after automatic activity resumes a manually paused book', async () => {
const book = await createBook();
await ctx.db.insert(schema.bookMetadata).values({ bookId: book.bookId, title: 'Paused book' });
expect((await patchStatus(book.bookId, { status: 'on_hold', startedAt: '2026-01-01' })).statusCode).toBe(200);
const current = async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/dashboard/widgets/currently-reading', headers: auth(ctx.adminToken) });
expect(response.statusCode).toBe(200);
return response.json().books as Array<{ bookId: number }>;
};
expect((await current()).some((row) => row.bookId === book.bookId)).toBe(false);
const before = await listAttempts(book.bookId);
await ctx.app.get(ReadingAttemptService).recordActivity({
userId: adminUserId,
bookId: book.bookId,
occurredOn: '2026-01-02',
origin: 'kobo',
progress: 25,
finishThreshold: 98,
strongRereadEvidence: false,
meaningfulActivity: true,
});
const after = await listAttempts(book.bookId);
expect(after).toEqual(before);
expect((await current()).some((row) => row.bookId === book.bookId)).toBe(true);
});
});