|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The one place this package builds an `IHttpResponse` for a test (#13454). |
| 5 | + * |
| 6 | + * Test layer only — nothing in `src/index.ts` reaches it, so tsup (entry: |
| 7 | + * `src/index.ts`) never emits it into `dist` and it is not published. Same |
| 8 | + * placement, and for the same reason, as `src/http-request-test-builder.ts` |
| 9 | + * and `src/xlsx-test-loader.ts`. |
| 10 | + * |
| 11 | + * ## The defect, stated once |
| 12 | + * |
| 13 | + * `IHttpResponse` (`packages/spec/src/contracts/http-server.ts`) declares FOUR |
| 14 | + * required members — `json`, `send`, `status`, `header` — and a route handler's |
| 15 | + * SECOND parameter IS that interface. So a test that hands a handler an object |
| 16 | + * literal owes all four. This package hands it two: |
| 17 | + * |
| 18 | + * ``` |
| 19 | + * src/rest.test.ts(2065,7): error TS2345: Argument of type |
| 20 | + * '{ json: Mock<Procedure>; status: Mock<Procedure>; }' |
| 21 | + * is not assignable to parameter of type 'IHttpResponse'. |
| 22 | + * Type '{ json: Mock<Procedure>; status: Mock<Procedure>; }' is missing the |
| 23 | + * following properties from type 'IHttpResponse': send, header |
| 24 | + * ``` |
| 25 | + * |
| 26 | + * ⚠️ Those two errors were never absent — they were MASKED. `tsc` reports at |
| 27 | + * most one argument-assignability error per call expression, so while argument |
| 28 | + * 1 was a non-conforming request literal it hid argument 2 entirely; repairing |
| 29 | + * the request half (#13377) is what made them visible, at the very same two |
| 30 | + * sites and with the per-file ledger count unmoved at 2. That mechanism has not |
| 31 | + * gone away and is why the repair here is one builder rather than two edits: |
| 32 | + * the decision about what a mock response IS belongs in a place a reader can |
| 33 | + * find, not spread across the 28 literals of this shape that this file holds. |
| 34 | + * |
| 35 | + * ## Why each decision is the decision |
| 36 | + * |
| 37 | + * **All four required members are present, always — including the two the |
| 38 | + * literals omit.** `send` and `header` are absent from those literals not |
| 39 | + * because a fixture means "this response cannot do that", but because whoever |
| 40 | + * wrote it stopped at the members the handler happened to call. Supplying them |
| 41 | + * is strictly better-formed than omitting them: had a handler reached |
| 42 | + * `res.header(...)`, the literal would have THROWN, and the test would have |
| 43 | + * failed for a reason unrelated to the thing it names. Same argument as |
| 44 | + * `headers: {}` in the request builder. |
| 45 | + * |
| 46 | + * **`status` and `header` return the double, and are TYPED as returning the |
| 47 | + * interface.** The contract declares `status(code: number): IHttpResponse`, and |
| 48 | + * that return is load-bearing rather than decorative — it is what makes |
| 49 | + * `res.status(404).json(...)` chain, which is how `src/rest-server.ts` writes |
| 50 | + * essentially every response it sends (138 `.status(` call sites). The literals |
| 51 | + * spell this `vi.fn().mockReturnThis()`, which is right at runtime only while |
| 52 | + * the member is invoked as a method on the response, and is typed |
| 53 | + * `Mock<Procedure>` — returning `any` — either way. Returning `double` |
| 54 | + * explicitly is true under any call shape, and it means a chained `.json(...)` |
| 55 | + * lands on the SAME spy the test asserts on whether the handler chained or not. |
| 56 | + * |
| 57 | + * **`write` and `end` are deliberately ABSENT, and have no default at all.** |
| 58 | + * This is the `remoteAddress` argument of the request builder, and it is the |
| 59 | + * one default here that could silently change what a test measures. Both are |
| 60 | + * optional AND feature-detected: the contract on `write` (#3607, ADR-0076 |
| 61 | + * OQ#10) says consumers emitting streaming results "feature-detect this member |
| 62 | + * and fall back to buffered `send()` when absent". A double that supplied them |
| 63 | + * by default would therefore route every streaming handler down its streaming |
| 64 | + * path — no test edited, every such test now measuring something else. Absent |
| 65 | + * is both legal and true; a test that is ABOUT streaming says so, by stating |
| 66 | + * them. |
| 67 | + * |
| 68 | + * **The double records through its spies and nowhere else.** This is the design |
| 69 | + * question the card raised — what a mock response records, and what a test may |
| 70 | + * assert on it — and the answer is that it already has exactly one record: |
| 71 | + * `res.status.mock.calls` IS the status record, `res.json.mock.calls` IS the |
| 72 | + * body record, and both are what the existing assertions read |
| 73 | + * (`expect(res.json).toHaveBeenCalledWith(...)`, |
| 74 | + * `res.json.mock.calls.at(-1)![0]`). A mirrored `statusCode` / `body` pair |
| 75 | + * would be a SECOND, derived record of the same call, and the two can disagree: |
| 76 | + * a mirror keeps only the last status where `mock.calls` keeps every one, and |
| 77 | + * `mockClear()` empties one and not the other. One record, not two. |
| 78 | + * |
| 79 | + * ⚠️ Three other files in this package — `analytics-dataset-dimension-gate`, |
| 80 | + * `meta-public-book-grant`, `rest-batch-size-cap` — assert on a mirrored |
| 81 | + * `res.statusCode` / `res.body` built by a local `makeRes()` typed `any`. They |
| 82 | + * are green for the forbidden reason rather than conforming, but they cannot |
| 83 | + * adopt this builder by substitution: converting those reads into spy reads |
| 84 | + * CHANGES the assertion, so it is a decision of its own rather than a mechanical |
| 85 | + * edit. Recorded here so the omission is not read as an oversight. |
| 86 | + * |
| 87 | + * **It takes no parameters.** `httpRequestForRoute` needs them because a |
| 88 | + * request carries fixture DATA that differs per test. A response double carries |
| 89 | + * none — it is a pure recorder — so there is nothing per-site to state, and an |
| 90 | + * options bag would be surface with no caller. |
| 91 | + */ |
| 92 | + |
| 93 | +import { vi, type Mock } from 'vitest'; |
| 94 | +import type { RouteHandler } from '@objectstack/core'; |
| 95 | + |
| 96 | +/** |
| 97 | + * The response type a handler is actually handed, read off the handler's own |
| 98 | + * signature instead of spelled by hand — the discipline `xlsx-test-loader.ts` |
| 99 | + * applies to its dependency and `http-request-test-builder.ts` applies to the |
| 100 | + * request half. It resolves to `IHttpResponse`. |
| 101 | + */ |
| 102 | +type HandlerResponse = Parameters<RouteHandler>[1]; |
| 103 | + |
| 104 | +/** Any callable — `Mock<T>` demands `T` be one, and this states it locally. */ |
| 105 | +type AnyProcedure = (...args: any[]) => any; |
| 106 | + |
| 107 | +/** |
| 108 | + * The members the contract REQUIRES, computed from the contract rather than |
| 109 | + * listed. A required member added to `IHttpResponse` therefore fails HERE, |
| 110 | + * loudly, in one file — the double below stops satisfying its own type — rather |
| 111 | + * than leaving a helper that still compiles and lies. |
| 112 | + */ |
| 113 | +type RequiredResponseMember = { |
| 114 | + [K in keyof HandlerResponse]-?: undefined extends HandlerResponse[K] ? never : K; |
| 115 | +}[keyof HandlerResponse]; |
| 116 | + |
| 117 | +/** |
| 118 | + * A complete `IHttpResponse` whose required members are vitest spies, so a test |
| 119 | + * can pass it to a handler AND assert on what the handler did with it. The |
| 120 | + * optional `write` / `end` stay optional and absent — see the header. |
| 121 | + */ |
| 122 | +export type HttpResponseTestDouble = HandlerResponse & { |
| 123 | + [K in RequiredResponseMember]: Mock<Extract<HandlerResponse[K], AnyProcedure>>; |
| 124 | +}; |
| 125 | + |
| 126 | +/** |
| 127 | + * Build a conforming response double for a handler under test. |
| 128 | + * |
| 129 | + * @returns a response satisfying every required member of `IHttpResponse`, |
| 130 | + * recording each call on the corresponding spy, with `status` and |
| 131 | + * `header` returning the same double so handler chains land on the |
| 132 | + * spies the test reads. |
| 133 | + */ |
| 134 | +export function httpResponseTestDouble(): HttpResponseTestDouble { |
| 135 | + const double: HttpResponseTestDouble = { |
| 136 | + json: vi.fn<HandlerResponse['json']>(), |
| 137 | + send: vi.fn<HandlerResponse['send']>(), |
| 138 | + status: vi.fn<HandlerResponse['status']>(() => double), |
| 139 | + header: vi.fn<HandlerResponse['header']>(() => double), |
| 140 | + }; |
| 141 | + return double; |
| 142 | +} |
0 commit comments