fix(books): let manual reading dates drive the reading lifecycle

Editing Date Started or Date Finished sent a date-only patch that the attempt layer had nothing to
attach it to. On an unread book no attempt was created, so the request returned 200 with null dates
and the field cleared itself. On a book being read the date landed on the open attempt as an end
date with no outcome, violating reading_attempts_closed_has_outcome_chk and surfacing as a 500.

Treat a date-only patch as a lifecycle statement: a start date opens an attempt and projects
reading, a finish date closes one as completed and projects read, and endedOn is never written
without its outcome. Clearing a finish date keeps the completion, because an outcome with no
completion date is legal and is already what re-read seeding writes. Setting a finish date on an
abandoned book now completes it. Reject a status that contradicts its dates with a stable error
code the client can translate, and derive statusChanged from the resulting status so a date-driven
transition still reaches achievements and the Kobo projection.

Keep unread and want_to_read free of dates they cannot own. The attempt keeps its own, so a book
marked unread no longer projects a start and finish date once a historical attempt is logged, and
marking a finished book unread no longer strands its finish date on the projection.

Stop the Hardcover import sending dates a status cannot carry. A want-to-read row that still holds
a start date would otherwise fail its row and skip the status import entirely.

Closes #1073
This commit is contained in:
neon
2026-08-21 19:01:54 +00:00
parent cbd5f56778
commit 3a095efcbb
17 changed files with 726 additions and 100 deletions
@@ -348,7 +348,7 @@ describe('DetailsTab - present state', () => {
await finishedInput.setValue('2026-04-05')
await flushPromises()
expect(wrapper.text()).toContain('Date Finished must be on or after Date Started.')
expect(wrapper.text()).toContain('Date Finished must be on or after Date Started (Apr 10, 2026).')
const saveButton = wrapper.findAll('button').find((button) => button.text() === 'Save')
expect(saveButton?.exists()).toBe(true)
await saveButton!.trigger('click')
@@ -532,10 +532,19 @@ function validateReadingDates(values: { startedAt: string; finishedAt: string })
const { startedAt, finishedAt } = values
if (startedAt && startedAt > todayDateInput.value) return t('book.detail.details.dateStartedFutureError')
if (finishedAt && finishedAt > todayDateInput.value) return t('book.detail.details.dateFinishedFutureError')
if (startedAt && finishedAt && finishedAt < startedAt) return t('book.detail.details.dateFinishedBeforeStartedError')
if (startedAt && finishedAt && finishedAt < startedAt)
return t('book.detail.details.dateFinishedBeforeStartedErrorWithDate', { date: formatDisplayDate(startedAt) })
return null
}
function readingDateSaveError(error: unknown): string {
const errorCode = typeof error === 'object' && error !== null && 'errorCode' in error ? (error as { errorCode?: unknown }).errorCode : null
if (errorCode === 'READING_DATE_STARTED_IN_FUTURE') return t('book.detail.details.dateStartedFutureError')
if (errorCode === 'READING_DATE_FINISHED_IN_FUTURE') return t('book.detail.details.dateFinishedFutureError')
if (errorCode === 'READING_DATES_INVALID_ORDER') return t('book.detail.details.dateFinishedBeforeStartedError')
return t('book.detail.details.saveReadingDatesError')
}
const isEditingAnyReadingDate = computed(() => activeReadingDateField.value !== null)
const isEditingAnyDate = computed(() => editingAddedDate.value || isEditingAnyReadingDate.value)
@@ -624,8 +633,8 @@ async function saveReadingDateField(field: 'startedAt' | 'finishedAt') {
const updatedReadStatus = await updateStatus(props.book.id, patch)
applyReadStatusUpdate(updatedReadStatus)
activeReadingDateField.value = null
} catch {
readingDatesError.value = t('book.detail.details.saveReadingDatesError')
} catch (error) {
readingDatesError.value = readingDateSaveError(error)
} finally {
savingReadingDates.value = false
}
@@ -109,10 +109,18 @@ function validateDates(values: { startedAt: string; finishedAt: string }): strin
if (values.startedAt && values.startedAt > todayDateInput.value) return t('book.detail.readingLog.hero.dateErrors.startFuture')
if (values.finishedAt && values.finishedAt > todayDateInput.value) return t('book.detail.readingLog.hero.dateErrors.finishFuture')
if (values.startedAt && values.finishedAt && values.finishedAt < values.startedAt)
return t('book.detail.readingLog.hero.dateErrors.finishBeforeStart')
return t('book.detail.readingLog.hero.dateErrors.finishBeforeStartWithDate', { date: formatDisplayDate(values.startedAt) })
return null
}
function dateSaveError(error: unknown): string {
const errorCode = typeof error === 'object' && error !== null && 'errorCode' in error ? (error as { errorCode?: unknown }).errorCode : null
if (errorCode === 'READING_DATE_STARTED_IN_FUTURE') return t('book.detail.readingLog.hero.dateErrors.startFuture')
if (errorCode === 'READING_DATE_FINISHED_IN_FUTURE') return t('book.detail.readingLog.hero.dateErrors.finishFuture')
if (errorCode === 'READING_DATES_INVALID_ORDER') return t('book.detail.readingLog.hero.dateErrors.finishBeforeStart')
return t('book.detail.readingLog.hero.dateErrors.saveFailed')
}
function applyReadStatusUpdate(updated: UserBookStatus) {
localReadStatus.value = updated.status
const normalized = normalizeDates(updated)
@@ -163,8 +171,8 @@ async function saveDateField(field: 'startedAt' | 'finishedAt') {
const updated = await updateStatus(props.book.id, patch)
applyReadStatusUpdate(updated)
activeDateField.value = null
} catch {
datesError.value = t('book.detail.readingLog.hero.dateErrors.saveFailed')
} catch (error) {
datesError.value = dateSaveError(error)
} finally {
savingDates.value = false
}
@@ -363,7 +371,7 @@ function handleAddSession() {
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
Started
{{ t('book.detail.readingLog.hero.dateStarted') }}
<input
v-if="activeDateField === 'startedAt'"
v-model="draftDates.startedAt"
@@ -380,8 +388,8 @@ function handleAddSession() {
{{ formatDisplayDate(savedDates.startedAt) }}
</button>
</span>
<span v-if="savedDates.finishedAt || activeDateField === 'finishedAt' || localReadStatus === 'read' || localReadStatus === 'abandoned'">
Finished
<span>
{{ t('book.detail.readingLog.hero.dateFinished') }}
<input
v-if="activeDateField === 'finishedAt'"
v-model="draftDates.finishedAt"
@@ -395,7 +403,7 @@ function handleAddSession() {
@keydown.esc.prevent="handleFinishedCancel"
/>
<button v-else class="ml-1 font-medium text-foreground transition-colors hover:text-primary" @click="handleFinishedClick">
{{ savedDates.finishedAt ? formatDisplayDate(savedDates.finishedAt) : 'Set date' }}
{{ formatDisplayDate(savedDates.finishedAt) }}
</button>
</span>
</div>
@@ -189,6 +189,28 @@ describe('ReadingLogHero', () => {
expect(wrapper.emitted('saved')?.[0]).toEqual([updated])
})
it('allows a finish date to complete an unread book', async () => {
const updated = makeReadStatus({ status: 'read', startedAt: null, finishedAt: '2026-04-20' })
mocks.updateStatus.mockResolvedValue(updated)
const wrapper = mountHero(makeBook({ readStatus: makeReadStatus({ status: 'unread', startedAt: null, finishedAt: null }) }))
await flushPromises()
const finishButton = wrapper
.findAll('button')
.filter((button) => button.text() === 'Not set')
.at(-1)
expect(finishButton).toBeDefined()
await finishButton!.trigger('click')
await wrapper.find('input[type="date"]').setValue('2026-04-20')
await wrapper.find('input[type="date"]').trigger('blur')
await flushPromises()
expect(mocks.updateStatus).toHaveBeenCalledWith(10, { finishedAt: '2026-04-20' })
expect(wrapper.text()).toContain('Read')
expect(wrapper.text()).toContain('Apr 20, 2026')
expect(wrapper.emitted('saved')?.[0]).toEqual([updated])
})
it('validates lifecycle dates before saving', async () => {
const wrapper = mountHero()
await flushPromises()
@@ -216,7 +238,7 @@ describe('ReadingLogHero', () => {
await invalidWrapper.find('input[type="date"]').trigger('blur')
await flushPromises()
expect(invalidWrapper.text()).toContain('Finish date must be on or after the start date.')
expect(invalidWrapper.text()).toContain('Finish date must be on or after the start date (Apr 1, 2026).')
mocks.updateStatus.mockRejectedValue(new Error('save failed'))
const failedWrapper = mountHero()
@@ -231,6 +253,20 @@ describe('ReadingLogHero', () => {
expect(failedWrapper.text()).toContain('Failed to save reading dates.')
})
it('maps stable server date errors to localized copy', async () => {
mocks.updateStatus.mockRejectedValue(Object.assign(new Error('rejected'), { errorCode: 'READING_DATE_STARTED_IN_FUTURE' }))
const wrapper = mountHero()
await flushPromises()
const startButton = wrapper.findAll('button').find((button) => button.text() === 'Apr 1, 2026')
await startButton!.trigger('click')
await wrapper.find('input[type="date"]').setValue('2026-04-02')
await wrapper.find('input[type="date"]').trigger('blur')
await flushPromises()
expect(wrapper.text()).toContain('Start date cannot be in the future.')
})
it('closes lifecycle date editing when unchanged or canceled', async () => {
const wrapper = mountHero()
await flushPromises()
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/api', () => ({ api: mocks.api }))
import { useBookStatus } from '../useBookStatus'
import { ReadStatusUpdateError, useBookStatus } from '../useBookStatus'
describe('useBookStatus', () => {
beforeEach(() => {
@@ -73,4 +73,18 @@ describe('useBookStatus', () => {
const { setStatus } = useBookStatus()
await expect(setStatus(2, 'read')).rejects.toThrow('HTTP 400')
})
it('preserves stable server error codes on failed updates', async () => {
;(mocks.api as Mock).mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ errorCode: 'READING_DATES_INVALID_ORDER' }),
} as Response)
const { updateStatus } = useBookStatus()
const error = await updateStatus(2, { startedAt: '2026-02-02' }).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ReadStatusUpdateError)
expect(error).toMatchObject({ message: 'HTTP 400', errorCode: 'READING_DATES_INVALID_ORDER' })
})
})
@@ -46,6 +46,16 @@ export type ReadStatusPatch = {
finishedAt?: string | null
}
export class ReadStatusUpdateError extends Error {
constructor(
message: string,
readonly errorCode: string | null,
) {
super(message)
this.name = 'ReadStatusUpdateError'
}
}
export function useBookStatus() {
async function updateStatus(bookId: number, patch: ReadStatusPatch): Promise<UserBookStatus> {
const res = await api(`/api/v1/books/${bookId}/status`, {
@@ -53,7 +63,16 @@ export function useBookStatus() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) {
let errorCode: string | null = null
try {
const body = (await res.json()) as { errorCode?: unknown }
if (typeof body.errorCode === 'string') errorCode = body.errorCode
} catch {
errorCode = null
}
throw new ReadStatusUpdateError(`HTTP ${res.status}`, errorCode)
}
return (await res.json()) as UserBookStatus
}
+2
View File
@@ -3900,6 +3900,7 @@
"dateAddedFutureError": "Date Added cannot be in the future.",
"dateFinishedFutureError": "Date Finished cannot be in the future.",
"dateFinishedBeforeStartedError": "Date Finished must be on or after Date Started.",
"dateFinishedBeforeStartedErrorWithDate": "Date Finished must be on or after Date Started ({date}).",
"saveReadingDatesError": "Failed to save reading dates.",
"saveAddedDateError": "Failed to save date added.",
"koboPendingDelete": "Pending delete from device",
@@ -4208,6 +4209,7 @@
"startFuture": "Start date cannot be in the future.",
"finishFuture": "Finish date cannot be in the future.",
"finishBeforeStart": "Finish date must be on or after the start date.",
"finishBeforeStartWithDate": "Finish date must be on or after the start date ({date}).",
"saveFailed": "Failed to save reading dates."
},
"eta": {
+23 -4
View File
@@ -3214,12 +3214,17 @@ describe('BookService', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'));
await expect(service.setReadStatus(10, { startedAt: '2026-05-21' }, makeUser())).rejects.toThrow('startedAt cannot be in the future');
await expect(service.setReadStatus(10, { startedAt: '2026-05-21' }, makeUser())).rejects.toMatchObject({
response: {
message: 'startedAt cannot be in the future',
errorCode: 'READING_DATE_STARTED_IN_FUTURE',
},
});
expect(userBookStatusService.updateManual).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('rejects finishedAt that is earlier than startedAt across omitted field merges', async () => {
it('delegates date ordering against the resolved reading attempt', async () => {
const { service, userBookStatusService } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.findOne.mockResolvedValue({
@@ -3229,9 +3234,23 @@ describe('BookService', () => {
finishedAt: '2026-01-10T00:00:00.000Z',
updatedAt: '2026-01-10T00:00:00.000Z',
});
userBookStatusService.updateManual.mockResolvedValue({
status: 'rereading',
source: 'manual',
startedAt: '2026-01-15',
finishedAt: null,
updatedAt: '2026-01-15T00:00:00.000Z',
});
await expect(service.setReadStatus(10, { startedAt: '2026-01-15' }, makeUser())).rejects.toThrow('finishedAt must be on or after startedAt');
expect(userBookStatusService.updateManual).not.toHaveBeenCalled();
await service.setReadStatus(10, { status: 'rereading', startedAt: '2026-01-15' }, makeUser());
expect(userBookStatusService.findOne).not.toHaveBeenCalled();
expect(userBookStatusService.updateManual).toHaveBeenCalledWith(
1,
10,
{ status: 'rereading', startedAt: expect.any(Date) },
{ startedOn: '2026-01-15' },
);
});
it('falls back to UTC when user timezone is invalid', async () => {
+5 -9
View File
@@ -107,6 +107,7 @@ import { UpdatePersonalNoteDto } from './dto/update-personal-note.dto';
import type { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
import { READING_DATE_ERROR_CODES } from '../user-book-status/user-book-status.constants';
type SeriesCollapseQueryOptions = {
seriesSelectionFilter: GroupRule | undefined;
@@ -2190,14 +2191,6 @@ export class BookService {
patch.status = dto.status;
}
const existing = await this.userBookStatusService.findOne(user.id, bookId);
const effectiveStarted = hasStartedAt ? (startedKey ?? null) : this.normalizeStoredStatusDateKey(existing?.startedAt ?? null, timeZone);
const effectiveFinished = hasFinishedAt ? (finishedKey ?? null) : this.normalizeStoredStatusDateKey(existing?.finishedAt ?? null, timeZone);
if (effectiveStarted && effectiveFinished && effectiveFinished < effectiveStarted) {
throw new BadRequestException('finishedAt must be on or after startedAt');
}
const dateKeys = {
...(startedKey ? { startedOn: startedKey } : {}),
...(finishedKey ? { endedOn: finishedKey } : {}),
@@ -2230,7 +2223,10 @@ export class BookService {
const dateKey = this.normalizeInputToDateKey(trimmed, field, timeZone);
const todayKey = toDateKeyInTimeZone(new Date(), timeZone);
if (dateKey > todayKey) {
throw new BadRequestException(`${field} cannot be in the future`);
throw new BadRequestException({
message: `${field} cannot be in the future`,
errorCode: field === 'startedAt' ? READING_DATE_ERROR_CODES.startedInFuture : READING_DATE_ERROR_CODES.finishedInFuture,
});
}
return {
dateKey,
@@ -512,6 +512,29 @@ describe('HardcoverImportService', () => {
expect(mockRepo.upsertImportProgress).not.toHaveBeenCalled();
});
it('imports a want-to-read row that still carries a start date', async () => {
mockClient.query.mockResolvedValue({
me: [
{
user_books: [
hardcoverBook({
status_id: 1,
user_book_status: { status: 'Want to Read' },
last_read_date: null,
user_book_reads: [{ id: 504, started_at: '2024-01-02', finished_at: null, progress: 0, progress_pages: null, progress_seconds: null }],
}),
],
},
],
});
const result = await makeService().applyImport(user as never);
expect(result.applied).toBe(1);
expect(result.failed).toBe(0);
expect(mockUserBookStatusService.updateManual).toHaveBeenCalledWith(7, 42, { status: 'want_to_read' });
});
it('imports Hardcover progress when requested and local progress is blank', async () => {
const result = await makeService().applyImport(user as never, { hardcoverUserBookIds: [1000], importProgress: true });
@@ -186,10 +186,18 @@ export class HardcoverImportService {
});
}
}
// want_to_read cannot carry lifecycle dates, and an unfinished status cannot carry a finish
// date. The imported reads above already hold the real dates either way.
const clearsLifecycle = row.importedStatus === 'want_to_read';
const isActiveStatus = row.importedStatus === 'reading' || row.importedStatus === 'on_hold';
await this.userBookStatusService.updateManual(user.id, row.localBookId, {
status: row.importedStatus,
startedAt: toDate(row.importedStartedAt),
finishedAt: toDate(row.importedFinishedAt),
...(clearsLifecycle
? {}
: {
startedAt: toDate(row.importedStartedAt),
finishedAt: isActiveStatus ? null : toDate(row.importedFinishedAt),
}),
});
const progressImported = await this.applyProgressIfRequested(user.id, row, importProgress, startedAt);
@@ -22,9 +22,36 @@ type Row = {
function makeFakeRepo() {
const rows: Row[] = [];
let nextId = 1;
const projections: Array<{ status: ReadStatus }> = [];
const transactionContext = {};
const projections: Array<{
status: ReadStatus;
source: 'manual' | 'auto';
startedAt: Date | null;
finishedAt: Date | null;
}> = [];
function assertValidAttempt(candidate: Pick<Row, 'userId' | 'bookId' | 'startedOn' | 'endedOn' | 'outcome' | 'deletedAt'>, excludedId?: number) {
if (candidate.endedOn !== null && candidate.outcome === null) {
throw new Error('reading_attempts_closed_has_outcome_chk');
}
if (candidate.startedOn !== null && candidate.endedOn !== null && candidate.endedOn < candidate.startedOn) {
throw new Error('reading_attempts_end_after_start_chk');
}
if (candidate.outcome === null && candidate.deletedAt === null) {
const duplicateActive = rows.some(
(row) =>
row.id !== excludedId &&
row.userId === candidate.userId &&
row.bookId === candidate.bookId &&
row.outcome === null &&
row.deletedAt === null,
);
if (duplicateActive) throw new Error('reading_attempts_one_active_uidx');
}
}
const repo = {
transaction: vi.fn((callback: (tx: object) => Promise<unknown>) => callback({})),
transaction: vi.fn((callback: (tx: object) => Promise<unknown>) => callback(transactionContext)),
findActive: vi.fn((_tx: object, userId: number, bookId: number) =>
Promise.resolve(rows.find((row) => row.userId === userId && row.bookId === bookId && row.outcome === null && row.deletedAt === null)),
),
@@ -52,6 +79,7 @@ function makeFakeRepo() {
createdAt: now,
updatedAt: now,
};
assertValidAttempt(row);
rows.push(row);
return Promise.resolve(row);
},
@@ -74,10 +102,11 @@ function makeFakeRepo() {
update: vi.fn((_tx: object, userId: number, bookId: number, id: number, patch: Partial<Row>) => {
const row = rows.find((item) => item.id === id && item.userId === userId && item.bookId === bookId && item.deletedAt === null);
if (!row) return Promise.resolve(null);
assertValidAttempt({ ...row, ...patch }, row.id);
Object.assign(row, patch, { updatedAt: new Date('2026-07-12T12:00:00.000Z') });
return Promise.resolve(row);
}),
project: vi.fn((_tx: object, _userId: number, _bookId: number, projection: { status: ReadStatus }) => {
project: vi.fn((_tx: object, _userId: number, _bookId: number, projection: (typeof projections)[number]) => {
projections.push(projection);
return Promise.resolve();
}),
@@ -96,7 +125,7 @@ function makeFakeRepo() {
}),
list: vi.fn(() => Promise.resolve({ items: [], total: 0 })),
};
return { repo, rows, projections };
return { repo, rows, projections, transactionContext };
}
describe('ReadingAttemptService', () => {
@@ -240,6 +269,198 @@ describe('ReadingAttemptService', () => {
expect(second.status).toBe('read');
});
describe('manual lifecycle dates without an explicit status', () => {
it.each(['unread', 'want_to_read'] as const)('opens a reading attempt when a start date is set from %s', async (status) => {
const result = await service.applyManualStatus(1, 10, status, '2026-01-10', undefined, '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: null, outcome: null, origin: 'manual' });
expect(result).toMatchObject({ status: 'reading', startedAt: '2026-01-10', finishedAt: null });
expect(fake.projections.at(-1)).toMatchObject({ status: 'reading', startedAt: new Date('2026-01-10T00:00:00.000Z'), finishedAt: null });
});
it('creates a completed attempt when both dates are set on an unread book', async () => {
const result = await service.applyManualStatus(1, 10, 'unread', '2026-01-10', '2026-02-01', '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed', origin: 'manual' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-10', finishedAt: '2026-02-01' });
});
it('creates a completed attempt with an unknown start when only a finish date is set', async () => {
const result = await service.applyManualStatus(1, 10, 'unread', undefined, '2026-02-01', '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: null, endedOn: '2026-02-01', outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: null, finishedAt: '2026-02-01' });
});
it.each(['reading', 'rereading', 'on_hold'] as const)('completes an active %s attempt when a finish date is set', async (status) => {
if (status === 'rereading') {
await fake.repo.create(
{},
{
userId: 1,
bookId: 10,
startedOn: '2025-01-01',
endedOn: '2025-01-10',
outcome: 'completed',
origin: 'manual',
},
);
}
await fake.repo.createActive({}, { userId: 1, bookId: 10, startedOn: '2026-01-10', origin: 'manual' });
const result = await service.applyManualStatus(1, 10, status, undefined, '2026-02-01', '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows.at(-1)).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-10', finishedAt: '2026-02-01' });
expect(fake.projections.at(-1)?.status).toBe('read');
});
it('clears a completed attempt finish date without reopening it', async () => {
await fake.repo.create(
{},
{
userId: 1,
bookId: 10,
startedOn: '2026-01-10',
endedOn: '2026-02-01',
outcome: 'completed',
origin: 'manual',
},
);
const result = await service.applyManualStatus(1, 10, 'read', undefined, null, '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows[0]).toMatchObject({ endedOn: null, outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-10', finishedAt: null });
expect(fake.rows.filter((row) => row.outcome === null)).toHaveLength(0);
});
it('edits the start date of the latest completed attempt without reopening it', async () => {
await fake.repo.create(
{},
{
userId: 1,
bookId: 10,
startedOn: '2026-01-10',
endedOn: '2026-02-01',
outcome: 'completed',
origin: 'manual',
},
);
const result = await service.applyManualStatus(1, 10, 'read', '2026-01-05', undefined, '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-05', endedOn: '2026-02-01', outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-05', finishedAt: '2026-02-01' });
});
it('treats clearing absent dates without an attempt as a no-op', async () => {
const result = await service.applyManualStatus(1, 10, 'unread', null, null, '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(0);
expect(fake.repo.create).not.toHaveBeenCalled();
expect(fake.repo.createActive).not.toHaveBeenCalled();
expect(fake.repo.update).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 'unread', startedAt: null, finishedAt: null });
});
it('validates the resolved attempt before changing an existing date', async () => {
await fake.repo.create(
{},
{
userId: 1,
bookId: 10,
startedOn: '2026-01-10',
endedOn: '2026-02-01',
outcome: 'completed',
origin: 'manual',
},
);
await expect(
service.applyManualStatus(1, 10, 'read', '2026-02-02', undefined, '2026-07-12', { statusWasExplicit: false }),
).rejects.toMatchObject({ response: { errorCode: 'READING_DATES_INVALID_ORDER' } });
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
expect(fake.repo.update).not.toHaveBeenCalled();
});
it('rejects an explicit active status combined with a finish date before writing', async () => {
await expect(service.applyManualStatus(1, 10, 'reading', '2026-01-10', '2026-02-01', '2026-07-12')).rejects.toMatchObject({
response: { errorCode: 'READING_DATES_STATUS_CONFLICT' },
});
expect(fake.rows).toHaveLength(0);
});
it('creates one completed attempt for an explicit read status with both dates', async () => {
const result = await service.applyManualStatus(1, 10, 'read', '2026-01-10', '2026-02-01', '2026-07-12');
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-10', finishedAt: '2026-02-01' });
});
it('completes an abandoned attempt when a finish date is set', async () => {
await fake.repo.create({}, { userId: 1, bookId: 10, startedOn: '2026-01-10', endedOn: '2026-01-20', outcome: 'abandoned', origin: 'manual' });
const result = await service.applyManualStatus(1, 10, 'abandoned', undefined, '2026-02-01', '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.rows).toHaveLength(1);
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
expect(result).toMatchObject({ status: 'read', startedAt: '2026-01-10', finishedAt: '2026-02-01' });
});
it('mutates and projects in one repository transaction', async () => {
await service.applyManualStatus(1, 10, 'unread', '2026-01-10', '2026-02-01', '2026-07-12', {
statusWasExplicit: false,
});
expect(fake.repo.transaction).toHaveBeenCalledOnce();
expect(fake.repo.create).toHaveBeenCalledWith(fake.transactionContext, expect.any(Object));
expect(fake.repo.project).toHaveBeenCalledWith(fake.transactionContext, 1, 10, expect.objectContaining({ status: 'read' }));
});
});
describe('lifecycle-clearing projections', () => {
it.each(['unread', 'want_to_read'] as const)('keeps %s free of lifecycle dates it cannot own', async (status) => {
await service.applyManualStatus(1, 10, 'read', '2026-01-10', '2026-02-01', '2026-07-12');
const result = await service.applyManualStatus(1, 10, status, undefined, undefined, '2026-07-12');
expect(result).toMatchObject({ status, startedAt: null, finishedAt: null });
expect(fake.projections.at(-1)).toMatchObject({ status, startedAt: null, finishedAt: null });
expect(fake.rows[0]).toMatchObject({ startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
});
it('does not project a historical attempt onto a book the reader marked unread', async () => {
fake.repo.findStatus.mockResolvedValue({ status: 'unread' });
await service.createHistorical(1, 10, { startedOn: '2026-01-10', endedOn: '2026-02-01', outcome: 'completed' });
expect(fake.projections.at(-1)).toMatchObject({ status: 'unread', startedAt: null, finishedAt: null });
});
});
it('edits the latest completed dates without creating another completion', async () => {
await service.applyManualStatus(1, 10, 'read', '2026-01-01', '2026-01-10', '2026-07-12');
const result = await service.applyManualStatus(1, 10, 'read', undefined, '2026-01-12', '2026-07-12');
@@ -346,12 +567,13 @@ describe('ReadingAttemptService', () => {
expect(active.endedOn).toBe('2026-07-12');
});
it('leaves an explicitly supplied end date untouched for upstream validation', async () => {
it('rejects an explicitly supplied end date before the active start date', 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');
await expect(service.applyManualStatus(1, 10, 'read', undefined, '2026-07-12', '2026-07-12')).rejects.toBeInstanceOf(BadRequestException);
expect(active.endedOn).toBe('2026-07-12');
expect(active).toMatchObject({ startedOn: '2026-11-01', endedOn: null, outcome: null });
expect(fake.repo.update).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import type {
ReadingAttempt,
ReadingAttemptListResponse,
@@ -10,6 +10,7 @@ import type {
} from '@bookorbit/types';
import { ReadingAttemptRepository } from './reading-attempt.repository';
import { READING_DATE_ERROR_CODES } from './user-book-status.constants';
function dateToUtcDate(value: string | null): Date | null {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
@@ -31,6 +32,22 @@ function notBeforeStart(endedOn: string, startedOn: string | null): string {
return startedOn && endedOn < startedOn ? startedOn : endedOn;
}
/**
* unread and want_to_read never carry lifecycle dates. The attempt keeps its own dates so the
* reading log still shows the history; the projection just stops claiming them for the book.
*/
function projectionDatesFor(
status: ReadStatus,
attempt: { startedOn: string | null; endedOn: string | null; outcome: ReadingAttemptOutcome | null } | null | undefined,
): { startedOn: string | null; endedOn: string | null } {
if (!attempt || status === 'unread' || status === 'want_to_read') return { startedOn: null, endedOn: null };
return { startedOn: attempt.startedOn, endedOn: attempt.outcome === 'completed' ? attempt.endedOn : null };
}
type ManualStatusOptions = {
statusWasExplicit?: boolean;
};
@Injectable()
export class ReadingAttemptService {
constructor(private readonly repo: ReadingAttemptRepository) {}
@@ -42,85 +59,187 @@ export class ReadingAttemptService {
startedOn: string | null | undefined,
endedOn: string | null | undefined,
today: string,
options: ManualStatusOptions = {},
): Promise<UserBookStatus> {
return this.repo.transaction(async (tx) => {
let active: Awaited<ReturnType<ReadingAttemptRepository['findActive']>> | null = await this.repo.findActive(tx, userId, bookId);
let latest = active ?? (await this.repo.findLatest(tx, userId, bookId));
const hasStartedOn = startedOn !== undefined;
const hasEndedOn = endedOn !== undefined;
const hasDatePatch = hasStartedOn || hasEndedOn;
const statusWasExplicit = options.statusWasExplicit !== false;
let projectedStatus = status;
if (status === 'rereading' && !(await this.repo.hasCompleted(tx, userId, bookId))) {
await this.repo.create(tx, {
userId,
bookId,
startedOn: null,
endedOn: null,
outcome: 'completed',
origin: 'migration',
});
}
const requireUpdatedAttempt = async (
attemptId: number,
patch: Parameters<ReadingAttemptRepository['update']>[4],
): Promise<NonNullable<Awaited<ReturnType<ReadingAttemptRepository['update']>>>> => {
const updated = await this.repo.update(tx, userId, bookId, attemptId, patch);
if (!updated) throw new InternalServerErrorException('Reading attempt disappeared during update');
return updated;
};
if (status === 'reading' || status === 'rereading' || status === 'on_hold') {
if (!active) {
active = await this.repo.createActive(tx, {
userId,
bookId,
startedOn: startedOn === undefined ? today : startedOn,
origin: 'manual',
});
} else if (startedOn !== undefined) {
active = await this.repo.update(tx, userId, bookId, active.id, { startedOn });
}
latest = active;
} else {
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 ? notBeforeStart(today, effectiveStartedOn) : endedOn,
outcome,
});
active = null;
} else if (latest?.outcome !== outcome) {
if (!statusWasExplicit && hasDatePatch) {
const target = active ?? latest;
if (!target) {
if (endedOn !== undefined && endedOn !== null) {
const resolvedStartedOn = startedOn ?? null;
this.validateDates(resolvedStartedOn, endedOn);
latest = await this.repo.create(tx, {
userId,
bookId,
startedOn: startedOn ?? null,
endedOn: endedOn === undefined ? notBeforeStart(today, startedOn ?? null) : endedOn,
outcome,
startedOn: resolvedStartedOn,
endedOn,
outcome: 'completed',
origin: 'manual',
});
projectedStatus = 'read';
} else if (startedOn !== undefined && startedOn !== null) {
active = await this.repo.createActive(tx, {
userId,
bookId,
startedOn,
origin: 'manual',
});
latest = active;
}
} else if (active) {
latest = await this.repo.update(tx, userId, bookId, active.id, {
endedOn: endedOn === undefined ? notBeforeStart(today, active.startedOn) : endedOn,
outcome: 'abandoned',
} else {
const resolvedStartedOn = hasStartedOn ? (startedOn ?? null) : target.startedOn;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : target.endedOn;
const completesAttempt = endedOn !== undefined && endedOn !== null;
this.validateDates(resolvedStartedOn, resolvedEndedOn);
const updated = await requireUpdatedAttempt(target.id, {
...(hasStartedOn ? { startedOn: startedOn ?? null } : {}),
...(hasEndedOn ? { endedOn: endedOn ?? null } : {}),
...(completesAttempt ? { outcome: 'completed' as const } : {}),
});
active = null;
if (active) {
if (completesAttempt) {
active = null;
latest = updated;
projectedStatus = 'read';
} else {
active = updated;
latest = updated;
}
} else {
latest = updated;
if (completesAttempt) projectedStatus = 'read';
}
}
if (active) {
const hasCompleted = await this.repo.hasCompleted(tx, userId, bookId);
projectedStatus = status === 'on_hold' ? 'on_hold' : hasCompleted ? 'rereading' : 'reading';
}
} else {
const isActiveStatus = status === 'reading' || status === 'rereading' || status === 'on_hold';
const clearsLifecycle = status === 'unread' || status === 'want_to_read';
if ((isActiveStatus && endedOn !== undefined && endedOn !== null) || (clearsLifecycle && (startedOn != null || endedOn != null))) {
throw new BadRequestException({
message: 'Reading dates conflict with the requested status',
errorCode: READING_DATE_ERROR_CODES.statusConflict,
});
}
if (status === 'rereading' && !(await this.repo.hasCompleted(tx, userId, bookId))) {
await this.repo.create(tx, {
userId,
bookId,
startedOn: null,
endedOn: null,
outcome: 'completed',
origin: 'migration',
});
}
if (isActiveStatus) {
if (!active) {
active = await this.repo.createActive(tx, {
userId,
bookId,
startedOn: startedOn === undefined ? today : startedOn,
origin: 'manual',
});
} else if (hasStartedOn) {
this.validateDates(startedOn ?? null, null);
active = await requireUpdatedAttempt(active.id, { startedOn: startedOn ?? null });
}
latest = active;
} else {
const outcome = outcomeForStatus(status);
if (outcome) {
if (active) {
const resolvedStartedOn = hasStartedOn ? (startedOn ?? null) : active.startedOn;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : notBeforeStart(today, resolvedStartedOn);
this.validateDates(resolvedStartedOn, resolvedEndedOn);
latest = await requireUpdatedAttempt(active.id, {
...(hasStartedOn ? { startedOn: startedOn ?? null } : {}),
endedOn: resolvedEndedOn,
outcome,
});
active = null;
} else if (latest?.outcome !== outcome) {
const resolvedStartedOn = startedOn ?? null;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : notBeforeStart(today, resolvedStartedOn);
this.validateDates(resolvedStartedOn, resolvedEndedOn);
latest = await this.repo.create(tx, {
userId,
bookId,
startedOn: resolvedStartedOn,
endedOn: resolvedEndedOn,
outcome,
origin: 'manual',
});
} else if (latest && hasDatePatch) {
const resolvedStartedOn = hasStartedOn ? (startedOn ?? null) : latest.startedOn;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : latest.endedOn;
this.validateDates(resolvedStartedOn, resolvedEndedOn);
latest = await requireUpdatedAttempt(latest.id, {
...(hasStartedOn ? { startedOn: startedOn ?? null } : {}),
...(hasEndedOn ? { endedOn: endedOn ?? null } : {}),
});
}
} else if (active) {
const resolvedStartedOn = hasStartedOn ? (startedOn ?? null) : active.startedOn;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : notBeforeStart(today, resolvedStartedOn);
this.validateDates(resolvedStartedOn, resolvedEndedOn);
latest = await requireUpdatedAttempt(active.id, {
...(hasStartedOn ? { startedOn: startedOn ?? null } : {}),
endedOn: resolvedEndedOn,
outcome: 'abandoned',
});
active = null;
} else if (latest && hasDatePatch) {
const resolvedStartedOn = hasStartedOn ? (startedOn ?? null) : latest.startedOn;
const resolvedEndedOn = hasEndedOn ? (endedOn ?? null) : latest.endedOn;
this.validateDates(resolvedStartedOn, resolvedEndedOn);
latest = await requireUpdatedAttempt(latest.id, {
...(hasStartedOn ? { startedOn: startedOn ?? null } : {}),
...(hasEndedOn ? { endedOn: endedOn ?? null } : {}),
});
}
}
if (active) {
const hasCompleted = await this.repo.hasCompleted(tx, userId, bookId);
projectedStatus = status === 'on_hold' ? 'on_hold' : hasCompleted ? 'rereading' : 'reading';
}
}
if (startedOn !== undefined || endedOn !== undefined) {
const target = active ?? latest;
if (target) {
latest = await this.repo.update(tx, userId, bookId, target.id, {
...(startedOn !== undefined ? { startedOn } : {}),
...(endedOn !== undefined ? { endedOn } : {}),
});
}
}
const completedBefore = await this.repo.hasCompleted(tx, userId, bookId);
const projectedStatus = active ? (status === 'on_hold' ? 'on_hold' : completedBefore ? 'rereading' : 'reading') : status;
const projectionTarget = active ?? latest;
const startedAt = dateToUtcDate(projectionTarget?.startedOn ?? null);
const finishedAt = projectionTarget?.outcome === 'completed' ? dateToUtcDate(projectionTarget.endedOn) : null;
await this.repo.project(tx, userId, bookId, { status: projectedStatus, source: 'manual', startedAt, finishedAt });
const projected = projectionDatesFor(projectedStatus, projectionTarget);
await this.repo.project(tx, userId, bookId, {
status: projectedStatus,
source: 'manual',
startedAt: dateToUtcDate(projected.startedOn),
finishedAt: dateToUtcDate(projected.endedOn),
});
return {
status: projectedStatus,
source: 'manual',
startedAt: projectionTarget?.startedOn ?? null,
finishedAt: projectionTarget?.outcome === 'completed' ? (projectionTarget.endedOn ?? null) : null,
startedAt: projected.startedOn,
finishedAt: projected.endedOn,
updatedAt: new Date().toISOString(),
};
});
@@ -307,17 +426,23 @@ export class ReadingAttemptService {
else if (latest?.outcome === 'completed') status = 'read';
else if (latest?.outcome === 'skimmed') status = 'skimmed';
else if (latest?.outcome === 'abandoned') status = 'abandoned';
const projected = projectionDatesFor(status, target);
await this.repo.project(tx, userId, bookId, {
status,
source: 'manual',
startedAt: dateToUtcDate(target?.startedOn ?? null),
finishedAt: target?.outcome === 'completed' ? dateToUtcDate(target.endedOn) : null,
startedAt: dateToUtcDate(projected.startedOn),
finishedAt: dateToUtcDate(projected.endedOn),
});
});
}
private validateDates(startedOn: string | null, endedOn: string | null): void {
if (startedOn && endedOn && endedOn < startedOn) throw new BadRequestException('endedOn must be on or after startedOn');
if (startedOn && endedOn && endedOn < startedOn) {
throw new BadRequestException({
message: 'endedOn must be on or after startedOn',
errorCode: READING_DATE_ERROR_CODES.invalidOrder,
});
}
}
private toDto(row: {
@@ -4,6 +4,13 @@ export const READ_STATUSES: readonly ReadStatus[] = ['unread', 'want_to_read', '
export const READ_STATUS_SOURCES: readonly ReadStatusSource[] = ['auto', 'manual'];
export const READING_DATE_ERROR_CODES = {
invalidOrder: 'READING_DATES_INVALID_ORDER',
startedInFuture: 'READING_DATE_STARTED_IN_FUTURE',
finishedInFuture: 'READING_DATE_FINISHED_IN_FUTURE',
statusConflict: 'READING_DATES_STATUS_CONFLICT',
} as const;
const READ_STATUS_SET = new Set<ReadStatus>(READ_STATUSES);
const READ_STATUS_SOURCE_SET = new Set<ReadStatusSource>(READ_STATUS_SOURCES);
@@ -3,6 +3,7 @@ import type { ReadStatus, ReadStatusSource } from '@bookorbit/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { UserBookStatusRow } from '../../db/schema';
import { ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from '../achievement/achievement-events.service';
import { UserBookStatusRepository } from './user-book-status.repository';
import type { SessionBoundaries } from './user-book-status.repository';
import { UserBookStatusService } from './user-book-status.service';
@@ -687,6 +688,62 @@ describe('kobo status projection', () => {
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10], 'rereading');
});
it('emits achievements and projects to Kobo when a date-only patch changes status', async () => {
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'unread' }));
mockAttempts.applyManualStatus.mockResolvedValue({
status: 'read',
source: 'manual',
startedAt: null,
finishedAt: '2026-05-01',
updatedAt: '2026-05-01T00:00:00.000Z',
});
await attemptService.updateManual(1, 10, { finishedAt: new Date('2026-05-01T00:00:00.000Z') }, { endedOn: '2026-05-01' });
expect(mockAttempts.applyManualStatus).toHaveBeenCalledWith(1, 10, 'unread', undefined, '2026-05-01', expect.any(String), {
statusWasExplicit: false,
});
expect(mockAchievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, {
userId: 1,
bookId: 10,
newStatus: 'read',
previousStatus: 'unread',
});
expect(mockKoboProjection.project).toHaveBeenCalledWith(1, [10], 'read');
});
it('does not emit or project when a date-only patch preserves status', async () => {
mockRepo.findOne.mockResolvedValue(makeRow({ bookId: 10, status: 'read' }));
mockAttempts.applyManualStatus.mockResolvedValue({
status: 'read',
source: 'manual',
startedAt: '2026-04-01',
finishedAt: null,
updatedAt: '2026-05-01T00:00:00.000Z',
});
await attemptService.updateManual(1, 10, { finishedAt: null });
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
expect(mockKoboProjection.project).not.toHaveBeenCalled();
});
it('does not emit or project when clearing an absent date without an existing status row', async () => {
mockRepo.findOne.mockResolvedValue(null);
mockAttempts.applyManualStatus.mockResolvedValue({
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-05-01T00:00:00.000Z',
});
await attemptService.updateManual(1, 10, { startedAt: null });
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
expect(mockKoboProjection.project).not.toHaveBeenCalled();
});
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' })),
@@ -76,9 +76,10 @@ export class UserBookStatusService {
hasStartedAt ? (dateKeys?.startedOn ?? patch.startedAt?.toISOString().slice(0, 10) ?? null) : undefined,
hasFinishedAt ? (dateKeys?.endedOn ?? patch.finishedAt?.toISOString().slice(0, 10) ?? null) : undefined,
today,
{ statusWasExplicit: hasStatus },
);
const previousStatus = existing?.status ?? null;
const statusChanged = hasStatus && updated.status !== previousStatus;
const statusChanged = updated.status !== (previousStatus ?? 'unread');
if (statusChanged) {
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, {
userId,
+80
View File
@@ -627,4 +627,84 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
expect(sync?.attemptsUpdatedAt ?? null).toBeNull();
});
it('26. lets lifecycle dates create and complete attempts through the status API', async () => {
const startedOnly = await createBook();
const startResponse = await patchStatus(startedOnly.bookId, { startedAt: '2026-01-10' });
expect(startResponse.statusCode).toBe(200);
expect(startResponse.json()).toMatchObject({ status: 'reading', startedAt: '2026-01-10', finishedAt: null });
expect((await listAttempts(startedOnly.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-01-10', endedOn: null, outcome: null, origin: 'manual' }),
]);
const bothDates = await createBook();
const bothResponse = await patchStatus(bothDates.bookId, { startedAt: '2026-02-01', finishedAt: '2026-02-10' });
expect(bothResponse.statusCode).toBe(200);
expect(bothResponse.json()).toMatchObject({ status: 'read', startedAt: '2026-02-01', finishedAt: '2026-02-10' });
expect((await listAttempts(bothDates.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-02-01', endedOn: '2026-02-10', outcome: 'completed', origin: 'manual' }),
]);
});
it('27. closes active lifecycle states with a date-only finish patch', async () => {
for (const status of ['reading', 'rereading', 'on_hold'] as const) {
const book = await createBook();
if (status === 'rereading') {
await patchStatus(book.bookId, { status: 'read', startedAt: '2025-01-01', finishedAt: '2025-01-10' });
}
await patchStatus(book.bookId, { status, startedAt: '2026-03-01' });
const response = await patchStatus(book.bookId, { finishedAt: '2026-03-10' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ status: 'read', startedAt: '2026-03-01', finishedAt: '2026-03-10' });
const history = await listAttempts(book.bookId);
expect(history.items[0]).toMatchObject({ startedOn: '2026-03-01', endedOn: '2026-03-10', outcome: 'completed' });
expect(history.items.filter((attempt) => attempt.outcome === null)).toHaveLength(0);
}
});
it('28. preserves completion when its finish date is cleared', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'read', startedAt: '2026-04-01', finishedAt: '2026-04-10' });
const response = await patchStatus(book.bookId, { finishedAt: null });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ status: 'read', startedAt: '2026-04-01', finishedAt: null });
expect((await listAttempts(book.bookId)).items).toEqual([
expect.objectContaining({ startedOn: '2026-04-01', endedOn: null, outcome: 'completed' }),
]);
});
it('30. keeps a lifecycle-clearing status free of dates it cannot own', async () => {
const book = await createBook();
await patchStatus(book.bookId, { status: 'unread' });
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${book.bookId}/reading-attempts`,
headers: auth(ctx.adminToken),
payload: { startedOn: '2026-06-01', endedOn: '2026-06-10', outcome: 'completed' },
});
expect(created.statusCode).toBe(201);
const [projected] = await ctx.db
.select({ status: schema.userBookStatus.status, startedAt: schema.userBookStatus.startedAt, finishedAt: schema.userBookStatus.finishedAt })
.from(schema.userBookStatus)
.where(and(eq(schema.userBookStatus.userId, adminUserId), eq(schema.userBookStatus.bookId, book.bookId)))
.limit(1);
expect(projected).toMatchObject({ status: 'unread', startedAt: null, finishedAt: null });
expect((await listAttempts(book.bookId)).items[0]).toMatchObject({ startedOn: '2026-06-01', endedOn: '2026-06-10', outcome: 'completed' });
});
it('29. rejects conflicting lifecycle dates without reaching a database constraint', async () => {
const book = await createBook();
const response = await patchStatus(book.bookId, { status: 'reading', startedAt: '2026-05-01', finishedAt: '2026-05-10' });
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ errorCode: 'READING_DATES_STATUS_CONFLICT' });
expect((await listAttempts(book.bookId)).total).toBe(0);
});
});