mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
e6ea5bd77e
Refresh dashboard and statistics caches immediately after reading history, projection, reset, import, and shared deletion changes. Coalesce bulk notifications while keeping committed changes visible to concurrent reads, and isolate listener failures from successful writes. Skip unchanged Hardcover attempt updates and protect cache generations against stale in-flight loads. Cover date edits, projection-only resumes, rollback, bulk failures, user scope, and cache races with regression tests. Ref #1695
1063 lines
47 KiB
TypeScript
1063 lines
47 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();
|
|
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('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);
|
|
});
|
|
|
|
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);
|
|
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,
|
|
1,
|
|
);
|
|
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,
|
|
1,
|
|
);
|
|
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('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);
|
|
});
|
|
});
|