mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user