test(frontend): pin abort-chain + signal pass-through contracts (0.87.16)
Three 0.87.9 follow-ups: 1. hooks.server.ts: two new tests covering the already-aborted-at-entry and mid-flight client-disconnect branches of the abort chain. Mutation-confirmed. 2. lib/api/admin.ts: two new tests pinning the getAnalysisMangaCoverage(init?.signal) pass-through and the AbortError-rejection propagation the dashboard's debounce-race handler relies on. Mutation-confirmed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.15"
|
version = "0.87.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.87.15"
|
version = "0.87.16"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.87.15",
|
"version": "0.87.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ function makeEvent(path: string, init?: RequestInit) {
|
|||||||
return { url, request } as Parameters<typeof handle>[0]['event'];
|
return { url, request } as Parameters<typeof handle>[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<typeof handle>[0]['event'];
|
||||||
|
}
|
||||||
|
|
||||||
describe('hooks.server proxy', () => {
|
describe('hooks.server proxy', () => {
|
||||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||||
|
|
||||||
@@ -191,6 +200,52 @@ describe('hooks.server proxy', () => {
|
|||||||
// presence is the contract this test is pinning.
|
// presence is the contract this test is pinning.
|
||||||
expect(init.signal?.aborted).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -713,6 +713,48 @@ describe('admin crawler api client', () => {
|
|||||||
expect(url).toContain('offset=50');
|
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 () => {
|
it('getAnalysisChapterCoverage unwraps items', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(
|
fetchSpy.mockResolvedValueOnce(
|
||||||
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
|
ok({ items: [{ chapter_id: 'c1', number: 1, title: null, total_pages: 2, analyzed_pages: 1 }] })
|
||||||
|
|||||||
Reference in New Issue
Block a user