Skip to content

Commit 68feaad

Browse files
test(http-conformance): 「重复查询参数」的跨适配器分歧落锁为已知事实 (#6878 路线 1) (#6941)
* test(qa): pin the repeated-query-parameter divergence between the two IHttpServer adapters (#6878 route 1) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx * chore(changeset): http-conformance patch — repeated-query-parameter divergence record (#6878) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df0605b commit 68feaad

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/http-conformance": patch
3+
---
4+
5+
test(http-conformance): a repeated query parameter is now a pinned cross-adapter fact, instead of an unrecorded disagreement
6+
7+
The two `IHttpServer` implementations hand a handler two different `req.query`
8+
shapes for one and the same request, and until now nothing in the repo said so.
9+
Re-measured for this change on hono@4.12.34, over a real socket, through the
10+
same public entry points production uses:
11+
12+
```
13+
GET /probe?version=1.0.0&version=2.0.0&single=9
14+
15+
[NodeHttpServer] { version: ['1.0.0', '2.0.0'], single: '9' } // array
16+
[HonoHttpServer] { version: '1.0.0', single: '9' } // first value
17+
```
18+
19+
`NodeHttpServer` reads `url.searchParams.getAll(key)` and keeps the array when
20+
`length > 1`; `HonoHttpServer` reads `c.req.query()`, which yields the first
21+
value per key.
22+
23+
**Neither adapter is wrong.** `IHttpRequest.query` is declared
24+
`Record<string, string | string[]>` and both shapes satisfy it, so this is a
25+
divergence the contract currently permits — not a bug on either side. What was
26+
missing was any gate recording it: the platform's answer to a repeated query
27+
parameter depends on which server booted, and this package exists precisely to
28+
assert that everything registered through `IHttpServer` behaves the same on a
29+
non-Hono server.
30+
31+
The node half was already pinned, but only adapter-locally (`adapter.test.ts`,
32+
`?a=1&b=x&b=y`). Neither `describe.each(ADAPTERS)` suite repeated a parameter at
33+
all, so the one place the adapters visibly disagree was the one place the
34+
cross-adapter suite was not looking. Consumer-side tests do not cover it either:
35+
`packages/rest`'s `package-routes-query-multiplicity.test.ts` (#6307)
36+
hand-constructs `query: { version: [...] }` and drives the handler directly, so
37+
it asserts a shape no adapter is obliged to produce.
38+
39+
`query-multiplicity.conformance.test.ts` therefore **records the divergence as
40+
it is** rather than asserting a unified answer — there is no unified answer yet,
41+
and inventing one in a test file would settle #6878's open contract question
42+
through the back door. Each adapter row carries its measured shape, one describe
43+
states the disagreement out loud, and a single-valued control key separates
44+
"arrays repeats" from "arrays everything".
45+
46+
This is route 1 of #6878 only. Both adapters' behaviour is unchanged and
47+
`packages/spec/src/contracts/http-server.ts` is untouched; the choice between
48+
"always array" and "always single" stays open on that card. When it is decided,
49+
this file goes red on purpose — that red is the reminder to collapse the
50+
per-adapter rows into one shared expectation.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A REPEATED query parameter — cross-adapter conformance (#6878, route 1).
5+
*
6+
* ## What this file is, and what it deliberately is NOT
7+
*
8+
* It is a **divergence record**, not a contract. The two `IHttpServer`
9+
* implementations hand a handler two DIFFERENT `req.query` shapes for one and
10+
* the same request, and **both are legal today**: the contract in
11+
* `packages/spec/src/contracts/http-server.ts` declares
12+
* `query: Record<string, string | string[]>`, and a repeated key landing on
13+
* either arm of that union satisfies it. Neither adapter has a bug here.
14+
*
15+
* So this file does NOT assert one unified answer. There isn't one. Asserting
16+
* one would be encoding a wish — and worse, it would settle #6878's open
17+
* contract question through the back door of a test file. Each adapter row
18+
* below carries the shape that adapter was **measured** to produce, and the
19+
* last describe states the disagreement out loud.
20+
*
21+
* ## The measurement (re-taken for this PR, not copied from the card)
22+
*
23+
* Measured 2026-08-09 on `origin/main` @ 9d425a94d, hono@4.12.34 resolved,
24+
* over a real socket through the same public entry points as production:
25+
*
26+
* GET /probe?version=1.0.0&version=2.0.0&single=9
27+
*
28+
* [NodeHttpServer] { version: ['1.0.0', '2.0.0'], single: '9' } // array
29+
* [HonoHttpServer] { version: '1.0.0', single: '9' } // first value
30+
*
31+
* - `NodeHttpServer` (this package's reference adapter, `./adapter.ts`) reads
32+
* `url.searchParams.getAll(key)` and keeps the array when `length > 1`.
33+
* - `HonoHttpServer` (`plugin-hono-server/src/adapter.ts`) reads
34+
* `c.req.query()`, which yields the FIRST value per key. It has two such
35+
* construction sites — the route handler seam and the `use()` middleware
36+
* seam — so both must move together whenever #6878 is decided.
37+
*
38+
* ## Why the gap existed until now
39+
*
40+
* The node half was already pinned, but only ADAPTER-LOCALLY, in
41+
* `adapter.test.ts` ('routes :param and multi-value query', `?a=1&b=x&b=y`).
42+
* Nothing ran the same request against Hono, and no case in either
43+
* `describe.each(ADAPTERS)` suite (`conformance.integration.test.ts`,
44+
* `fallback-seam.conformance.test.ts`) repeated a parameter at all. The one
45+
* place the adapters visibly disagree was therefore the one place this package
46+
* — whose entire purpose is "everything registered through `IHttpServer`
47+
* behaves the same on a non-Hono server" — was not looking.
48+
*
49+
* Consumer-side tests do not close that gap either: `packages/rest`'s
50+
* `package-routes-query-multiplicity.test.ts` (#6307) hand-constructs
51+
* `query: { version: [...] }` and drives the handler directly, so it asserts a
52+
* shape that no adapter is obliged to produce. A hand-built double can produce
53+
* anything, which is precisely why the suite was green.
54+
*
55+
* ## THIS FILE IS EXPECTED TO GO RED — on purpose — when #6878 is decided
56+
*
57+
* #6878 offers three dispositions; route 1 (this file) pins the divergence as
58+
* KNOWN without removing it. If route 2 lands ("a repeated parameter is always
59+
* an array": Hono switches to `c.req.queries()` normalised by `length`) or
60+
* route 3 lands ("always single, first value wins": node collapses), the rows
61+
* below stop matching and the divergence describe fails. That red is the
62+
* REMINDER, not a regression: collapse the per-adapter `measuredQuery` rows
63+
* into one shared expectation and delete the divergence describe.
64+
*
65+
* One datum for that decision, gathered here: the `single` key is a control.
66+
* On hono@4.12.34, `c.req.queries()` returns an array for EVERY key
67+
* (`{ version: ['1.0.0','2.0.0'], single: ['9'] }`), so route 2 cannot be a
68+
* bare swap — without the `length` normalisation, `single` would arrive as
69+
* `['9']` and this file's node/hono agreement on single-valued keys, asserted
70+
* below, is what would catch it.
71+
*/
72+
73+
import { describe, it, expect, afterEach } from 'vitest';
74+
import type { IHttpRequest, RouteHandler } from '@objectstack/core';
75+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
76+
77+
import { NodeHttpServer } from './adapter.js';
78+
79+
/** The `IHttpServer` surface these cases drive — one GET route, one socket. */
80+
interface QueryProbeServer {
81+
get(path: string, handler: RouteHandler): void;
82+
listen(port: number): Promise<void>;
83+
close?(): Promise<void>;
84+
getPort(): number;
85+
}
86+
87+
/**
88+
* The probe request, verbatim from #6878: one key repeated, one key single.
89+
* The single key is load-bearing — it separates "this adapter arrays repeats"
90+
* from "this adapter arrays everything".
91+
*/
92+
const PROBE_QUERY = 'version=1.0.0&version=2.0.0&single=9';
93+
94+
type AdapterCase = {
95+
label: 'node' | 'hono';
96+
make: () => QueryProbeServer;
97+
/**
98+
* The shape this adapter was MEASURED to hand the handler for
99+
* `?${PROBE_QUERY}` — recorded, not chosen. See the file header: both
100+
* shapes satisfy `IHttpRequest.query` today, and which one is "right" is
101+
* exactly what #6878 has not decided yet.
102+
*/
103+
measuredQuery: Record<string, string | string[]>;
104+
};
105+
106+
const ADAPTERS: AdapterCase[] = [
107+
{
108+
label: 'node',
109+
make: () => new NodeHttpServer(0),
110+
// `url.searchParams.getAll(key)`, kept as an array when length > 1.
111+
measuredQuery: { version: ['1.0.0', '2.0.0'], single: '9' },
112+
},
113+
{
114+
label: 'hono',
115+
make: () => {
116+
const server = new HonoHttpServer(0);
117+
// The standard composed state `HonoServerPlugin.start()` produces —
118+
// same as the sibling fallback-seam suite. No case here issues an
119+
// unmatched request, but booting both adapters in the shape a
120+
// deployment actually serves keeps the comparison honest.
121+
server.installNotFoundSeam();
122+
return server;
123+
},
124+
// `c.req.query()`, which yields the first value per key.
125+
measuredQuery: { version: '1.0.0', single: '9' },
126+
},
127+
];
128+
129+
/** Register `/probe` on `server`, capturing the `req` the adapter builds. */
130+
function probeRoute(server: QueryProbeServer): { received: () => IHttpRequest | undefined } {
131+
let received: IHttpRequest | undefined;
132+
server.get('/probe', (req, res) => {
133+
received = req;
134+
res.status(200);
135+
res.json({ ok: true });
136+
});
137+
return { received: () => received };
138+
}
139+
140+
describe.each(ADAPTERS)(
141+
'repeated query parameter on $label adapter (#6878 — divergence record, not a contract)',
142+
({ make, measuredQuery }) => {
143+
const opened: QueryProbeServer[] = [];
144+
145+
async function boot(server: QueryProbeServer): Promise<string> {
146+
await server.listen(0);
147+
opened.push(server);
148+
return `http://127.0.0.1:${server.getPort()}`;
149+
}
150+
151+
afterEach(async () => {
152+
await Promise.all(opened.splice(0).map((s) => s.close?.()));
153+
});
154+
155+
it('hands the handler its measured query shape, over a real socket', async () => {
156+
const server = make();
157+
const probe = probeRoute(server);
158+
const base = await boot(server);
159+
160+
const res = await fetch(`${base}/probe?${PROBE_QUERY}`);
161+
expect(res.status).toBe(200);
162+
163+
// Exact shape, not a subset: this is the record of what this
164+
// adapter does TODAY. Both the repeated key and the single key
165+
// are pinned, so a change to either is visible.
166+
expect(probe.received()?.query).toEqual(measuredQuery);
167+
});
168+
169+
it('leaves a single-valued key a plain string (the control)', async () => {
170+
// Without this, "arrays on repeats" and "arrays on everything"
171+
// read identically — and they are different adapters' futures
172+
// under #6878 route 2. See the file header.
173+
const server = make();
174+
const probe = probeRoute(server);
175+
const base = await boot(server);
176+
177+
await fetch(`${base}/probe?single=9`);
178+
expect(probe.received()?.query).toEqual({ single: '9' });
179+
});
180+
},
181+
);
182+
183+
/**
184+
* The divergence itself, asserted in ONE place so it cannot be read as two
185+
* unrelated per-adapter facts. This describe is the deliverable of #6878
186+
* route 1: the platform's answer to a repeated query parameter currently
187+
* depends on which server booted, and from here on a gate says so.
188+
*
189+
* It fails the day the two adapters agree — see the file header for why that
190+
* red is the intended alarm and what to do about it.
191+
*/
192+
describe('node ↔ hono: the repeated-parameter answer depends on which server booted (#6878)', () => {
193+
const opened: QueryProbeServer[] = [];
194+
195+
async function bootProbe(adapter: AdapterCase) {
196+
const server = adapter.make();
197+
const probe = probeRoute(server);
198+
await server.listen(0);
199+
opened.push(server);
200+
return { base: `http://127.0.0.1:${server.getPort()}`, probe };
201+
}
202+
203+
afterEach(async () => {
204+
await Promise.all(opened.splice(0).map((s) => s.close?.()));
205+
});
206+
207+
it('agrees on a single-valued key and DISAGREES on a repeated one', async () => {
208+
const node = await bootProbe(ADAPTERS[0]);
209+
const hono = await bootProbe(ADAPTERS[1]);
210+
211+
const [nodeRes, honoRes] = await Promise.all([
212+
fetch(`${node.base}/probe?${PROBE_QUERY}`),
213+
fetch(`${hono.base}/probe?${PROBE_QUERY}`),
214+
]);
215+
expect(nodeRes.status).toBe(honoRes.status);
216+
217+
const nodeQuery = node.probe.received()?.query;
218+
const honoQuery = hono.probe.received()?.query;
219+
220+
// Same status, same route, same request — and not the same answer.
221+
expect(nodeQuery?.single).toBe('9');
222+
expect(honoQuery?.single).toBe('9');
223+
expect(nodeQuery?.version).toEqual(['1.0.0', '2.0.0']);
224+
expect(honoQuery?.version).toBe('1.0.0');
225+
expect(
226+
nodeQuery?.version,
227+
'the adapters now AGREE on a repeated query parameter — #6878 route 2/3 has ' +
228+
'presumably landed, so collapse the per-adapter `measuredQuery` rows into one ' +
229+
'shared expectation and delete this describe',
230+
).not.toEqual(honoQuery?.version);
231+
});
232+
233+
it('a consumer reading the FIRST value gets a different operand on each adapter', async () => {
234+
// Why the shape difference is not cosmetic: this is the read #6307
235+
// found on `DELETE /api/v1/packages/:id`, where a truthy `version`
236+
// silently narrows a destructive operation's scope. Same request, two
237+
// operands — `'1.0.0'` on Hono, an ARRAY on node:http — and the
238+
// consumer cannot tell which server it is running on.
239+
const node = await bootProbe(ADAPTERS[0]);
240+
const hono = await bootProbe(ADAPTERS[1]);
241+
242+
await Promise.all([
243+
fetch(`${node.base}/probe?${PROBE_QUERY}`),
244+
fetch(`${hono.base}/probe?${PROBE_QUERY}`),
245+
]);
246+
247+
const asOperand = (q: IHttpRequest['query'] | undefined) => q?.version;
248+
expect(typeof asOperand(hono.probe.received()?.query)).toBe('string');
249+
expect(Array.isArray(asOperand(node.probe.received()?.query))).toBe(true);
250+
});
251+
});

0 commit comments

Comments
 (0)