fix(stats): harden KOReader reset hold recovery

Preserve reset-hold notices when recovery requests fail and prevent
stale responses from replacing another book's progress. Refresh holds
after permission changes and rereads, and cover recovery and session
deletion with regression tests.

Ref #1355
This commit is contained in:
neon
2026-10-13 01:43:49 +00:00
parent 613c7d2ef2
commit a9f8cea616
6 changed files with 568 additions and 23 deletions
@@ -99,6 +99,7 @@ const {
bookProgress: koreaderBookProgress,
fetchBookProgress: fetchKoreaderBookProgress,
releaseResetHold: releaseKoreaderResetHold,
clearBookProgress,
} = useKoreaderBookProgress()
const resetHeldDevices = computed(() => {
@@ -112,12 +113,23 @@ function loadResetHolds() {
void fetchKoreaderBookProgress(props.book.id)
}
watch(bookIdRef, loadResetHolds, { immediate: true })
let holdContext = 0
watch(
[bookIdRef, canViewKoreader],
() => {
holdContext += 1
clearBookProgress()
loadResetHolds()
},
{ immediate: true },
)
// Accepting the device position moves the book's progress and status, and can open an attempt.
async function releaseResetHold(deviceId: string): Promise<boolean> {
if (!canViewKoreader.value) return false
const context = holdContext
const released = await releaseKoreaderResetHold(props.book.id, deviceId)
if (released) {
if (released && context === holdContext) {
void vitalsRef.value?.reloadProgress()
void reload({ silent: true })
void reloadAttempts({ silent: true })
@@ -225,6 +237,7 @@ async function handleStartReread(resetProgress: boolean) {
const readStatus = await startReread(resetProgress)
if (!readStatus) return
await reload()
loadResetHolds()
emit('saved', { ...props.book, readStatus })
}
@@ -305,7 +318,7 @@ const quickFilters = computed<{ label: string; value: QuickFilter }[]>(() => [
@add-session="handleOpenAddSession"
>
<template v-if="resetHeldDevices.length > 0" #notice>
<KoreaderResetHoldNotice :held="resetHeldDevices" :release="releaseResetHold" />
<KoreaderResetHoldNotice :key="book.id" :held="resetHeldDevices" :release="releaseResetHold" />
</template>
</ReadingLogVitals>
@@ -1,9 +1,16 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { createPinia } from 'pinia'
import { RouterLinkStub } from '@vue/test-utils'
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
import type { BookDetail, BookReadingSession, BookReadingSessionStats, KoreaderBookSyncInfo, ReadingAttempt } from '@bookorbit/types'
import {
Permission,
type BookDetail,
type BookReadingSession,
type BookReadingSessionStats,
type KoreaderBookSyncInfo,
type ReadingAttempt,
} from '@bookorbit/types'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
@@ -29,9 +36,43 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuTrigger: { template: '<div><slot /></div>' },
}))
import { i18n } from '@/i18n'
import ReadingLogAttempts from '../../reading-log/ReadingLogAttempts.vue'
import ReadingLogTab from '../ReadingLogTab.vue'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
function makeHeldDevice(overrides: Partial<KoreaderBookSyncInfo['heldByReset'][number]> = {}) {
return { device: 'Kindle Paperwhite', deviceId: 'kindle-device', percentage: 42, updatedAt: '2026-09-10T12:00:00.000Z', ...overrides }
}
function makeHeldProgress(overrides: Partial<KoreaderBookSyncInfo> = {}): KoreaderBookSyncInfo {
return {
bookId: 10,
bookFileId: 17,
canonicalPercentage: 0,
canonicalChapterIndex: null,
canonicalChapterTitle: null,
canonicalSource: 'web_reader',
canonicalUpdatedAt: '2026-09-10T12:00:00.000Z',
devices: [],
fileModifiedSinceLastSync: false,
heldByReset: [makeHeldDevice()],
...overrides,
}
}
function releaseButtons(wrapper: ReturnType<typeof mount>) {
return wrapper.findAll('button').filter((button) => button.text() === "Use this device's position")
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
id: 10,
@@ -181,12 +222,20 @@ function makeKoreaderProgress(overrides: Partial<KoreaderBookSyncInfo> = {}): Ko
const HELD_KINDLE = { device: 'Kindle', deviceId: 'kindle-1', percentage: 26.67, updatedAt: '2026-10-12T00:00:00.000Z' }
/** Routes each request the tab makes on mount: sessions, attempts, the progress reads and KOReader holds. */
function routeApi(options: { sessions?: BookReadingSession[]; attempts?: ReadingAttempt[]; koreader?: () => KoreaderBookSyncInfo } = {}) {
function routeApi(
options: {
sessions?: BookReadingSession[]
attempts?: ReadingAttempt[]
koreader?: () => KoreaderBookSyncInfo
koreaderProgress?: KoreaderBookSyncInfo | null
} = {},
) {
const sessions = options.sessions ?? []
const attempts = options.attempts ?? []
return (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/koreader/books/')) return Promise.resolve(jsonResponse(options.koreader?.() ?? makeKoreaderProgress()))
if (url.includes('/koreader/books/'))
return Promise.resolve(jsonResponse(options.koreader?.() ?? options.koreaderProgress ?? makeKoreaderProgress()))
if (url.includes('/reading-attempts')) return Promise.resolve(jsonResponse({ items: attempts, page: 1, pageSize: 50, total: attempts.length }))
if (url.includes('/sessions')) {
return Promise.resolve(jsonResponse({ items: sessions, total: sessions.length, page: 1, pageSize: 25, stats: makeStats(sessions) }))
@@ -203,6 +252,8 @@ function mountTab(book = makeBook()) {
})
}
enableAutoUnmount(afterEach)
describe('ReadingLogTab', () => {
beforeEach(() => {
mocks.api.mockReset()
@@ -210,6 +261,7 @@ describe('ReadingLogTab', () => {
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(true)
localStorage.clear()
i18n.global.locale.value = 'en'
})
it('shows one explanation instead of four empty panels when nothing is logged', async () => {
@@ -525,4 +577,233 @@ describe('ReadingLogTab', () => {
expect(wrapper.findAll('button').some((button) => button.text() === 'Reset reading state')).toBe(false)
})
it('shows a held device and recovery action even when the reading log is empty', async () => {
mocks.api.mockImplementation(routeApi({ koreaderProgress: makeHeldProgress() }))
const wrapper = mountTab()
await flushPromises()
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/10/progress')
expect(wrapper.get('[role="status"]').text()).toContain('Kindle Paperwhite still has the old position (42%)')
expect(releaseButtons(wrapper)).toHaveLength(1)
expect(wrapper.text()).toContain('Nothing logged for this book yet')
})
it.each([null, makeHeldProgress({ heldByReset: [] }), { ...makeHeldProgress(), heldByReset: undefined }])(
'does not show a notice without held devices, including responses from older servers (%j)',
async (progress) => {
mocks.api.mockImplementation(routeApi({ koreaderProgress: progress as KoreaderBookSyncInfo | null }))
const wrapper = mountTab()
await flushPromises()
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(releaseButtons(wrapper)).toHaveLength(0)
},
)
it('gates the hold read and release action on KOReader permission rather than metadata permission', async () => {
mocks.hasPermission.mockImplementation((permission) => permission !== Permission.KoreaderSync)
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()], koreaderProgress: makeHeldProgress() }))
const wrapper = mountTab()
await flushPromises()
expect(mocks.api.mock.calls.some(([input]) => String(input).startsWith('/api/v1/koreader/'))).toBe(false)
expect(releaseButtons(wrapper)).toHaveLength(0)
expect(wrapper.findAll('button').some((button) => button.text() === 'Reset reading state')).toBe(true)
})
it('releases the selected device, refreshes its hold, and leaves the ledger usable', async () => {
let progress = makeHeldProgress()
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/reset-hold/release')) {
progress = makeHeldProgress({ heldByReset: [], canonicalPercentage: 42 })
return Promise.resolve(jsonResponse(null))
}
return routeApi({ sessions: [makeSession()], koreaderProgress: progress })(input)
})
const wrapper = mountTab()
await flushPromises()
await releaseButtons(wrapper)[0]!.trigger('click')
await flushPromises()
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/koreader/books/10/reset-hold/release',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ deviceId: 'kindle-device' }) }),
)
expect(releaseButtons(wrapper)).toHaveLength(0)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Sessions')
})
it('disables only the releasing device and prevents duplicate releases while its request is pending', async () => {
const progress = makeHeldProgress({
heldByReset: [makeHeldDevice(), makeHeldDevice({ device: 'Kobo Clara', deviceId: 'kobo-device', percentage: 17 })],
})
const pending = deferred<Response>()
mocks.api.mockImplementation((input) =>
String(input).endsWith('/reset-hold/release') ? pending.promise : routeApi({ koreaderProgress: progress })(input),
)
const wrapper = mountTab()
await flushPromises()
await releaseButtons(wrapper)[0]!.trigger('click')
const releasing = wrapper.get('button:disabled')
expect(releasing.text()).toBe('Releasing...')
expect(releaseButtons(wrapper)).toHaveLength(1)
expect(releaseButtons(wrapper)[0]!.attributes('disabled')).toBeUndefined()
await releasing.trigger('click')
expect(mocks.api.mock.calls.filter(([input]) => String(input).endsWith('/reset-hold/release'))).toHaveLength(1)
pending.resolve({ ok: false, status: 500 } as Response)
await flushPromises()
expect(releaseButtons(wrapper)).toHaveLength(2)
})
it.each(['http', 'network'] as const)('keeps the notice and offers a retry after a %s release failure', async (failure) => {
let fail = true
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/reset-hold/release')) {
if (fail) return failure === 'network' ? Promise.reject(new Error('Offline')) : Promise.resolve({ ok: false, status: 404 } as Response)
return Promise.resolve(jsonResponse(null))
}
return routeApi({ koreaderProgress: makeHeldProgress({ heldByReset: fail ? [makeHeldDevice()] : [] }) })(input)
})
const wrapper = mountTab()
await flushPromises()
await releaseButtons(wrapper)[0]!.trigger('click')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('Could not use this position. Try again.')
expect(releaseButtons(wrapper)[0]!.attributes('disabled')).toBeUndefined()
fail = false
await releaseButtons(wrapper)[0]!.trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(releaseButtons(wrapper)).toHaveLength(0)
})
it('keeps the recovery notice if the release succeeds but refreshing progress fails', async () => {
let released = false
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/reset-hold/release')) {
released = true
return Promise.resolve(jsonResponse(null))
}
if (String(input) === '/api/v1/koreader/books/10/progress' && released) return Promise.reject(new Error('Offline'))
return routeApi({ koreaderProgress: makeHeldProgress() })(input)
})
const wrapper = mountTab()
await flushPromises()
await releaseButtons(wrapper)[0]!.trigger('click')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('Could not use this position. Try again.')
expect(releaseButtons(wrapper)).toHaveLength(1)
})
it('refreshes held devices after resetting reading state from this tab', async () => {
let reset = false
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/reset-reading-state')) {
reset = true
return Promise.resolve(jsonResponse({ readStatus: { status: 'unread' } }))
}
return routeApi({ sessions: reset ? [] : [makeSession()], koreaderProgress: reset ? makeHeldProgress() : null })(input)
})
const wrapper = mountTab()
await flushPromises()
wrapper.findComponent(ResetReadingStateDialog).vm.$emit('confirm')
await flushPromises()
expect(wrapper.get('[role="status"]').text()).toContain('Kindle Paperwhite')
expect(releaseButtons(wrapper)).toHaveLength(1)
expect(wrapper.text()).toContain('Nothing logged for this book yet')
})
it('refreshes a kept-alive tab after a device syncs while another tab is open', async () => {
let progress: KoreaderBookSyncInfo | null = null
mocks.api.mockImplementation((input) => routeApi({ koreaderProgress: progress })(input))
const visible = ref(true)
const book = makeBook()
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => (visible.value ? h(ReadingLogTab, { book }) : null) }) })
const wrapper = mount(host, { global: { plugins: [createPinia()], stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(mocks.api.mock.calls.filter(([input]) => String(input) === '/api/v1/koreader/books/10/progress')).toHaveLength(1)
visible.value = false
await flushPromises()
progress = makeHeldProgress()
visible.value = true
await flushPromises()
expect(wrapper.get('[role="status"]').text()).toContain('Kindle Paperwhite')
expect(mocks.api.mock.calls.filter(([input]) => String(input) === '/api/v1/koreader/books/10/progress')).toHaveLength(2)
})
it('does not show a late hold response for the previous book', async () => {
const previous = deferred<Response>()
mocks.api.mockImplementation((input) => (String(input) === '/api/v1/koreader/books/10/progress' ? previous.promise : routeApi()(input)))
const wrapper = mountTab()
await flushPromises()
await wrapper.setProps({ book: makeBook({ id: 20 }) })
await flushPromises()
previous.resolve(jsonResponse(makeHeldProgress()))
await flushPromises()
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/20/progress')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(releaseButtons(wrapper)).toHaveLength(0)
})
it('allows KOReader recovery without the metadata-edit permission', async () => {
mocks.hasPermission.mockImplementation((permission) => permission === Permission.KoreaderSync)
mocks.api.mockImplementation(routeApi({ koreaderProgress: makeHeldProgress() }))
const wrapper = mountTab()
await flushPromises()
expect(releaseButtons(wrapper)).toHaveLength(1)
})
it('updates the notice when KOReader permission is granted or revoked', async () => {
const allowed = ref(false)
mocks.hasPermission.mockImplementation((permission) => (permission === Permission.KoreaderSync ? allowed.value : true))
mocks.api.mockImplementation(routeApi({ koreaderProgress: makeHeldProgress() }))
const wrapper = mountTab()
await flushPromises()
expect(releaseButtons(wrapper)).toHaveLength(0)
allowed.value = true
await flushPromises()
expect(releaseButtons(wrapper)).toHaveLength(1)
mocks.api.mockClear()
allowed.value = false
await flushPromises()
expect(releaseButtons(wrapper)).toHaveLength(0)
expect(mocks.api).not.toHaveBeenCalled()
})
it('does not attach an old release failure to the next book', async () => {
const release = deferred<Response>()
mocks.api.mockImplementation((input) =>
String(input).endsWith('/reset-hold/release')
? release.promise
: routeApi({ koreaderProgress: makeHeldProgress({ bookId: String(input).includes('/20/') ? 20 : 10 }) })(input),
)
const wrapper = mountTab()
await flushPromises()
await releaseButtons(wrapper)[0]!.trigger('click')
await wrapper.setProps({ book: makeBook({ id: 20 }) })
await flushPromises()
release.resolve({ ok: false, status: 404 } as Response)
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(releaseButtons(wrapper)[0]!.attributes('disabled')).toBeUndefined()
})
it('refreshes held devices after starting a reread with a progress reset', async () => {
let reset = false
mocks.api.mockImplementation((input) => {
if (String(input).endsWith('/start-reread')) {
reset = true
return Promise.resolve(jsonResponse({ status: 'reading' }))
}
return routeApi({ sessions: [makeSession()], koreaderProgress: reset ? makeHeldProgress() : null })(input)
})
const wrapper = mountTab()
await flushPromises()
wrapper.findComponent(ReadingLogAttempts).vm.$emit('start-reread', true)
await flushPromises()
expect(wrapper.get('[role="status"]').text()).toContain('Kindle Paperwhite')
expect(releaseButtons(wrapper)).toHaveLength(1)
})
})
@@ -47,7 +47,7 @@ async function handleRelease(deviceId: string) {
</script>
<template>
<div class="flex flex-col gap-2">
<div role="status" class="flex flex-col gap-2">
<div v-for="device in held" :key="device.deviceId" class="flex items-start gap-1.5" data-testid="koreader-reset-hold">
<TriangleAlert class="mt-0.5 size-3 shrink-0 text-warning" aria-hidden="true" />
<div class="min-w-0">
@@ -56,7 +56,7 @@ async function handleRelease(deviceId: string) {
</p>
<button
type="button"
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
class="mt-0.5 rounded-xs text-[11px] text-foreground underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed"
:disabled="isReleasing(device.deviceId)"
@click="handleRelease(device.deviceId)"
>
@@ -41,6 +41,14 @@ function makeResponse(data: unknown, options: { ok?: boolean; status?: number }
} as Response
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('useKoreaderBookProgress', () => {
beforeEach(() => {
vi.resetModules()
@@ -140,4 +148,129 @@ describe('useKoreaderBookProgress', () => {
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(false)
})
it('reports a network failure without refreshing or discarding the held device', async () => {
const progress = makeBookProgress({
heldByReset: [{ device: 'Kindle', deviceId: 'device-1', percentage: 42, updatedAt: '2026-01-02T00:00:00.000Z' }],
})
apiMock.mockResolvedValueOnce(makeResponse(progress)).mockRejectedValueOnce(new Error('Offline'))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress, releaseResetHold } = useKoreaderBookProgress()
await fetchBookProgress(42)
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(false)
expect(apiMock).toHaveBeenCalledTimes(2)
expect(bookProgress.value).toEqual(progress)
})
it('preserves the notice when the release succeeds but its refresh fails', async () => {
const progress = makeBookProgress({
heldByReset: [{ device: 'Kindle', deviceId: 'device-1', percentage: 42, updatedAt: '2026-01-02T00:00:00.000Z' }],
})
apiMock
.mockResolvedValueOnce(makeResponse(progress))
.mockResolvedValueOnce(makeResponse(null))
.mockResolvedValueOnce(makeResponse(null, { ok: false }))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress, releaseResetHold } = useKoreaderBookProgress()
await fetchBookProgress(42)
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(false)
expect(bookProgress.value).toEqual(progress)
})
it('does not report success while the refreshed response still holds the device', async () => {
const progress = makeBookProgress({
heldByReset: [{ device: 'Kindle', deviceId: 'device-1', percentage: 42, updatedAt: '2026-01-02T00:00:00.000Z' }],
})
apiMock.mockResolvedValue(makeResponse(progress))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { releaseResetHold } = useKoreaderBookProgress()
await expect(releaseResetHold(42, 'device-1')).resolves.toBe(false)
})
it('keeps the latest fetch and its loading state when an older request finishes first', async () => {
const first = deferred<Response>()
const second = deferred<Response>()
apiMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, loading, fetchBookProgress } = useKoreaderBookProgress()
const olderFetch = fetchBookProgress(42)
const newerFetch = fetchBookProgress(43)
first.resolve(makeResponse(makeBookProgress()))
await olderFetch
expect(bookProgress.value).toBeNull()
expect(loading.value).toBe(true)
second.resolve(makeResponse(makeBookProgress({ bookId: 43 })))
await newerFetch
expect(bookProgress.value?.bookId).toBe(43)
expect(loading.value).toBe(false)
})
it.each(['success', 'failure'] as const)('ignores an older %s after a newer fetch completes', async (result) => {
const older = deferred<Response>()
apiMock.mockReturnValueOnce(older.promise).mockResolvedValueOnce(makeResponse(makeBookProgress({ bookId: 43 })))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, loading, fetchBookProgress } = useKoreaderBookProgress()
const olderFetch = fetchBookProgress(42)
await fetchBookProgress(43)
older.resolve(result === 'success' ? makeResponse(makeBookProgress()) : makeResponse(null, { ok: false }))
await olderFetch
expect(bookProgress.value?.bookId).toBe(43)
expect(loading.value).toBe(false)
})
it('clears the previous book immediately when fetching another book', async () => {
const next = deferred<Response>()
apiMock.mockResolvedValueOnce(makeResponse(makeBookProgress())).mockReturnValueOnce(next.promise)
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress } = useKoreaderBookProgress()
await fetchBookProgress(42)
const nextFetch = fetchBookProgress(43)
expect(bookProgress.value).toBeNull()
next.resolve(makeResponse(null))
await nextFetch
})
it('does not refetch the old book when its release finishes after navigation', async () => {
const release = deferred<Response>()
apiMock
.mockResolvedValueOnce(makeResponse(makeBookProgress()))
.mockReturnValueOnce(release.promise)
.mockResolvedValueOnce(makeResponse(makeBookProgress({ bookId: 43 })))
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress, releaseResetHold } = useKoreaderBookProgress()
await fetchBookProgress(42)
const pendingRelease = releaseResetHold(42, 'device-1')
await fetchBookProgress(43)
release.resolve(makeResponse(null))
await expect(pendingRelease).resolves.toBe(true)
expect(bookProgress.value?.bookId).toBe(43)
expect(apiMock).toHaveBeenCalledTimes(3)
})
it('invalidates a pending fetch when progress is cleared', async () => {
const pending = deferred<Response>()
apiMock.mockReturnValueOnce(pending.promise)
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, loading, fetchBookProgress, clearBookProgress } = useKoreaderBookProgress()
const fetch = fetchBookProgress(42)
clearBookProgress()
pending.resolve(makeResponse(makeBookProgress()))
await fetch
expect(bookProgress.value).toBeNull()
expect(loading.value).toBe(false)
})
it('does not refresh after permission revocation clears a pending release', async () => {
const pending = deferred<Response>()
apiMock.mockResolvedValueOnce(makeResponse(makeBookProgress())).mockReturnValueOnce(pending.promise)
const { useKoreaderBookProgress } = await import('../useKoreaderBookProgress')
const { bookProgress, fetchBookProgress, clearBookProgress, releaseResetHold } = useKoreaderBookProgress()
await fetchBookProgress(42)
const release = releaseResetHold(42, 'device-1')
clearBookProgress()
pending.resolve(makeResponse(null))
await expect(release).resolves.toBe(true)
expect(apiMock).toHaveBeenCalledTimes(2)
expect(bookProgress.value).toBeNull()
})
})
@@ -5,30 +5,53 @@ import type { KoreaderBookSyncInfo } from '@bookorbit/types'
export function useKoreaderBookProgress() {
const bookProgress = ref<KoreaderBookSyncInfo | null>(null)
const loading = ref(false)
let activeBookId: number | null = null
let fetchVersion = 0
async function fetchBookProgress(bookId: number): Promise<void> {
function clearBookProgress() {
fetchVersion += 1
activeBookId = null
bookProgress.value = null
loading.value = false
}
async function fetchBookProgress(bookId: number, options: { preserveOnError?: boolean } = {}): Promise<boolean> {
const version = ++fetchVersion
if (activeBookId !== bookId) bookProgress.value = null
activeBookId = bookId
loading.value = true
try {
const res = await api(`/api/v1/koreader/books/${bookId}/progress`)
if (!res.ok) throw new Error('Failed to fetch book progress')
bookProgress.value = await res.json()
const progress = await res.json()
if (version !== fetchVersion) return false
bookProgress.value = progress
return true
} catch {
bookProgress.value = null
if (version === fetchVersion && !options.preserveOnError) bookProgress.value = null
return false
} finally {
loading.value = false
if (version === fetchVersion) loading.value = false
}
}
async function releaseResetHold(bookId: number, deviceId: string): Promise<boolean> {
const res = await api(`/api/v1/koreader/books/${bookId}/reset-hold/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
if (!res.ok) return false
await fetchBookProgress(bookId)
return true
const version = fetchVersion
try {
const res = await api(`/api/v1/koreader/books/${bookId}/reset-hold/release`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
if (!res.ok) return false
// A release can finish after navigation; it must not replace the new book's progress.
if (activeBookId !== bookId && (activeBookId !== null || version !== fetchVersion)) return true
const refreshed = await fetchBookProgress(bookId, { preserveOnError: true })
return refreshed && bookProgress.value !== null && !(bookProgress.value.heldByReset ?? []).some((held) => held.deviceId === deviceId)
} catch {
return false
}
}
return { bookProgress, loading, fetchBookProgress, releaseResetHold }
return { bookProgress, loading, fetchBookProgress, clearBookProgress, releaseResetHold }
}
@@ -443,4 +443,99 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
[epub.bookFileId, sharing[1]!.id].sort((left, right) => left - right),
);
});
it('keeps accepting fresh reading events after deleting a KOReader session from the reading log', async () => {
const opening = cluster(BASE_EPOCH + 30_000_000, 4, 200);
await uploadPageStats(opening, epub.bookFileId);
const first = (await storedSessions()).find((row) => row.sessionId === buildSessionId(DEVICE_ID, epub.bookFileId, opening[0]!.startTime))!;
const deleted = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/sessions/${first.id}`,
headers: authHeader(ctx.adminToken),
});
expect(deleted.statusCode).toBe(204);
expect((await storedSessions()).some((row) => row.sessionId === first.sessionId)).toBe(false);
const resets = await ctx.db.select().from(schema.koreaderProgressResets).where(eq(schema.koreaderProgressResets.bookFileId, epub.bookFileId));
expect(resets).toHaveLength(0);
const replay = await uploadPageStats(opening, epub.bookFileId);
expect(replay.results[0]!.accepted).toBe(0);
expect(replay.results[0]!.duplicates).toBe(opening.length);
expect((await storedSessions()).some((row) => row.sessionId === first.sessionId)).toBe(false);
const continued = cluster(opening.at(-1)!.startTime + 60, 4, 204);
expect((await uploadPageStats(continued, epub.bookFileId)).results[0]!.accepted).toBe(continued.length);
const afterContinuation = await storedSessions();
expect(afterContinuation.some((row) => row.sessionId === first.sessionId)).toBe(false);
const newSession = afterContinuation.filter((row) => row.startedAt.getTime() === continued[0]!.startTime * 1000);
expect(comparable(newSession)).toEqual(expectedSessions(continued));
const later = cluster(BASE_EPOCH + 30_010_000, 4, 210);
expect((await uploadPageStats(later, epub.bookFileId)).results[0]!.accepted).toBe(later.length);
const log = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${epub.bookId}/sessions?sortBy=startedAt&sortDir=desc`,
headers: authHeader(ctx.adminToken),
});
expect(log.statusCode).toBe(200);
expect(log.json().items[0].startedAt).toBe(new Date(later[0]!.startTime * 1000).toISOString());
});
it('exposes a reset hold while accepting fresh sessions, then restores the selected device position on release', async () => {
const reset = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${epub.bookId}/reset-reading-state`,
headers: authHeader(ctx.adminToken),
});
expect(reset.statusCode).toBe(201);
expect(await storedSessions()).toHaveLength(0);
const pushed = await ctx.app.inject({
method: 'PUT',
url: '/api/v1/koreader/syncs/progress',
headers: deviceHeaders(),
payload: {
document: fileHash,
percentage: 0.42,
progress: '/body/DocFragment[8]/body/p[12]/text().0',
device: 'Kindle Paperwhite',
device_id: DEVICE_ID,
},
});
expect(pushed.statusCode).toBe(200);
const progress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/books/${epub.bookId}/progress`,
headers: authHeader(ctx.adminToken),
});
expect(progress.statusCode).toBe(200);
expect(progress.json().heldByReset).toEqual([expect.objectContaining({ deviceId: DEVICE_ID, percentage: 42 })]);
expect(progress.json().canonicalPercentage).toBe(0);
const fresh = cluster(Math.floor(Date.now() / 1000) + 60, 4, 130);
expect((await uploadPageStats(fresh, epub.bookFileId)).results[0]!.accepted).toBe(fresh.length);
const log = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${epub.bookId}/sessions`,
headers: authHeader(ctx.adminToken),
});
expect(log.statusCode).toBe(200);
expect(log.json().total).toBe(1);
const released = await ctx.app.inject({
method: 'POST',
url: `/api/v1/koreader/books/${epub.bookId}/reset-hold/release`,
headers: authHeader(ctx.adminToken),
payload: { deviceId: DEVICE_ID },
});
expect(released.statusCode).toBe(201);
const after = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/books/${epub.bookId}/progress`,
headers: authHeader(ctx.adminToken),
});
expect(after.statusCode).toBe(200);
expect(after.json().heldByReset).toEqual([]);
expect(after.json().canonicalPercentage).toBe(42);
});
});