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
@@ -50,6 +50,64 @@ describe('ReadingAttemptService', () => {
expect(fake.rows).toHaveLength(1);
});
describe('sessions a device delivers after its finished state closed the read', () => {
const lateActivity = (occurredOn: string, progress = 64, strongRereadEvidence = false) =>
service.recordActivity({
userId: 1,
bookId: 10,
occurredOn,
origin: 'bookorbit',
progress,
finishThreshold: 98,
strongRereadEvidence,
meaningfulActivity: true,
timeZone: 'America/Chicago',
});
const finished = (startedOn: string | null) =>
fake.repo.create({}, { userId: 1, bookId: 10, startedOn, endedOn: '2026-10-17', outcome: 'completed', origin: 'kobo' });
it('files a session from within the read into it instead of opening another', async () => {
const read = await finished('2026-10-11');
await expect(lateActivity('2026-10-16')).resolves.toBeNull();
expect(fake.rows).toHaveLength(1);
expect(fake.repo.attachBackfilledSessions).toHaveBeenCalledWith({}, 1, 10, read.id, 'America/Chicago', '2026-10-11', '2026-10-17');
});
it('does not open a second completed read when the late session is already at the finish', async () => {
await finished('2026-10-11');
await expect(lateActivity('2026-10-17', 100)).resolves.toBeNull();
expect(fake.rows).toHaveLength(1);
});
it('bounds the files it adopts by the late session itself when the read has no start', async () => {
const read = await finished(null);
await lateActivity('2026-10-16');
expect(fake.repo.attachBackfilledSessions).toHaveBeenCalledWith({}, 1, 10, read.id, 'America/Chicago', '2026-10-16', '2026-10-17');
});
it('leaves a session from before the read began unassigned without opening a read', async () => {
await finished('2026-10-11');
await expect(lateActivity('2026-10-02')).resolves.toBeNull();
expect(fake.rows).toHaveLength(1);
expect(fake.repo.attachBackfilledSessions).not.toHaveBeenCalled();
});
it('still opens a reread from activity after the finish day', async () => {
await finished('2026-10-11');
await expect(lateActivity('2026-10-18')).resolves.toMatchObject({ status: 'rereading' });
expect(fake.rows).toHaveLength(2);
});
});
it('opens a reread from a strong Kobo transition', async () => {
await fake.repo.create(
{},
@@ -276,6 +276,8 @@ export class ReadingAttemptService {
finishThreshold: number;
strongRereadEvidence: boolean;
meaningfulActivity: boolean;
/** The reader's timezone, which files late sessions into the read they belong to. */
timeZone?: string;
}): Promise<UserBookStatus | null> {
let changed = false;
const result = await this.repo.transaction<UserBookStatus | null>(async (tx) => {
@@ -284,6 +286,29 @@ export class ReadingAttemptService {
const hasCompleted = await this.repo.hasCompleted(tx, input.userId, input.bookId);
const isFinished = hasReachedProgressThreshold(input.progress, input.finishThreshold);
// A device that went without syncing delivers the sessions of a read only after its finished state
// has closed the attempt. Activity dated no later than that close is backlog, not a new read: it
// joins the read it falls within, and activity from before that read began joins nothing.
if (
!active &&
!input.strongRereadEvidence &&
latest?.outcome === 'completed' &&
latest.endedOn !== null &&
input.occurredOn <= latest.endedOn
) {
if (input.timeZone && (latest.startedOn === null || input.occurredOn >= latest.startedOn)) {
await this.repo.attachBackfilledSessions(
tx,
input.userId,
input.bookId,
latest.id,
input.timeZone,
latest.startedOn ?? input.occurredOn,
latest.endedOn,
);
}
return null;
}
if (!active && hasCompleted && !input.strongRereadEvidence && !input.meaningfulActivity) return null;
if (!active && isFinished && !hasCompleted) {
latest = await this.repo.create(tx, {
@@ -273,6 +273,7 @@ export class UserBookStatusService {
finishThreshold: normalizedFinishThreshold,
strongRereadEvidence: activity.strongRereadEvidence === true,
meaningfulActivity: activity.meaningfulActivity === true,
timeZone,
});
if (updated && updated.status !== (existing?.status ?? null)) {
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, {
@@ -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);