fix(reading): file late Kobo sessions into the read they finished

When a Kobo syncs after finishing a book, its finished state closes the
reading attempt before the offline sessions arrive, and those sessions
opened a second attempt, marking the book as reread or read twice.
Activity dated within the finished read now joins it, activity from
before it began joins nothing, and an undated start never widens the
range. Update the reading-attempts E2E Kobo test to use a real device
that holds the book.

Fixes #1224
Fixes #1182
This commit is contained in:
neon
2026-10-17 02:55:13 +00:00
parent 058cd6e7e4
commit b189bf2edd
5 changed files with 115 additions and 2 deletions
@@ -44,6 +44,7 @@ export function makeFakeRepo() {
findLatest: vi.fn((_tx: object, userId: number, bookId: number) =>
Promise.resolve([...rows].reverse().find((row) => row.userId === userId && row.bookId === bookId && row.deletedAt === null)),
),
attachBackfilledSessions: vi.fn(() => Promise.resolve()),
hasCompleted: vi.fn((_tx: object, userId: number, bookId: number) =>
Promise.resolve(rows.some((row) => row.userId === userId && row.bookId === bookId && row.outcome === 'completed' && row.deletedAt === null)),
),
+30 -2
View File
@@ -346,6 +346,34 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
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,
@@ -357,7 +385,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
1,
98,
false,
1,
deviceId,
);
await kobo.upsertState(
adminUserId,
@@ -370,7 +398,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
1,
98,
false,
1,
deviceId,
);
const history = await listAttempts(book.bookId);
expect(history.total).toBe(2);