diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dadd79b..53538e4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.87.15" +version = "0.87.16" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b9e82eb..5852227 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.87.15" +version = "0.87.16" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 38f0608..de1c74d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.87.15", + "version": "0.87.16", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 685fcfd..226da79 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -20,6 +20,15 @@ function makeEvent(path: string, init?: RequestInit) { return { url, request } as Parameters[0]['event']; } +/** Like {@link makeEvent} but lets a test inject a specific `signal` + * on the underlying Request — the path SvelteKit uses to surface the + * client-disconnect signal. Used by the abort-chain tests. */ +function makeEventWithSignal(path: string, signal: AbortSignal) { + const url = new URL(`http://app.example.com${path}`); + const request = new Request(url, { signal }); + return { url, request } as Parameters[0]['event']; +} + describe('hooks.server proxy', () => { let fetchSpy: MockInstance; @@ -191,6 +200,52 @@ describe('hooks.server proxy', () => { // presence is the contract this test is pinning. expect(init.signal?.aborted).toBe(false); }); + + // T2 — client-disconnect abort chain (0.87.9). Closing an admin tab + // must propagate into the upstream fetch so the backend's SSE + // broadcast::Receiver + spawned task tear down immediately. Before + // 0.87.9 the upstream fetch kept running until the proxy wall-clock + // timeout fired. + it('chains an already-aborted client signal into the upstream signal immediately', async () => { + // Capture the signal the proxy hands fetch; resolve when called. + let upstream: AbortSignal | undefined; + fetchSpy.mockImplementation(async (_url, init) => { + upstream = (init as RequestInit).signal ?? undefined; + return new Response('[]', { status: 200 }); + }); + const resolve = vi.fn(); + // Build an event with an ALREADY-aborted request signal — the + // shape SvelteKit hands us after a client disconnect that has + // already settled. + const ctrl = new AbortController(); + ctrl.abort(); + const event = makeEventWithSignal('/api/v1/health', ctrl.signal); + await handle({ event, resolve }); + expect(upstream).toBeInstanceOf(AbortSignal); + expect(upstream?.aborted).toBe(true); + }); + + it('chains a mid-flight client-disconnect into the upstream signal', async () => { + // The mid-flight shape: request.signal is live when the handler + // starts, then aborts while the upstream fetch is in progress. + const clientCtrl = new AbortController(); + let upstream: AbortSignal | undefined; + fetchSpy.mockImplementation(async (_url, init) => { + upstream = (init as RequestInit).signal ?? undefined; + // Let the handler's chaining listener attach, then trip the + // client signal — the upstream signal should reflect. + await Promise.resolve(); + clientCtrl.abort(); + // Tick once so the listener observes the abort before we + // check. + await Promise.resolve(); + return new Response('[]', { status: 200 }); + }); + const resolve = vi.fn(); + const event = makeEventWithSignal('/api/v1/health', clientCtrl.signal); + await handle({ event, resolve }); + expect(upstream?.aborted).toBe(true); + }); }); // ---------------------------------------------------------------------------- diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 0571508..c8320a3 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -713,6 +713,48 @@ describe('admin crawler api client', () => { expect(url).toContain('offset=50'); }); + it('getAnalysisMangaCoverage forwards the AbortSignal to fetch', async () => { + // The dashboard's debounce-race fix (0.87.9) relies on this knob + // landing on `init.signal`. Without it, a slow first request + // can still overwrite a faster second one because `request()` + // builds a fresh fetch with no cancellation hook. + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + ); + const ctrl = new AbortController(); + await getAnalysisMangaCoverage( + { limit: 50 }, + { signal: ctrl.signal } + ); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.signal).toBe(ctrl.signal); + }); + + it('getAnalysisMangaCoverage propagates an abort rejection', async () => { + // Mid-flight abort: caller flips the AbortController, the fetch + // promise rejects, and the api client lets the rejection + // surface to its caller (the debounce-race handler treats + // AbortError as silent cancellation rather than an error toast). + const ctrl = new AbortController(); + fetchSpy.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal; + signal?.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true } + ); + }) + ); + const inflight = getAnalysisMangaCoverage( + { limit: 25 }, + { signal: ctrl.signal } + ); + ctrl.abort(); + await expect(inflight).rejects.toMatchObject({ name: 'AbortError' }); + }); + it('getAnalysisChapterCoverage unwraps items', async () => { fetchSpy.mockResolvedValueOnce( ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })