test(e2e): let a cassette entry pin the request's query string

The REST gate matched on method and path alone, so an endpoint that carries its
payload in the query and sends no body could not be pinned — `bodySubset` has
nothing to bite on, and the path hit answered success whatever the request
named. Unmatched-call diagnostics dropped the query too, so a miss printed a
path that looked right.

Add `querySubset`, the counterpart to `bodySubset`, thread the query into the
matcher and the sequencing cursor, and report it on a miss. No spec exercises a
query-carrying endpoint yet; the unit test pins the behaviour and fails when the
new filter is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-24 19:34:09 +07:00
co-authored by Claude Opus 4.8
parent fadde5ad5a
commit 162c84d99a
4 changed files with 55 additions and 9 deletions
+7
View File
@@ -51,6 +51,13 @@ export interface RestCassetteEntry extends WorldFlagged {
* a broken image while the request still counts as matched.
*/
contentType?: string;
/**
* Subset match against the request's query string, the counterpart to `bodySubset` for
* endpoints that carry their payload there and send no body — a delete addressed by
* `?paths[]=…` is otherwise answered success whatever it names. Repeated keys are compared
* as the full list of values.
*/
querySubset?: Record<string, string | string[]>;
status?: number;
}
+3 -3
View File
@@ -14,7 +14,7 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise<v
// instead of leaking through the vite preview proxy to a live backend.
await page.route('**/api/v1/**', async (route) => {
const request = route.request();
const { pathname } = new URL(request.url());
const { pathname, searchParams } = new URL(request.url());
if (pathname === GRAPHQL_PATH && request.method() === 'POST') {
const body = request.postDataJSON() as {
@@ -50,7 +50,7 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise<v
const body = contentType.includes('application/json')
? (request.postDataJSON() as Record<string, unknown> | undefined)
: undefined;
const entry = world.matchRest(request.method(), pathname, body ?? undefined);
const entry = world.matchRest(request.method(), pathname, body ?? undefined, searchParams);
if (entry) {
await route.fulfill(
@@ -66,7 +66,7 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise<v
return;
}
world.reportUnmatched(`${request.method()} ${pathname}`);
world.reportUnmatched(`${request.method()} ${pathname}${searchParams.size ? `?${searchParams}` : ''}`);
await route.fulfill({
json: { error: `e2e: no cassette entry for ${request.method()} ${pathname}`, status: 'error' },
status: 501,
+24 -6
View File
@@ -39,6 +39,14 @@ const isSubsetMatch = (expected?: Record<string, unknown>, actual?: Record<strin
const sleep = (delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs));
/** Repeated keys (`paths[]=a&paths[]=b`) collapse to the full list, so a subset match sees both. */
const queryRecord = (search?: URLSearchParams): Record<string, string | string[]> =>
Object.fromEntries(
[...(search?.keys() ?? [])]
.map((key) => [key, search!.getAll(key)])
.map(([key, values]) => [key, (values as string[]).length > 1 ? values : (values as string[])[0]]),
);
export const subscriptionStreamKey = (operationName: string, variables?: Record<string, unknown>): string =>
`sub:${operationName}:${stableStringify(variables ?? {})}`;
@@ -110,22 +118,32 @@ export class MockWorld {
return undefined;
}
matchRest(method: string, pathname: string, body?: Record<string, unknown>): RestCassetteEntry | undefined {
matchRest(
method: string,
pathname: string,
body?: Record<string, unknown>,
search?: URLSearchParams,
): RestCassetteEntry | undefined {
const normalized = pathname.replace(/\/+$/, '');
const key = Object.keys(this.cassette.rest ?? {}).find((candidate) => {
const [candidateMethod, candidatePath = ''] = candidate.split(' ');
return candidateMethod === method && candidatePath.replace(/\/+$/, '') === normalized;
});
const matching = (key ? this.cassette.rest?.[key] : undefined)?.filter((entry) =>
isSubsetMatch(entry.bodySubset, body),
const query = queryRecord(search);
const matching = (key ? this.cassette.rest?.[key] : undefined)?.filter(
(entry) => isSubsetMatch(entry.bodySubset, body) && isSubsetMatch(entry.querySubset, query),
);
const candidates = this.eligible(matching);
// Include the body in the cursor key (as the GraphQL half keys on variables): two
// body-differentiated sequences for one path must not reset each other's cursor.
// Include body and query in the cursor key (as the GraphQL half keys on variables): two
// payload-differentiated sequences for one path must not reset each other's cursor.
return matching && candidates?.length
? this.nextEntry(`rest:${method}:${normalized}:${stableStringify(body ?? {})}`, candidates, matching)
? this.nextEntry(
`rest:${method}:${normalized}:${stableStringify(body ?? {})}:${stableStringify(query)}`,
candidates,
matching,
)
: undefined;
}
+21
View File
@@ -56,6 +56,27 @@ describe('MockWorld matching', () => {
expect(world.matchRest('POST', '/api/v1/resources/mkdir/', { path: 'new-folder' })).toBeDefined();
});
it('pins a query-carrying request on querySubset, the way bodySubset pins a payload', () => {
const world = new MockWorld({
rest: {
'DELETE /api/v1/resources/': [
{ body: { deleted: 'notes' }, querySubset: { 'paths[]': 'notes.txt' } },
{ body: { deleted: 'both' }, querySubset: { 'paths[]': ['a.txt', 'b.txt'] } },
],
},
});
const query = (search: string) => new URLSearchParams(search);
expect(world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=notes.txt'))?.body).toEqual({
deleted: 'notes',
});
expect(
world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=a.txt&paths[]=b.txt'))?.body,
).toEqual({ deleted: 'both' });
// The path alone must not answer for a payload no entry describes.
expect(world.matchRest('DELETE', '/api/v1/resources/', undefined, query('paths[]=secret.txt'))).toBeUndefined();
});
it('hides a flag-gated entry until the flag is raised, then lets it outrank the unflagged one', () => {
const world = new MockWorld({
mutations: { login: [{ data: { ok: true }, setFlag: 'logged-in' }] },