fix(kobo): stop a replacement with no earlier copy from blocking delivery

A replacement prepared before the device ever received the book had no
earlier delivery to fall back to, so every sync failed the book. It now
stops standing for the book and the book gets an ordinary first offer.
Cancelling a replacement the device has not fetched now clears it, hands
the book back to the earlier delivery, and re-announces it. Update the
download E2E suite for the stale-offer replacement from #1816.

Ref #1786
This commit is contained in:
neon
2026-10-16 23:47:40 +00:00
parent d02b4fcb8a
commit dd004a3fa7
5 changed files with 164 additions and 12 deletions
+27 -1
View File
@@ -222,7 +222,6 @@ describe('Kobo download identity in KOReader (e2e)', { timeout: 180_000 }, () =>
expect(Date.now() - duplicateStart).toBeLessThan(1000);
vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(path);
const startedAt = Date.now();
expect((await download()).statusCode).toBe(409);
const response = await download(freshDevice.token);
const durationMs = Date.now() - startedAt;
expect(response.statusCode).toBe(200);
@@ -537,4 +536,31 @@ describe('Kobo download identity in KOReader (e2e)', { timeout: 180_000 }, () =>
expect(after.libraryVersion).not.toBe(before);
expect(after.matches).toEqual([]);
});
it('replaces an offer its source can no longer rebuild instead of failing every download of the book', async () => {
const created = await ctx.app.inject({
method: 'POST',
url: '/api/v1/kobo/devices',
headers: authHeader(user.accessToken),
payload: { name: 'Stale offer device' },
});
const device = created.json() as { id: number; token: string };
const deliveries = ctx.app.get(KoboDeliveryService);
const conversion = vi.spyOn(ctx.app.get(KepubConversionService), 'getKepubPath').mockResolvedValue(downloadPath);
const stale = await deliveries.offer(user.userId, device.id, source.bookId);
const rebuilt = await createEpubFixture(ctx.fixture.rootPath, 'rebuilt.epub', { title: 'Rebuilt conversion', uid: `urn:uuid:${randomUUID()}` });
conversion.mockResolvedValue(rebuilt);
try {
const response = await download(device.token);
expect(response.statusCode).toBe(200);
expect(response.rawPayload).toEqual(await readFile(rebuilt));
const fresh = await deliveries.current(user.userId, device.id, source.bookId);
expect(fresh!.id).not.toBe(stale.id);
expect(fresh!.previousId).toBeNull();
expect((await deliveries.byId(user.userId, device.id, stale.id))!.current).toBe(false);
expect((await download(device.token)).rawPayload).toEqual(await readFile(rebuilt));
} finally {
conversion.mockResolvedValue(downloadPath);
}
});
});
@@ -413,6 +413,32 @@ describe('Read-aloud delivery contracts (e2e)', { timeout: 180_000 }, () => {
expect((await action('cancel')).json().state).toBe('user_confirmed');
});
it('delivers a book whose replacement was prepared before any copy, and an unfetched cancel hands back the earlier copy', async () => {
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Replacement before delivery' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'original' });
const base = `/api/v1/kobo/devices/${device.id}`;
const download = () => ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/books/${book.bookId}/download` });
const early = (await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
expect(early).toMatchObject({ state: 'prepared', previousId: null });
const first = await download();
expect(first.statusCode).toBe(200);
expect(first.rawPayload).toEqual(sourceBytes);
const delivered = (await request('GET', `${base}/books/${book.bookId}/delivery`)).json();
expect(delivered.id).not.toBe(early.id);
expect(delivered.options.replacement).toBe(false);
await waitForCondition(async () => {
const row = await ctx.db.query.koboDeviceDeliveries.findFirst({ where: eq(schema.koboDeviceDeliveries.id, delivered.id) });
expect(row?.state).toBe('transfer_observed');
});
const pending = (await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' })).json();
expect((await request('POST', `${base}/replacements/${pending.id}/offer`)).json().state).toBe('offered');
expect((await request('POST', `${base}/replacements/${pending.id}/cancel`)).json()).toMatchObject({ state: 'cancelled', current: false });
expect((await request('GET', `${base}/books/${book.bookId}/delivery`)).json()).toMatchObject({ id: delivered.id, state: 'transfer_observed' });
expect((await download()).rawPayload).toEqual(sourceBytes);
});
describe('Storyteller progress-reset regressions over live HTTP', () => {
let origin: string;
const cfi = 'epubcfi(/6/2!/4/2/2/1:3)';