mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(kobo): sync manual book read status updates to kobo devices
Project manual book status changes onto Kobo reading states and mark device snapshot rows as unsynced. This ensures manual status updates made in the web UI reach registered Kobo devices on the next sync without overwriting device bookmarks. Closes #812
This commit is contained in:
@@ -772,7 +772,7 @@ describe('BookRepository', () => {
|
||||
const db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(
|
||||
makeSelectChain('limit', [
|
||||
{
|
||||
@@ -839,7 +839,7 @@ describe('BookRepository', () => {
|
||||
const db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(
|
||||
makeSelectChain('limit', [
|
||||
{
|
||||
@@ -888,7 +888,7 @@ describe('BookRepository', () => {
|
||||
const db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [existingState])),
|
||||
insert: vi.fn().mockReturnValue(insertChain),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -915,7 +915,7 @@ describe('BookRepository', () => {
|
||||
const db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub' }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete: 98 }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [existingState])),
|
||||
insert: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -930,7 +930,9 @@ describe('BookRepository', () => {
|
||||
|
||||
it('does not sync Kobo reading state for non-primary EPUB files', async () => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 99, format: 'epub' }])),
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 99, format: 'epub', markAsFinishedPercentComplete: 98 }])),
|
||||
insert: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
@@ -942,6 +944,45 @@ describe('BookRepository', () => {
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Kobo status derived from the library finished threshold', () => {
|
||||
async function syncStatusFor(percentage: number, markAsFinishedPercentComplete: unknown): Promise<string> {
|
||||
const insertChain = makeInsertChain();
|
||||
const db = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [{ bookId: 10, primaryFileId: 9, format: 'epub', markAsFinishedPercentComplete }]))
|
||||
.mockReturnValueOnce(makeSelectChain('limit', [])),
|
||||
insert: vi.fn().mockReturnValue(insertChain),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await new BookRepository(db as never).syncKoboReadingStateFromProgress(5, 9, percentage);
|
||||
|
||||
return (insertChain.values.mock.calls[0][0] as { statusInfo: { Status: string } }).statusInfo.Status;
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ percentage: 0, threshold: 98, expected: 'ReadyToRead' },
|
||||
{ percentage: 40, threshold: 98, expected: 'Reading' },
|
||||
{ percentage: 97.5, threshold: 98, expected: 'Reading' },
|
||||
{ percentage: 98, threshold: 98, expected: 'Finished' },
|
||||
{ percentage: 100, threshold: 98, expected: 'Finished' },
|
||||
{ percentage: 95, threshold: 95, expected: 'Finished' },
|
||||
{ percentage: 99, threshold: 100, expected: 'Reading' },
|
||||
])('reports $expected at $percentage% with a $threshold% threshold', async ({ percentage, threshold, expected }) => {
|
||||
await expect(syncStatusFor(percentage, threshold)).resolves.toBe(expected);
|
||||
});
|
||||
|
||||
it('never reports an unread book as finished when the threshold is zero', async () => {
|
||||
await expect(syncStatusFor(0, 0)).resolves.toBe('ReadyToRead');
|
||||
});
|
||||
|
||||
it('requires full completion when the threshold is unusable', async () => {
|
||||
await expect(syncStatusFor(98, null)).resolves.toBe('Reading');
|
||||
await expect(syncStatusFor(100, null)).resolves.toBe('Finished');
|
||||
});
|
||||
});
|
||||
|
||||
it('reads whether Kobo two-way progress sync is enabled', async () => {
|
||||
const db = {
|
||||
select: vi
|
||||
|
||||
@@ -2004,9 +2004,11 @@ export class BookRepository {
|
||||
bookId: bookFiles.bookId,
|
||||
primaryFileId: books.primaryFileId,
|
||||
format: bookFiles.format,
|
||||
markAsFinishedPercentComplete: libraries.markAsFinishedPercentComplete,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.where(eq(bookFiles.id, fileId))
|
||||
.limit(1);
|
||||
|
||||
@@ -2076,7 +2078,7 @@ export class BookRepository {
|
||||
const statusInfo = {
|
||||
...(this.asJsonObj(existing?.statusInfo) ?? {}),
|
||||
LastModified: nowIso,
|
||||
Status: this.deriveKoboStatus(clampedPercentage),
|
||||
Status: this.deriveKoboStatus(clampedPercentage, file.markAsFinishedPercentComplete),
|
||||
};
|
||||
const statistics = this.asJsonObj(existing?.statistics) ?? { LastModified: nowIso };
|
||||
|
||||
@@ -2223,8 +2225,14 @@ export class BookRepository {
|
||||
return existingPercent !== null && Math.abs(existingPercent - percentage) < PROGRESS_EPSILON;
|
||||
}
|
||||
|
||||
private deriveKoboStatus(percentage: number): string {
|
||||
if (percentage >= 100) return 'Finished';
|
||||
/**
|
||||
* Uses the library's finished threshold so the device agrees with the read status
|
||||
* BookOrbit derives from the same percentage; requiring 100 left books BookOrbit
|
||||
* calls read showing as Reading on Kobo.
|
||||
*/
|
||||
private deriveKoboStatus(percentage: number, markAsFinishedPercentComplete: number): string {
|
||||
const threshold = Number.isFinite(markAsFinishedPercentComplete) ? Math.min(100, Math.max(1, markAsFinishedPercentComplete)) : 100;
|
||||
if (percentage >= threshold) return 'Finished';
|
||||
return percentage > 0 ? 'Reading' : 'ReadyToRead';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { ReadStatus } from '@bookorbit/types';
|
||||
import { type SQL } from 'drizzle-orm';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { KoboStatusProjectionRepository, toKoboReadingStatus } from './kobo-status-projection.repository';
|
||||
|
||||
const dialect = new PgDialect();
|
||||
|
||||
function renderSql(value: unknown) {
|
||||
return dialect.sqlToQuery(value as SQL);
|
||||
}
|
||||
|
||||
function makeDb(options: { twoWayProgressSync?: boolean; changedBookIds?: number[][] } = {}) {
|
||||
const limit = vi.fn().mockResolvedValue(options.twoWayProgressSync === undefined ? [] : [{ twoWayProgressSync: options.twoWayProgressSync }]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
|
||||
const execute = vi.fn();
|
||||
for (const bookIds of options.changedBookIds ?? []) {
|
||||
execute.mockResolvedValueOnce({ rows: bookIds.map((book_id) => ({ book_id })) });
|
||||
execute.mockResolvedValueOnce({ rows: [] });
|
||||
}
|
||||
execute.mockResolvedValue({ rows: [] });
|
||||
|
||||
return { select: vi.fn().mockReturnValue({ from }), execute, from, where, limit };
|
||||
}
|
||||
|
||||
/** Statements are issued in pairs: the reading-state upsert, then the snapshot invalidation. */
|
||||
function upsertStatements(db: ReturnType<typeof makeDb>) {
|
||||
return db.execute.mock.calls.filter((_call, index) => index % 2 === 0).map(([statement]) => renderSql(statement));
|
||||
}
|
||||
|
||||
function unsyncStatements(db: ReturnType<typeof makeDb>) {
|
||||
return db.execute.mock.calls.filter((_call, index) => index % 2 === 1).map(([statement]) => renderSql(statement));
|
||||
}
|
||||
|
||||
/** Book ids are the only bound parameters rendered as a standalone `($n::integer)` row. */
|
||||
function countValueRows(statement: string) {
|
||||
return statement.match(/\(\$\d+::integer\)/g)?.length ?? 0;
|
||||
}
|
||||
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let repo: KoboStatusProjectionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db = makeDb({ changedBookIds: [[10]] });
|
||||
repo = new KoboStatusProjectionRepository(db as never);
|
||||
});
|
||||
|
||||
describe('toKoboReadingStatus', () => {
|
||||
it.each([
|
||||
['unread', 'ReadyToRead'],
|
||||
['want_to_read', 'ReadyToRead'],
|
||||
['reading', 'Reading'],
|
||||
['on_hold', 'Reading'],
|
||||
['rereading', 'Reading'],
|
||||
['abandoned', 'Reading'],
|
||||
['read', 'Finished'],
|
||||
['skimmed', 'Finished'],
|
||||
] as const)('maps %s to %s', (status, expected) => {
|
||||
expect(toKoboReadingStatus(status)).toBe(expected);
|
||||
});
|
||||
|
||||
it('covers every read status', () => {
|
||||
const statuses: ReadStatus[] = ['unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned'];
|
||||
for (const status of statuses) {
|
||||
expect(['ReadyToRead', 'Reading', 'Finished']).toContain(toKoboReadingStatus(status));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('is true only when two-way progress sync is on', async () => {
|
||||
repo = new KoboStatusProjectionRepository(makeDb({ twoWayProgressSync: true }) as never);
|
||||
await expect(repo.isEnabled(1)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is false when two-way progress sync is off', async () => {
|
||||
repo = new KoboStatusProjectionRepository(makeDb({ twoWayProgressSync: false }) as never);
|
||||
await expect(repo.isEnabled(1)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the user has no Kobo sync settings row', async () => {
|
||||
repo = new KoboStatusProjectionRepository(makeDb() as never);
|
||||
await expect(repo.isEnabled(1)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project', () => {
|
||||
it('writes nothing for an empty book list', async () => {
|
||||
await expect(repo.project(1, [], 'read')).resolves.toEqual([]);
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the book ids whose Kobo status changed', async () => {
|
||||
await expect(repo.project(1, [10], 'read')).resolves.toEqual([10]);
|
||||
});
|
||||
|
||||
it('writes the mapped Kobo status and scopes the upsert to the owning user', async () => {
|
||||
await repo.project(7, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
expect(upsert!.sql).toContain('INSERT INTO "kobo_reading_states"');
|
||||
expect(upsert!.sql).toContain('RETURNING book_id');
|
||||
expect(upsert!.params).toContain('Finished');
|
||||
expect(upsert!.params).toContain(7);
|
||||
});
|
||||
|
||||
it('only updates StatusInfo so the reading bookmark stays owned by progress', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
const conflictClause = upsert!.sql.slice(upsert!.sql.indexOf('ON CONFLICT'));
|
||||
expect(conflictClause).toContain('status_info');
|
||||
expect(conflictClause).not.toContain('current_bookmark');
|
||||
expect(conflictClause).not.toContain('statistics');
|
||||
});
|
||||
|
||||
it('merges into the existing StatusInfo so device counters survive', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
expect(upsert!.sql).toContain(`COALESCE("kobo_reading_states".status_info, '{}'::jsonb)`);
|
||||
});
|
||||
|
||||
it('bumps the envelope timestamps the device uses to resolve conflicts', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
const conflictClause = upsert!.sql.slice(upsert!.sql.indexOf('ON CONFLICT'));
|
||||
expect(conflictClause).toContain('last_modified_kobo');
|
||||
expect(conflictClause).toContain('priority_timestamp');
|
||||
});
|
||||
|
||||
it('skips rows whose Kobo status already matches', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
expect(upsert!.sql).toContain(`COALESCE("kobo_reading_states".status_info->>'Status', '') IS DISTINCT FROM`);
|
||||
});
|
||||
|
||||
it('restricts the projection to Kobo-deliverable primary files', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
expect(upsert!.sql).toContain('bf.id = bk.primary_file_id');
|
||||
expect(upsert!.sql).toContain(`bf.format IN ('epub', 'kepub')`);
|
||||
});
|
||||
|
||||
it('marks only unsynced-eligible snapshot rows for the books that changed', async () => {
|
||||
await repo.project(1, [10], 'read');
|
||||
|
||||
const [unsync] = unsyncStatements(db);
|
||||
expect(unsync!.sql).toContain('UPDATE "kobo_device_snapshot_books"');
|
||||
expect(unsync!.sql).toContain('synced = false');
|
||||
expect(unsync!.sql).toContain('is_new = false');
|
||||
expect(unsync!.sql).toContain('sb.pending_delete = false');
|
||||
expect(unsync!.sql).toContain('sb.removed_by_device = false');
|
||||
expect(unsync!.params).toContain(10);
|
||||
});
|
||||
|
||||
it('does not touch device snapshots when no Kobo status changed', async () => {
|
||||
db = makeDb({ changedBookIds: [[]] });
|
||||
repo = new KoboStatusProjectionRepository(db as never);
|
||||
|
||||
await expect(repo.project(1, [10], 'read')).resolves.toEqual([]);
|
||||
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates book ids so the upsert cannot hit the same row twice', async () => {
|
||||
db = makeDb({ changedBookIds: [[10]] });
|
||||
repo = new KoboStatusProjectionRepository(db as never);
|
||||
|
||||
await repo.project(1, [10, 10, 10], 'read');
|
||||
|
||||
const [upsert] = upsertStatements(db);
|
||||
expect(upsert!.params.filter((param) => param === 10)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('batches large book lists instead of building one unbounded statement', async () => {
|
||||
const bookIds = Array.from({ length: 1200 }, (_value, index) => index + 1);
|
||||
db = makeDb({ changedBookIds: [bookIds.slice(0, 500), bookIds.slice(500, 1000), bookIds.slice(1000)] });
|
||||
repo = new KoboStatusProjectionRepository(db as never);
|
||||
|
||||
await expect(repo.project(1, bookIds, 'read')).resolves.toEqual(bookIds);
|
||||
|
||||
const upserts = upsertStatements(db);
|
||||
expect(upserts).toHaveLength(3);
|
||||
expect(countValueRows(upserts[0]!.sql)).toBe(500);
|
||||
expect(countValueRows(upserts[1]!.sql)).toBe(500);
|
||||
expect(countValueRows(upserts[2]!.sql)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { ReadStatus } from '@bookorbit/types';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export type KoboReadingStatus = 'ReadyToRead' | 'Reading' | 'Finished';
|
||||
|
||||
const PROJECTION_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* Kobo only models three states, so several BookOrbit statuses collapse onto one.
|
||||
* `abandoned` maps to Reading rather than ReadyToRead so the device keeps the
|
||||
* bookmark instead of presenting the book as never opened.
|
||||
*/
|
||||
const KOBO_STATUS_BY_READ_STATUS: Record<ReadStatus, KoboReadingStatus> = {
|
||||
unread: 'ReadyToRead',
|
||||
want_to_read: 'ReadyToRead',
|
||||
reading: 'Reading',
|
||||
on_hold: 'Reading',
|
||||
rereading: 'Reading',
|
||||
read: 'Finished',
|
||||
skimmed: 'Finished',
|
||||
abandoned: 'Reading',
|
||||
};
|
||||
|
||||
export function toKoboReadingStatus(status: ReadStatus): KoboReadingStatus {
|
||||
return KOBO_STATUS_BY_READ_STATUS[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes BookOrbit read status onto the Kobo reading state so a status set in the
|
||||
* web UI (or by KOReader/Hardcover) reaches registered devices. Only StatusInfo is
|
||||
* written: CurrentBookmark stays owned by reading progress, which the reading-state
|
||||
* pull path may recompute from the hub position at delivery time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KoboStatusProjectionRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async isEnabled(userId: number): Promise<boolean> {
|
||||
const [settings] = await this.db
|
||||
.select({ twoWayProgressSync: schema.koboSyncSettings.twoWayProgressSync })
|
||||
.from(schema.koboSyncSettings)
|
||||
.where(eq(schema.koboSyncSettings.userId, userId))
|
||||
.limit(1);
|
||||
return settings?.twoWayProgressSync === true;
|
||||
}
|
||||
|
||||
/** Returns the book ids whose Kobo status actually changed. */
|
||||
async project(userId: number, bookIds: number[], status: ReadStatus): Promise<number[]> {
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
if (uniqueBookIds.length === 0) return [];
|
||||
|
||||
const koboStatus = toKoboReadingStatus(status);
|
||||
const changed: number[] = [];
|
||||
|
||||
for (let index = 0; index < uniqueBookIds.length; index += PROJECTION_BATCH_SIZE) {
|
||||
const chunk = uniqueBookIds.slice(index, index + PROJECTION_BATCH_SIZE);
|
||||
const updated = await this.upsertStatusInfo(userId, chunk, koboStatus);
|
||||
if (updated.length === 0) continue;
|
||||
await this.markSnapshotBooksUnsynced(userId, updated);
|
||||
changed.push(...updated);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async upsertStatusInfo(userId: number, bookIds: number[], koboStatus: KoboReadingStatus): Promise<number[]> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const timesStartedReading = koboStatus === 'ReadyToRead' ? 0 : 1;
|
||||
const values = sql.join(
|
||||
bookIds.map((bookId) => sql`(${bookId}::integer)`),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
const result = await this.db.execute<{ book_id: number }>(sql`
|
||||
INSERT INTO ${schema.koboReadingStates}
|
||||
(user_id, book_id, entitlement_id, created_at_kobo, last_modified_kobo, priority_timestamp, current_bookmark, statistics, status_info, updated_at)
|
||||
SELECT
|
||||
${userId},
|
||||
b.book_id,
|
||||
COALESCE(e.entitlement_id::text, b.book_id::text),
|
||||
${nowIso},
|
||||
${nowIso},
|
||||
${nowIso},
|
||||
jsonb_build_object('LastModified', ${nowIso}::text, 'ProgressPercent', 0),
|
||||
jsonb_build_object('LastModified', ${nowIso}::text),
|
||||
jsonb_build_object('LastModified', ${nowIso}::text, 'Status', ${koboStatus}::text, 'TimesStartedReading', ${timesStartedReading}::integer),
|
||||
now()
|
||||
FROM (VALUES ${values}) AS b(book_id)
|
||||
JOIN ${schema.books} AS bk ON bk.id = b.book_id
|
||||
JOIN ${schema.bookFiles} AS bf ON bf.id = bk.primary_file_id AND bf.format IN ('epub', 'kepub')
|
||||
LEFT JOIN ${schema.koboBookEntitlements} AS e ON e.user_id = ${userId} AND e.book_id = b.book_id
|
||||
ON CONFLICT (user_id, book_id) DO UPDATE
|
||||
SET last_modified_kobo = ${nowIso},
|
||||
priority_timestamp = ${nowIso},
|
||||
status_info = COALESCE(${schema.koboReadingStates}.status_info, '{}'::jsonb)
|
||||
|| jsonb_build_object('LastModified', ${nowIso}::text, 'Status', ${koboStatus}::text),
|
||||
updated_at = now()
|
||||
WHERE COALESCE(${schema.koboReadingStates}.status_info->>'Status', '') IS DISTINCT FROM ${koboStatus}
|
||||
RETURNING book_id
|
||||
`);
|
||||
|
||||
return (result?.rows ?? []).map((row) => Number(row.book_id));
|
||||
}
|
||||
|
||||
private async markSnapshotBooksUnsynced(userId: number, bookIds: number[]): Promise<void> {
|
||||
const ids = sql.join(
|
||||
bookIds.map((bookId) => sql`${bookId}`),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
await this.db.execute(sql`
|
||||
UPDATE ${schema.koboSnapshotBooks} AS sb
|
||||
SET synced = false,
|
||||
is_new = false
|
||||
FROM ${schema.koboLibrarySnapshots} AS snap
|
||||
WHERE snap.id = sb.snapshot_id
|
||||
AND snap.user_id = ${userId}
|
||||
AND sb.book_id IN (${ids})
|
||||
AND sb.synced = true
|
||||
AND sb.pending_delete = false
|
||||
AND sb.removed_by_device = false
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -291,4 +291,67 @@ describe('ReadingAttemptService', () => {
|
||||
|
||||
expect(row.endedOn).toBe('2025-01-10');
|
||||
});
|
||||
|
||||
describe('closing an attempt that a skewed device clock started in the future', () => {
|
||||
it('ends a completed attempt on its own start date instead of an earlier today', async () => {
|
||||
const active = await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-11-01', origin: 'kobo' });
|
||||
|
||||
const result = await service.applyManualStatus(1, 10, 'read', undefined, undefined, '2026-07-12');
|
||||
|
||||
expect(active.endedOn).toBe('2026-11-01');
|
||||
expect(active.outcome).toBe('completed');
|
||||
expect(result.status).toBe('read');
|
||||
});
|
||||
|
||||
it('ends an implicitly abandoned attempt on its own start date', async () => {
|
||||
const active = await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-11-01', origin: 'kobo' });
|
||||
|
||||
await service.applyManualStatus(1, 10, 'unread', undefined, undefined, '2026-07-12');
|
||||
|
||||
expect(active.endedOn).toBe('2026-11-01');
|
||||
expect(active.outcome).toBe('abandoned');
|
||||
});
|
||||
|
||||
it('ends a newly created closed attempt on its own start date', async () => {
|
||||
await service.applyManualStatus(1, 10, 'read', '2026-11-01', undefined, '2026-07-12');
|
||||
|
||||
expect(fake.rows).toHaveLength(1);
|
||||
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-11-01', endedOn: '2026-11-01', outcome: 'completed' });
|
||||
});
|
||||
|
||||
it('ends an automatic completion on its own start date', async () => {
|
||||
const active = await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-11-01', origin: 'kobo' });
|
||||
|
||||
const result = await service.recordActivity({
|
||||
userId: 1,
|
||||
bookId: 10,
|
||||
occurredOn: '2026-07-12',
|
||||
origin: 'kobo',
|
||||
progress: 100,
|
||||
finishThreshold: 98,
|
||||
strongRereadEvidence: false,
|
||||
meaningfulActivity: true,
|
||||
});
|
||||
|
||||
expect(active.endedOn).toBe('2026-11-01');
|
||||
expect(active.outcome).toBe('completed');
|
||||
expect(result?.status).toBe('read');
|
||||
});
|
||||
|
||||
it('still ends on today when the attempt started in the past', async () => {
|
||||
const active = await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-01-05', origin: 'kobo' });
|
||||
|
||||
await service.applyManualStatus(1, 10, 'read', undefined, undefined, '2026-07-12');
|
||||
|
||||
expect(active.endedOn).toBe('2026-07-12');
|
||||
});
|
||||
|
||||
it('leaves an explicitly supplied end date untouched for upstream validation', async () => {
|
||||
const active = await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-11-01', origin: 'kobo' });
|
||||
|
||||
await service.applyManualStatus(1, 10, 'read', undefined, '2026-07-12', '2026-07-12');
|
||||
|
||||
expect(active.endedOn).toBe('2026-07-12');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,15 @@ function outcomeForStatus(status: ReadStatus): ReadingAttemptOutcome | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing an attempt on a date earlier than it started violates the reading_attempts range
|
||||
* check. A device reporting a skewed clock can leave startedOn in the future, so the implied
|
||||
* close date has to yield to it. Explicit caller dates are validated upstream and left alone.
|
||||
*/
|
||||
function notBeforeStart(endedOn: string, startedOn: string | null): string {
|
||||
return startedOn && endedOn < startedOn ? startedOn : endedOn;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadingAttemptService {
|
||||
constructor(private readonly repo: ReadingAttemptRepository) {}
|
||||
@@ -65,9 +74,10 @@ export class ReadingAttemptService {
|
||||
const outcome = outcomeForStatus(status);
|
||||
if (outcome) {
|
||||
if (active) {
|
||||
const effectiveStartedOn = startedOn === undefined ? active.startedOn : startedOn;
|
||||
latest = await this.repo.update(tx, userId, bookId, active.id, {
|
||||
...(startedOn !== undefined ? { startedOn } : {}),
|
||||
endedOn: endedOn === undefined ? today : endedOn,
|
||||
endedOn: endedOn === undefined ? notBeforeStart(today, effectiveStartedOn) : endedOn,
|
||||
outcome,
|
||||
});
|
||||
active = null;
|
||||
@@ -76,14 +86,14 @@ export class ReadingAttemptService {
|
||||
userId,
|
||||
bookId,
|
||||
startedOn: startedOn ?? null,
|
||||
endedOn: endedOn === undefined ? today : endedOn,
|
||||
endedOn: endedOn === undefined ? notBeforeStart(today, startedOn ?? null) : endedOn,
|
||||
outcome,
|
||||
origin: 'manual',
|
||||
});
|
||||
}
|
||||
} else if (active) {
|
||||
latest = await this.repo.update(tx, userId, bookId, active.id, {
|
||||
endedOn: endedOn === undefined ? today : endedOn,
|
||||
endedOn: endedOn === undefined ? notBeforeStart(today, active.startedOn) : endedOn,
|
||||
outcome: 'abandoned',
|
||||
});
|
||||
active = null;
|
||||
@@ -166,7 +176,7 @@ export class ReadingAttemptService {
|
||||
let status: ReadStatus;
|
||||
if (active && isFinished) {
|
||||
projectionTarget = await this.repo.update(tx, input.userId, input.bookId, active.id, {
|
||||
endedOn: input.occurredOn,
|
||||
endedOn: notBeforeStart(input.occurredOn, active.startedOn),
|
||||
outcome: 'completed',
|
||||
});
|
||||
status = 'read';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AchievementModule } from '../achievement/achievement.module';
|
||||
import { KoboStatusProjectionRepository } from './kobo-status-projection.repository';
|
||||
import { UserBookStatusRepository } from './user-book-status.repository';
|
||||
import { UserBookStatusService } from './user-book-status.service';
|
||||
import { ReadingAttemptRepository } from './reading-attempt.repository';
|
||||
@@ -9,7 +10,14 @@ import { ReadingAttemptBackfillService } from './reading-attempt-backfill.servic
|
||||
|
||||
@Module({
|
||||
imports: [AchievementModule],
|
||||
providers: [UserBookStatusService, UserBookStatusRepository, ReadingAttemptService, ReadingAttemptRepository, ReadingAttemptBackfillService],
|
||||
providers: [
|
||||
UserBookStatusService,
|
||||
UserBookStatusRepository,
|
||||
KoboStatusProjectionRepository,
|
||||
ReadingAttemptService,
|
||||
ReadingAttemptRepository,
|
||||
ReadingAttemptBackfillService,
|
||||
],
|
||||
exports: [UserBookStatusService, ReadingAttemptService],
|
||||
})
|
||||
export class UserBookStatusModule {}
|
||||
|
||||
@@ -35,6 +35,10 @@ const mockRepo = {
|
||||
const mockAchievementEvents = {
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const mockKoboProjection = {
|
||||
isEnabled: vi.fn<(...args: [number]) => Promise<boolean>>(),
|
||||
project: vi.fn<(...args: [number, number[], ReadStatus]) => Promise<number[]>>(),
|
||||
};
|
||||
|
||||
let service: UserBookStatusService;
|
||||
|
||||
@@ -45,7 +49,9 @@ beforeEach(() => {
|
||||
mockRepo.upsert.mockResolvedValue(undefined);
|
||||
mockRepo.upsertState.mockResolvedValue(undefined);
|
||||
mockRepo.findSessionBoundariesForBook.mockResolvedValue({ firstStartedAt: null, lastEndedAt: null });
|
||||
service = new UserBookStatusService(mockRepo as unknown as UserBookStatusRepository, mockAchievementEvents as never);
|
||||
mockKoboProjection.isEnabled.mockResolvedValue(true);
|
||||
mockKoboProjection.project.mockResolvedValue([]);
|
||||
service = new UserBookStatusService(mockRepo as unknown as UserBookStatusRepository, mockAchievementEvents as never, mockKoboProjection as never);
|
||||
});
|
||||
|
||||
describe('setManual', () => {
|
||||
@@ -575,3 +581,131 @@ describe('findByBookIds', () => {
|
||||
expect(result.get(20)?.status).toBe('read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kobo status projection', () => {
|
||||
it('projects a manual status change so it reaches registered devices', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'unread' }));
|
||||
|
||||
await service.setManual(1, 10, 'read');
|
||||
|
||||
expect(mockKoboProjection.isEnabled).toHaveBeenCalledWith(1);
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10], 'read');
|
||||
});
|
||||
|
||||
it('does not project when the status is unchanged', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'read' }));
|
||||
|
||||
await service.setManual(1, 10, 'read');
|
||||
|
||||
expect(mockKoboProjection.project).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not project when only lifecycle dates change', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'read' }));
|
||||
|
||||
await service.updateManual(1, 10, { finishedAt: new Date('2026-05-01T00:00:00.000Z') });
|
||||
|
||||
expect(mockKoboProjection.project).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the projection write when two-way progress sync is disabled', async () => {
|
||||
mockKoboProjection.isEnabled.mockResolvedValue(false);
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'unread' }));
|
||||
|
||||
await service.setManual(1, 10, 'read');
|
||||
|
||||
expect(mockKoboProjection.isEnabled).toHaveBeenCalledWith(1);
|
||||
expect(mockKoboProjection.project).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the status change when the projection fails', async () => {
|
||||
mockKoboProjection.project.mockRejectedValue(new Error('kobo unavailable'));
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'unread' }));
|
||||
|
||||
await expect(service.updateManual(1, 10, { status: 'read' })).resolves.toMatchObject({ status: 'read' });
|
||||
|
||||
expect(mockRepo.upsertState).toHaveBeenCalledOnce();
|
||||
expect(mockAchievementEvents.emit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('never projects device-originated automatic status changes', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'reading', source: 'auto' }));
|
||||
|
||||
await service.autoUpdate(1, 10, 100);
|
||||
|
||||
expect(mockKoboProjection.isEnabled).not.toHaveBeenCalled();
|
||||
expect(mockKoboProjection.project).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects bulk changes in a single call covering only the books that changed', async () => {
|
||||
mockRepo.findByBookIds.mockResolvedValue([makeRow({ bookId: 10, status: 'unread' }), makeRow({ bookId: 11, status: 'read' })]);
|
||||
|
||||
await service.bulkSetManual(1, [10, 11, 12], 'read');
|
||||
|
||||
expect(mockKoboProjection.isEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledTimes(1);
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10, 12], 'read');
|
||||
});
|
||||
|
||||
it('does not project a bulk change when nothing changed', async () => {
|
||||
mockRepo.findByBookIds.mockResolvedValue([makeRow({ bookId: 10, status: 'read' }), makeRow({ bookId: 11, status: 'read' })]);
|
||||
|
||||
await service.bulkSetManual(1, [10, 11], 'read');
|
||||
|
||||
expect(mockKoboProjection.isEnabled).not.toHaveBeenCalled();
|
||||
expect(mockKoboProjection.project).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with reading attempts enabled', () => {
|
||||
const mockAttempts = {
|
||||
applyManualStatus: vi.fn(),
|
||||
};
|
||||
let attemptService: UserBookStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAttempts.applyManualStatus.mockReset();
|
||||
attemptService = new UserBookStatusService(
|
||||
mockRepo as unknown as UserBookStatusRepository,
|
||||
mockAchievementEvents as never,
|
||||
mockKoboProjection as never,
|
||||
mockAttempts as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('projects the status the attempt projection settled on, not the requested one', async () => {
|
||||
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'read' }));
|
||||
mockAttempts.applyManualStatus.mockResolvedValue({
|
||||
status: 'rereading',
|
||||
source: 'manual',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await attemptService.setManual(1, 10, 'reading');
|
||||
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10], 'rereading');
|
||||
});
|
||||
|
||||
it('groups bulk projections by the resulting status', async () => {
|
||||
mockRepo.findOne.mockImplementation((_userId: number, bookId: number) =>
|
||||
Promise.resolve(makeRow({ bookId, status: bookId === 10 ? 'read' : 'unread' })),
|
||||
);
|
||||
mockAttempts.applyManualStatus.mockImplementation((_userId: number, bookId: number) =>
|
||||
Promise.resolve({
|
||||
status: bookId === 10 ? 'rereading' : 'reading',
|
||||
source: 'manual',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
await attemptService.bulkSetManual(1, [10, 11], 'reading');
|
||||
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledTimes(2);
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10], 'rereading');
|
||||
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [11], 'reading');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, InternalServerErrorException, Optional } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException, Logger, Optional } from '@nestjs/common';
|
||||
import type { ReadStatus, UserBookStatus } from '@bookorbit/types';
|
||||
import { UserBookStatusRepository } from './user-book-status.repository';
|
||||
import type { UserBookStatusRow } from '../../db/schema';
|
||||
import { isReadStatus, isReadStatusSource } from './user-book-status.constants';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { KoboStatusProjectionRepository } from './kobo-status-projection.repository';
|
||||
import { ReadingAttemptService } from './reading-attempt.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
const DEFAULT_FINISH_THRESHOLD = 98;
|
||||
const READING_THRESHOLD = 0.25;
|
||||
@@ -26,9 +28,12 @@ export type AutoReadingActivity = {
|
||||
|
||||
@Injectable()
|
||||
export class UserBookStatusService {
|
||||
private readonly logger = new Logger(UserBookStatusService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: UserBookStatusRepository,
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
private readonly koboProjection: KoboStatusProjectionRepository,
|
||||
@Optional() private readonly attempts?: ReadingAttemptService,
|
||||
) {}
|
||||
|
||||
@@ -42,6 +47,19 @@ export class UserBookStatusService {
|
||||
patch: ManualStatusPatch,
|
||||
dateKeys?: { startedOn?: string | null; endedOn?: string | null },
|
||||
): Promise<UserBookStatus> {
|
||||
const { state, statusChanged } = await this.applyManualStatus(userId, bookId, patch, dateKeys);
|
||||
if (statusChanged) {
|
||||
await this.projectToKobo(userId, [bookId], state.status);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async applyManualStatus(
|
||||
userId: number,
|
||||
bookId: number,
|
||||
patch: ManualStatusPatch,
|
||||
dateKeys?: { startedOn?: string | null; endedOn?: string | null },
|
||||
): Promise<{ state: UserBookStatus; statusChanged: boolean }> {
|
||||
const existing = await this.repo.findOne(userId, bookId);
|
||||
const now = new Date();
|
||||
const hasStatus = Object.prototype.hasOwnProperty.call(patch, 'status');
|
||||
@@ -60,7 +78,8 @@ export class UserBookStatusService {
|
||||
today,
|
||||
);
|
||||
const previousStatus = existing?.status ?? null;
|
||||
if (hasStatus && updated.status !== previousStatus) {
|
||||
const statusChanged = hasStatus && updated.status !== previousStatus;
|
||||
if (statusChanged) {
|
||||
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, {
|
||||
userId,
|
||||
bookId,
|
||||
@@ -68,7 +87,7 @@ export class UserBookStatusService {
|
||||
previousStatus,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
return { state: updated, statusChanged };
|
||||
}
|
||||
|
||||
const nextStatus = patch.status ?? existing?.status ?? 'unread';
|
||||
@@ -109,7 +128,8 @@ export class UserBookStatusService {
|
||||
}
|
||||
|
||||
const previousStatus = existing?.status ?? null;
|
||||
if (hasStatus && nextStatus !== previousStatus) {
|
||||
const statusChanged = hasStatus && nextStatus !== previousStatus;
|
||||
if (statusChanged) {
|
||||
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, {
|
||||
userId,
|
||||
bookId,
|
||||
@@ -119,24 +139,41 @@ export class UserBookStatusService {
|
||||
}
|
||||
|
||||
return {
|
||||
status: nextStatus,
|
||||
source: 'manual',
|
||||
startedAt: nextStartedAt?.toISOString() ?? null,
|
||||
finishedAt: nextFinishedAt?.toISOString() ?? null,
|
||||
updatedAt: now.toISOString(),
|
||||
state: {
|
||||
status: nextStatus,
|
||||
source: 'manual',
|
||||
startedAt: nextStartedAt?.toISOString() ?? null,
|
||||
finishedAt: nextFinishedAt?.toISOString() ?? null,
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
statusChanged,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkSetManual(userId: number, bookIds: number[], status: ReadStatus): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
// Grouped by resulting status because reading-attempt projection can settle on a
|
||||
// different status than requested (a completed book set to "reading" becomes "rereading").
|
||||
const changedByStatus = new Map<ReadStatus, number[]>();
|
||||
const recordChange = (resolved: ReadStatus, bookId: number) => {
|
||||
const ids = changedByStatus.get(resolved);
|
||||
if (ids) ids.push(bookId);
|
||||
else changedByStatus.set(resolved, [bookId]);
|
||||
};
|
||||
|
||||
if (this.attempts) {
|
||||
const batchSize = 50;
|
||||
for (let offset = 0; offset < bookIds.length; offset += batchSize) {
|
||||
const batch = bookIds.slice(offset, offset + batchSize);
|
||||
await Promise.all(batch.map((bookId) => this.updateManual(userId, bookId, { status })));
|
||||
const results = await Promise.all(batch.map((bookId) => this.applyManualStatus(userId, bookId, { status })));
|
||||
results.forEach((result, index) => {
|
||||
if (result.statusChanged) recordChange(result.state.status, batch[index]!);
|
||||
});
|
||||
}
|
||||
await this.projectGroupsToKobo(userId, changedByStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const existing = await this.repo.findByBookIds(userId, bookIds);
|
||||
const existingMap = new Map(existing.map((row) => [row.bookId, row]));
|
||||
@@ -162,8 +199,34 @@ export class UserBookStatusService {
|
||||
newStatus: status,
|
||||
previousStatus,
|
||||
});
|
||||
recordChange(status, bookId);
|
||||
}
|
||||
}
|
||||
await this.projectGroupsToKobo(userId, changedByStatus);
|
||||
}
|
||||
|
||||
private async projectGroupsToKobo(userId: number, changedByStatus: Map<ReadStatus, number[]>): Promise<void> {
|
||||
for (const [status, ids] of changedByStatus) {
|
||||
await this.projectToKobo(userId, ids, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the new read status onto the Kobo reading state so devices pick it up on
|
||||
* the next sync. Best-effort: a Kobo projection failure must not fail the status change.
|
||||
*/
|
||||
private async projectToKobo(userId: number, bookIds: number[], status: ReadStatus): Promise<void> {
|
||||
if (bookIds.length === 0) return;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
if (!(await this.koboProjection.isEnabled(userId))) return;
|
||||
await this.koboProjection.project(userId, bookIds, status);
|
||||
} catch (error: unknown) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.warn(
|
||||
`[user_book_status.kobo_projection] [fail] userId=${userId} count=${bookIds.length} status=${status} durationMs=${Date.now() - startedAt} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - kobo status projection failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async autoUpdate(
|
||||
|
||||
@@ -599,4 +599,142 @@ describe('Kobo multi-device library sync (e2e)', { timeout: 180_000 }, () => {
|
||||
});
|
||||
expect(retiredLegacySnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('manual read status delivery', () => {
|
||||
let statusBookIds!: number[];
|
||||
|
||||
async function setReadStatus(bookId: number, status: string): Promise<Record<string, unknown>> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/books/${bookId}/status`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { status },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
return response.json() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function entitlementIdFor(bookId: number): Promise<string> {
|
||||
const [identity] = await ctx.db
|
||||
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
|
||||
.from(schema.koboBookEntitlements)
|
||||
.where(and(eq(schema.koboBookEntitlements.userId, userId), eq(schema.koboBookEntitlements.bookId, bookId)));
|
||||
if (!identity) throw new Error(`Book ${bookId} has no Kobo entitlement identity`);
|
||||
return identity.entitlementId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const paths = await Promise.all(
|
||||
Array.from({ length: 4 }, () =>
|
||||
createEpubFixture(library.folderPath, `kobo-manual-status-${randomUUID()}.epub`, {
|
||||
title: `Kobo Manual Status ${randomUUID().slice(0, 8)}`,
|
||||
uid: `urn:uuid:${randomUUID()}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||
statusBookIds = (await Promise.all(paths.map((path) => locateBookByAbsolutePath(ctx, path)))).map((book) => book.bookId);
|
||||
|
||||
const addBooksResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/collections/${collectionId}/books`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { bookIds: statusBookIds },
|
||||
});
|
||||
expect([200, 201]).toContain(addBooksResponse.statusCode);
|
||||
}, 180_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await drainEntries(deviceA);
|
||||
await drainEntries(deviceB);
|
||||
});
|
||||
|
||||
it('delivers Finished for a book marked read that was never opened anywhere', async () => {
|
||||
const targetBookId = statusBookIds[0]!;
|
||||
const existingState = await ctx.db.query.koboReadingStates.findFirst({
|
||||
where: and(eq(schema.koboReadingStates.userId, userId), eq(schema.koboReadingStates.bookId, targetBookId)),
|
||||
});
|
||||
expect(existingState).toBeUndefined();
|
||||
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
||||
|
||||
await setReadStatus(targetBookId, 'read');
|
||||
|
||||
expect(await snapshotState(targetBookId)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ deviceId: deviceA.id, synced: false, isNew: false }),
|
||||
expect.objectContaining({ deviceId: deviceB.id, synced: false, isNew: false }),
|
||||
]),
|
||||
);
|
||||
|
||||
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
||||
const delivered = changedReadingStates(await drainEntries(deviceA));
|
||||
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
||||
expect(state).toEqual(expect.objectContaining({ StatusInfo: expect.objectContaining({ Status: 'Finished' }) }));
|
||||
});
|
||||
|
||||
it('keeps the device bookmark when a partly read book is corrected to read', async () => {
|
||||
const targetBookId = statusBookIds[1]!;
|
||||
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
||||
const deviceModified = new Date(Date.now() - 60_000).toISOString();
|
||||
await putReadingState(deviceA, targetEntitlementId, {
|
||||
EntitlementId: targetEntitlementId,
|
||||
LastModified: deviceModified,
|
||||
PriorityTimestamp: deviceModified,
|
||||
CurrentBookmark: { LastModified: deviceModified, ProgressPercent: 42 },
|
||||
Statistics: { LastModified: deviceModified, SpentReadingMinutes: 30 },
|
||||
StatusInfo: { LastModified: deviceModified, Status: 'Reading', TimesStartedReading: 2 },
|
||||
});
|
||||
await drainEntries(deviceB);
|
||||
|
||||
await setReadStatus(targetBookId, 'read');
|
||||
|
||||
const delivered = changedReadingStates(await drainEntries(deviceB));
|
||||
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
||||
expect(state).toEqual(
|
||||
expect.objectContaining({
|
||||
StatusInfo: expect.objectContaining({ Status: 'Finished', TimesStartedReading: 2 }),
|
||||
CurrentBookmark: expect.objectContaining({ ProgressPercent: 42 }),
|
||||
Statistics: expect.objectContaining({ SpentReadingMinutes: 30 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resync when the manual status leaves the Kobo status unchanged', async () => {
|
||||
const targetBookId = statusBookIds[2]!;
|
||||
await setReadStatus(targetBookId, 'reading');
|
||||
await drainEntries(deviceA);
|
||||
await drainEntries(deviceB);
|
||||
|
||||
await setReadStatus(targetBookId, 'on_hold');
|
||||
|
||||
expect((await snapshotState(targetBookId)).every((row) => row.synced)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a book read after a skewed device clock started its attempt in the future', async () => {
|
||||
const targetBookId = statusBookIds[3]!;
|
||||
const targetEntitlementId = await entitlementIdFor(targetBookId);
|
||||
const futureModified = new Date(Date.now() + 120 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const futureDay = futureModified.slice(0, 10);
|
||||
await putReadingState(deviceA, targetEntitlementId, {
|
||||
EntitlementId: targetEntitlementId,
|
||||
LastModified: futureModified,
|
||||
PriorityTimestamp: futureModified,
|
||||
CurrentBookmark: { LastModified: futureModified, ProgressPercent: 35 },
|
||||
Statistics: { LastModified: futureModified },
|
||||
StatusInfo: { LastModified: futureModified, Status: 'Reading', TimesStartedReading: 1 },
|
||||
});
|
||||
const [attemptBefore] = await ctx.db
|
||||
.select({ startedOn: schema.readingAttempts.startedOn })
|
||||
.from(schema.readingAttempts)
|
||||
.where(and(eq(schema.readingAttempts.userId, userId), eq(schema.readingAttempts.bookId, targetBookId)));
|
||||
expect(attemptBefore?.startedOn).toBe(futureDay);
|
||||
|
||||
const status = await setReadStatus(targetBookId, 'read');
|
||||
|
||||
expect(status).toMatchObject({ status: 'read', finishedAt: futureDay });
|
||||
const delivered = changedReadingStates(await drainEntries(deviceB));
|
||||
const state = delivered.find((readingState) => readingState.EntitlementId === targetEntitlementId);
|
||||
expect(state).toEqual(expect.objectContaining({ StatusInfo: expect.objectContaining({ Status: 'Finished' }) }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user