diff --git a/frontend/e2e/mocks/cassette.ts b/frontend/e2e/mocks/cassette.ts index 94577f60..4ef5267c 100644 --- a/frontend/e2e/mocks/cassette.ts +++ b/frontend/e2e/mocks/cassette.ts @@ -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; status?: number; } diff --git a/frontend/e2e/mocks/install.ts b/frontend/e2e/mocks/install.ts index 0e766d4c..b866bb34 100644 --- a/frontend/e2e/mocks/install.ts +++ b/frontend/e2e/mocks/install.ts @@ -14,7 +14,7 @@ export const installMockRoutes = async (page: Page, world: MockWorld): Promise { 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 | 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, actual?: Record new Promise((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 => + 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 => `sub:${operationName}:${stableStringify(variables ?? {})}`; @@ -110,22 +118,32 @@ export class MockWorld { return undefined; } - matchRest(method: string, pathname: string, body?: Record): RestCassetteEntry | undefined { + matchRest( + method: string, + pathname: string, + body?: Record, + 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; } diff --git a/frontend/e2e/mocks/world.unit.test.ts b/frontend/e2e/mocks/world.unit.test.ts index 8435174f..9032e3b4 100644 --- a/frontend/e2e/mocks/world.unit.test.ts +++ b/frontend/e2e/mocks/world.unit.test.ts @@ -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' }] },