|
| 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