From aedddb5b5feadfbea785b56383174bcf1014225c Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Tue, 8 Sep 2026 13:03:48 -0300 Subject: [PATCH] test: stop the mock server sharing _links between concurrent requests FX-287 The resource GET handler wrote `_links` onto the dataset entry itself and then awaited `addEmbeds` before serializing, so a second GET for the same resource could overwrite the first one's `_links` while the first was still resolving embeds. Both responses then carried the last writer's `self` href. The same sharing also left `_embedded` from a zoomed request on later unzoomed ones, which needs no race at all. Write both onto a copy instead. `src/server` had no test group, and the config has no top-level `files` key, so a test placed there was silently never run. The generator now emits one group for it. npx wtr --group server --- .scripts/generate-groups.js | 8 ++++ .../router/createResourceGetHandler.test.ts | 43 +++++++++++++++++++ src/server/router/createResourceGetHandler.ts | 9 +++- web-test-runner.groups.js | 4 ++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/server/router/createResourceGetHandler.test.ts diff --git a/.scripts/generate-groups.js b/.scripts/generate-groups.js index 9cd5e9c5e..2b9c4148b 100644 --- a/.scripts/generate-groups.js +++ b/.scripts/generate-groups.js @@ -37,6 +37,14 @@ publicElements.forEach(file => { } }); +// The mock HAL+JSON server is not a component directory, so it gets one group of its own. Without +// it there is nothing globbing ./src/server, and since the config has no top-level `files` key a +// test placed there is silently never run. +groups.push({ + name: 'server', + files: './src/server/**/*.test.ts', +}); + const configURL = new URL('../web-test-runner.groups.js', import.meta.url); const config = `export const groups = ${JSON.stringify(groups, null, 2)}`; diff --git a/src/server/router/createResourceGetHandler.test.ts b/src/server/router/createResourceGetHandler.test.ts new file mode 100644 index 000000000..1ff9bbe45 --- /dev/null +++ b/src/server/router/createResourceGetHandler.test.ts @@ -0,0 +1,43 @@ +import type { HandleResult, Router } from 'service-worker-router'; + +import { createRouter } from '../hapi/index'; +import { expect } from '@open-wc/testing'; + +type ResponseBody = { + _embedded?: Record; + _links: { self: { href: string } }; +}; + +const base = 'https://demo.api/hapi'; + +function get(router: Router, url: string): Promise { + const result = router.handleRequest(new Request(url)) as HandleResult; + return result.handlerPromise.then(response => response.json()); +} + +describe('createResourceGetHandler', () => { + it('gives each of two overlapping requests for one resource its own _links', async () => { + const zoomedURL = `${base}/customers/0?zoom=attributes`; + const plainURL = `${base}/customers/0`; + const router = createRouter(); + + // Both requests are dispatched before either is awaited, so the second one runs while the + // first is still resolving its embeds. + const whenZoomed = get(router, zoomedURL); + const whenPlain = get(router, plainURL); + const [zoomed, plain] = await Promise.all([whenZoomed, whenPlain]); + + expect(zoomed._links.self).to.have.property('href', zoomedURL); + expect(plain._links.self).to.have.property('href', plainURL); + }); + + it('does not leave _embedded from a zoomed request on a later plain request', async () => { + const router = createRouter(); + + const zoomed = await get(router, `${base}/customers/0?zoom=attributes`); + const plain = await get(router, `${base}/customers/0`); + + expect(zoomed._embedded).to.have.property('fx:attributes'); + expect(plain).to.not.have.property('_embedded'); + }); +}); diff --git a/src/server/router/createResourceGetHandler.ts b/src/server/router/createResourceGetHandler.ts index 84a1d2b1b..8b0005bc2 100644 --- a/src/server/router/createResourceGetHandler.ts +++ b/src/server/router/createResourceGetHandler.ts @@ -44,8 +44,13 @@ async function addEmbeds(router: Router, result: Document, zoom: string[][]) { export function createResourceGetHandler(router: Router, dataset: Dataset, links: Links) { return async ({ params, url }: HandlerContext): Promise => { const { collection, prefix, id } = params; - const document = dataset[collection]?.find(v => v.id == id); - if (!document) return new Response('Not found', { status: 404 }); + const storedDocument = dataset[collection]?.find(v => v.id == id); + if (!storedDocument) return new Response('Not found', { status: 404 }); + + // Serialization happens after `addEmbeds` yields, so `_links` and `_embedded` go onto a copy. + // Writing them to the stored document lets two overlapping requests for the same resource + // overwrite each other's `_links`, and leaves `_embedded` behind for later unzoomed requests. + const document = { ...storedDocument }; const resourceLinks = links[collection]?.(document) ?? {}; resourceLinks.self = { href: url.toString() }; diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index cd68b5b2e..beea8951f 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -707,4 +707,8 @@ export const groups = [ name: 'foxy-webhook-status-card', files: './src/elements/public/WebhookStatusCard/**/*.test.ts', }, + { + name: 'server', + files: './src/server/**/*.test.ts', + }, ];