fix(kobo): refuse a replacement with the bytes the device already has

A replacement byte-identical to the device's copy was never downloaded, so
it stayed offered and every bookmark the device pushed was discarded.
Keep the current delivery instead, and tell the user in the replacement
panel that the device already has this copy.

Ref #1786
This commit is contained in:
neon
2026-10-17 00:10:32 +00:00
parent 5051ec0913
commit abb44aa47a
6 changed files with 61 additions and 6 deletions
@@ -52,6 +52,9 @@ export class KoboDeliveryRepository {
throw new ConflictException({ code: 'KOBO_REPLACEMENT_ALREADY_PENDING' });
return existing;
}
// The device already holds these exact bytes. A replacement it never needs to download would stay
// offered, and keep every bookmark it pushes from counting, so there is nothing to replace.
if (replace && existing && !existing.options.replacement && existing.metadata.sha256 === values.metadata.sha256) return existing;
if (existing) await tx.update(schema.koboDeviceDeliveries).set({ current: false }).where(eq(schema.koboDeviceDeliveries.id, existing.id));
const [row] = await tx
.insert(schema.koboDeviceDeliveries)
@@ -439,6 +439,21 @@ describe('Read-aloud delivery contracts (e2e)', { timeout: 180_000 }, () => {
expect((await download()).rawPayload).toEqual(sourceBytes);
});
it('keeps the current delivery instead of offering a replacement with the bytes the device already has', async () => {
const device = (await request('POST', '/api/v1/kobo/devices', { name: 'Identical replacement' })).json();
await request('PATCH', '/api/v1/kobo/settings', { readAloudDelivery: 'audioless_epub' });
const base = `/api/v1/kobo/devices/${device.id}`;
expect((await ctx.app.inject({ method: 'GET', url: `/api/v1/kobo/${device.token}/v1/books/${book.bookId}/download` })).statusCode).toBe(200);
const delivered = (await request('GET', `${base}/books/${book.bookId}/delivery`)).json();
const prepared = await request('POST', `${base}/books/${book.bookId}/replacement`, { variant: 'audioless_epub' });
expect(prepared.statusCode).toBe(201);
expect(prepared.json()).toMatchObject({ id: delivered.id, options: { replacement: false } });
const rows = await ctx.db.query.koboDeviceDeliveries.findMany({ where: eq(schema.koboDeviceDeliveries.deviceId, device.id) });
expect(rows).toHaveLength(1);
});
describe('Storyteller progress-reset regressions over live HTTP', () => {
let origin: string;
const cfi = 'epubcfi(/6/2!/4/2/2/1:3)';