@luckystack/test-runner 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -14
- package/dist/index.js.map +1 -1
- package/docs/auth-tests.md +226 -226
- package/docs/contract-tests.md +259 -259
- package/docs/extension-hooks.md +438 -438
- package/docs/fuzz-tests.md +249 -249
- package/docs/rate-limit-tests.md +256 -256
- package/package.json +3 -3
package/docs/contract-tests.md
CHANGED
|
@@ -1,259 +1,259 @@
|
|
|
1
|
-
# Contract Tests
|
|
2
|
-
|
|
3
|
-
The contract layer is the entry point of `@luckystack/test-runner`. It walks every endpoint that the devkit emits into `apiMethodMap.generated.ts`, fires a single well-formed request, and asserts the response conforms to the framework envelope. It is the cheapest and broadest of the four layers — it does NOT prove business logic, it proves the route exists, decodes input without crashing, and replies with `{ status: 'success' | 'error', errorCode? }`.
|
|
4
|
-
|
|
5
|
-
## When to use it
|
|
6
|
-
|
|
7
|
-
- CI on every PR: catches `_api/<page>/<name>_v<n>.ts` files that have a typo in `main`, a broken import, or a renamed Zod field.
|
|
8
|
-
- After regenerating the type map (`npx @luckystack/devkit gen`): confirms the routing table the runtime resolves still matches the file system.
|
|
9
|
-
- During refactors of `@luckystack/api` request handling: contract failures here are a fast signal that the envelope shape regressed.
|
|
10
|
-
|
|
11
|
-
## Functions
|
|
12
|
-
|
|
13
|
-
### `walkEndpoints(apiMethodMap)`
|
|
14
|
-
|
|
15
|
-
Flatten the three-level generated map (`page -> name -> version -> method`) into a flat list of endpoint descriptors. Used internally by every `run*Tests` sweep and exported so consumers can build custom layers on top of the same iteration order.
|
|
16
|
-
|
|
17
|
-
Signature:
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
walkEndpoints(apiMethodMap: ApiMethodMap): EndpointDescriptor[]
|
|
21
|
-
|
|
22
|
-
type ApiMethodMap = Record<string, Record<string, Record<string, string>>>;
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
The order is deterministic: it follows `Object.entries` over the generated map, which the devkit emits sorted by page name, then api name, then version. Empty maps return `[]` (not an error).
|
|
26
|
-
|
|
27
|
-
### `runContractCheck(input)`
|
|
28
|
-
|
|
29
|
-
Run the contract assertion against one endpoint.
|
|
30
|
-
|
|
31
|
-
Signature:
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
runContractCheck(input: ContractCheckInput): Promise<ContractCheckResult>
|
|
35
|
-
|
|
36
|
-
interface ContractCheckInput {
|
|
37
|
-
endpoint: EndpointDescriptor;
|
|
38
|
-
baseUrl: string;
|
|
39
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
40
|
-
headers?: Record<string, string>;
|
|
41
|
-
requestTimeoutMs?: number; // default 5000
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Flow:
|
|
46
|
-
|
|
47
|
-
1. Build URL: `${baseUrl.replace(/\/$/, '')}/${endpoint.fullPath}` so trailing slashes on `baseUrl` are tolerated.
|
|
48
|
-
2. Build body: `inputFor?.(endpoint) ?? {}`. For `GET` the body is dropped entirely; otherwise it is `JSON.stringify`d.
|
|
49
|
-
3. Send the request with `AbortController` wired to `requestTimeoutMs`. A timeout surfaces as a `fetch` error rather than hanging the sweep.
|
|
50
|
-
4. Parse the JSON body. Any parse error is a contract failure (the framework always returns JSON).
|
|
51
|
-
5. Assert `parsed.status` is exactly `'success'` or `'error'`. Anything else (`null`, `undefined`, missing key, a custom shape) fails with `reason: 'Response missing standard `status` envelope'`.
|
|
52
|
-
6. If `parsed.status === 'error'`, assert `parsed.errorCode` is truthy. Missing `errorCode` on an error response is a contract violation — every framework error path is required to set one.
|
|
53
|
-
|
|
54
|
-
Pass criterion: a response that round-trips through the framework envelope, regardless of whether it's success or a typed error.
|
|
55
|
-
|
|
56
|
-
### `runContractTests(input)`
|
|
57
|
-
|
|
58
|
-
Full sweep version of `runContractCheck`. Iterates `walkEndpoints(input.apiMethodMap)` serially and aggregates results.
|
|
59
|
-
|
|
60
|
-
Signature:
|
|
61
|
-
|
|
62
|
-
```ts
|
|
63
|
-
runContractTests(input: RunContractTestsInput): Promise<RunContractSummary>
|
|
64
|
-
|
|
65
|
-
interface RunContractTestsInput {
|
|
66
|
-
apiMethodMap: ApiMethodMap;
|
|
67
|
-
baseUrl: string;
|
|
68
|
-
skip?: string[];
|
|
69
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
70
|
-
headers?: Record<string, string>;
|
|
71
|
-
onResult?: (result: ContractCheckResult) => void;
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
Behavior:
|
|
76
|
-
|
|
77
|
-
- **Serial, not parallel.** The runner walks endpoints one by one. This avoids tripping shared rate-limit buckets, keeps log output linear, and stops endpoint-ordering bugs from being masked by races. A pool option may land later; do not assume concurrency.
|
|
78
|
-
- **`skip` matching is two-tier.** `skip: ['billing/getInvoice']` skips every version of that endpoint. `skip: ['billing/getInvoice/v2']` skips only v2. Both forms compose: pass both to skip every version AND keep the specific one explicit.
|
|
79
|
-
- **`onResult` is fired after every endpoint** including skipped ones, so progress reporters update in real time. Throwing inside `onResult` propagates out of the sweep — wrap your own handler in `tryCatch` if you want it isolated.
|
|
80
|
-
- **Skipped results carry `status: 'skipped'` with `reason: 'Explicitly skipped'`** and `durationMs: 0`. They count toward `summary.skipped`, not `passed` or `failed`.
|
|
81
|
-
|
|
82
|
-
Returned summary:
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
interface RunContractSummary {
|
|
86
|
-
total: number; // results.length, including skipped
|
|
87
|
-
passed: number;
|
|
88
|
-
failed: number;
|
|
89
|
-
skipped: number;
|
|
90
|
-
results: ContractCheckResult[];
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### `sampleSchemaInput(schema)`
|
|
95
|
-
|
|
96
|
-
Walk a Zod schema and produce a deterministic minimal-valid value. The contract layer does not call this automatically — consumers use it inside a custom `inputFor` when an endpoint's Zod validator rejects an empty object.
|
|
97
|
-
|
|
98
|
-
Signature:
|
|
99
|
-
|
|
100
|
-
```ts
|
|
101
|
-
sampleSchemaInput(schema: z.ZodTypeAny): unknown
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
Type-to-sample table:
|
|
105
|
-
|
|
106
|
-
| Zod node | Sample |
|
|
107
|
-
| --- | --- |
|
|
108
|
-
| `ZodString` | `'test'` |
|
|
109
|
-
| `ZodNumber` | `0` |
|
|
110
|
-
| `ZodBoolean` | `false` |
|
|
111
|
-
| `ZodNull` | `null` |
|
|
112
|
-
| `ZodUndefined` | `undefined` |
|
|
113
|
-
| `ZodAny` / `ZodUnknown` | `null` |
|
|
114
|
-
| `ZodLiteral` | the literal value |
|
|
115
|
-
| `ZodOptional` / `ZodNullable` | `undefined` (field omitted in objects) |
|
|
116
|
-
| `ZodUnion` | first option, recursively sampled |
|
|
117
|
-
| `ZodArray` | `[]` |
|
|
118
|
-
| `ZodObject` | object with every non-`undefined` child sampled |
|
|
119
|
-
| `ZodRecord` | `{}` |
|
|
120
|
-
| `ZodDate` | `new Date().toISOString()` |
|
|
121
|
-
| anything else | `null` |
|
|
122
|
-
|
|
123
|
-
`ZodOptional`/`ZodNullable` returning `undefined` is deliberate — the object branch drops keys whose sample is `undefined`, so optional fields stay absent rather than being sent as `null`. The result is the smallest payload that the validator will accept.
|
|
124
|
-
|
|
125
|
-
This is NOT a property-based generator. It always returns the same value for the same schema. Swap in `fast-check` if you need randomized fuzz that respects the schema.
|
|
126
|
-
|
|
127
|
-
## Types
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
131
|
-
|
|
132
|
-
interface EndpointDescriptor {
|
|
133
|
-
page: string;
|
|
134
|
-
name: string;
|
|
135
|
-
version: string;
|
|
136
|
-
method: HttpMethod;
|
|
137
|
-
fullPath: string; // `api/<page>/<name>/<version>`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
interface ContractCheckResult {
|
|
141
|
-
endpoint: EndpointDescriptor;
|
|
142
|
-
status: 'pass' | 'fail' | 'skipped';
|
|
143
|
-
httpStatus?: number;
|
|
144
|
-
responseStatus?: 'success' | 'error' | 'unknown';
|
|
145
|
-
errorCode?: string;
|
|
146
|
-
reason?: string;
|
|
147
|
-
durationMs: number;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
interface RunContractSummary {
|
|
151
|
-
total: number;
|
|
152
|
-
passed: number;
|
|
153
|
-
failed: number;
|
|
154
|
-
skipped: number;
|
|
155
|
-
results: ContractCheckResult[];
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
`responseStatus: 'unknown'` only appears on failed results when the JSON either failed to parse or did not carry a `status` field. `errorCode` is set on pass results when `responseStatus === 'error'` and on fail results when the auth/rate-limit layers' `errorCode` mismatch path runs (contract layer never sets it on fail).
|
|
160
|
-
|
|
161
|
-
## Result shapes by branch
|
|
162
|
-
|
|
163
|
-
- **Pass / success**: `{ status: 'pass', httpStatus, responseStatus: 'success', errorCode: undefined, durationMs }`.
|
|
164
|
-
- **Pass / typed error**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: '<code>', durationMs }`. A typed framework error still satisfies the contract.
|
|
165
|
-
- **Fail / fetch error**: `{ status: 'fail', reason: <Error.message>, durationMs }`. No `httpStatus`. Network refused, DNS failure, or `AbortController` firing all land here.
|
|
166
|
-
- **Fail / JSON parse**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'JSON parse failed: ...', durationMs }`.
|
|
167
|
-
- **Fail / missing envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
|
|
168
|
-
- **Fail / error without code**: `{ status: 'fail', httpStatus, responseStatus: 'error', reason: 'Error response missing `errorCode`', durationMs }`.
|
|
169
|
-
- **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
170
|
-
|
|
171
|
-
## Examples
|
|
172
|
-
|
|
173
|
-
### CI sweep with default inputs
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
177
|
-
import {
|
|
178
|
-
runContractTests,
|
|
179
|
-
logContractResult,
|
|
180
|
-
logContractSummary,
|
|
181
|
-
} from '@luckystack/test-runner';
|
|
182
|
-
|
|
183
|
-
const summary = await runContractTests({
|
|
184
|
-
apiMethodMap,
|
|
185
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
186
|
-
onResult: logContractResult,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
logContractSummary(summary);
|
|
190
|
-
|
|
191
|
-
if (summary.failed > 0) {
|
|
192
|
-
process.exit(1);
|
|
193
|
-
}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
### Endpoint-specific input via `inputFor`
|
|
197
|
-
|
|
198
|
-
Most validators reject `{}`. Use `inputFor` to return a shape the validator will accept for the specific endpoints that need it; fall through to `{}` for everything else.
|
|
199
|
-
|
|
200
|
-
```ts
|
|
201
|
-
import { sampleSchemaInput } from '@luckystack/test-runner';
|
|
202
|
-
import { sendInviteSchema } from 'src/organization-settings/_api/sendInvite_v1';
|
|
203
|
-
|
|
204
|
-
await runContractTests({
|
|
205
|
-
apiMethodMap,
|
|
206
|
-
baseUrl,
|
|
207
|
-
inputFor: (endpoint) => {
|
|
208
|
-
if (endpoint.fullPath === 'api/organization-settings/sendInvite/v1') {
|
|
209
|
-
return sampleSchemaInput(sendInviteSchema);
|
|
210
|
-
}
|
|
211
|
-
return {}; // default
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### Single-endpoint debug loop
|
|
217
|
-
|
|
218
|
-
```ts
|
|
219
|
-
import { runContractCheck, logContractResult } from '@luckystack/test-runner';
|
|
220
|
-
|
|
221
|
-
const result = await runContractCheck({
|
|
222
|
-
endpoint: {
|
|
223
|
-
page: 'billing',
|
|
224
|
-
name: 'getInvoice',
|
|
225
|
-
version: 'v1',
|
|
226
|
-
method: 'POST',
|
|
227
|
-
fullPath: 'api/billing/getInvoice/v1',
|
|
228
|
-
},
|
|
229
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
230
|
-
inputFor: () => ({ invoiceId: 'inv_1' }),
|
|
231
|
-
headers: { Cookie: 'session=abc' },
|
|
232
|
-
requestTimeoutMs: 15000, // bump for slow endpoints
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
logContractResult(result);
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
### Skip noisy endpoints
|
|
239
|
-
|
|
240
|
-
```ts
|
|
241
|
-
await runContractTests({
|
|
242
|
-
apiMethodMap,
|
|
243
|
-
baseUrl,
|
|
244
|
-
skip: [
|
|
245
|
-
'uploads/fileUpload', // needs multipart, not JSON
|
|
246
|
-
'billing/getInvoice/v1', // deprecated, kept for backwards compat
|
|
247
|
-
],
|
|
248
|
-
});
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
## Edge cases and gotchas
|
|
252
|
-
|
|
253
|
-
- **`GET` endpoints ignore `inputFor`.** The handler still calls `inputFor`, but the body is dropped before `fetch` sees it. Query-string fuzz is not in scope for the contract layer.
|
|
254
|
-
- **`headers` are merged after `Content-Type: application/json`.** If you pass `Content-Type` yourself you will override the default; the framework parses based on the header so this is intentional.
|
|
255
|
-
- **`baseUrl` trailing slash is normalized once.** `http://localhost/` and `http://localhost` produce identical requests. Embedded `//` further down the path is not collapsed.
|
|
256
|
-
- **`requestTimeoutMs` is per-request, not per-sweep.** A 30-endpoint sweep with the default 5000ms timeout can run for over two minutes if every endpoint stalls. The sweep itself has no overall timeout.
|
|
257
|
-
- **An endpoint that returns `204 No Content`** fails the contract because there is no JSON to parse. The framework never emits 204 from `_api/*`; this only happens if you've added a custom route handler that intercepts the path.
|
|
258
|
-
- **A response with `status: 'success'` and an extra `errorCode`** still passes. The contract layer only validates the envelope keys it cares about; unknown extras are ignored.
|
|
259
|
-
- **Endpoints absent from `apiMetaMap` are still walked.** The contract layer never reads `apiMetaMap`. Auth and rate-limit layers do — see those docs.
|
|
1
|
+
# Contract Tests
|
|
2
|
+
|
|
3
|
+
The contract layer is the entry point of `@luckystack/test-runner`. It walks every endpoint that the devkit emits into `apiMethodMap.generated.ts`, fires a single well-formed request, and asserts the response conforms to the framework envelope. It is the cheapest and broadest of the four layers — it does NOT prove business logic, it proves the route exists, decodes input without crashing, and replies with `{ status: 'success' | 'error', errorCode? }`.
|
|
4
|
+
|
|
5
|
+
## When to use it
|
|
6
|
+
|
|
7
|
+
- CI on every PR: catches `_api/<page>/<name>_v<n>.ts` files that have a typo in `main`, a broken import, or a renamed Zod field.
|
|
8
|
+
- After regenerating the type map (`npx @luckystack/devkit gen`): confirms the routing table the runtime resolves still matches the file system.
|
|
9
|
+
- During refactors of `@luckystack/api` request handling: contract failures here are a fast signal that the envelope shape regressed.
|
|
10
|
+
|
|
11
|
+
## Functions
|
|
12
|
+
|
|
13
|
+
### `walkEndpoints(apiMethodMap)`
|
|
14
|
+
|
|
15
|
+
Flatten the three-level generated map (`page -> name -> version -> method`) into a flat list of endpoint descriptors. Used internally by every `run*Tests` sweep and exported so consumers can build custom layers on top of the same iteration order.
|
|
16
|
+
|
|
17
|
+
Signature:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
walkEndpoints(apiMethodMap: ApiMethodMap): EndpointDescriptor[]
|
|
21
|
+
|
|
22
|
+
type ApiMethodMap = Record<string, Record<string, Record<string, string>>>;
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The order is deterministic: it follows `Object.entries` over the generated map, which the devkit emits sorted by page name, then api name, then version. Empty maps return `[]` (not an error).
|
|
26
|
+
|
|
27
|
+
### `runContractCheck(input)`
|
|
28
|
+
|
|
29
|
+
Run the contract assertion against one endpoint.
|
|
30
|
+
|
|
31
|
+
Signature:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
runContractCheck(input: ContractCheckInput): Promise<ContractCheckResult>
|
|
35
|
+
|
|
36
|
+
interface ContractCheckInput {
|
|
37
|
+
endpoint: EndpointDescriptor;
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
requestTimeoutMs?: number; // default 5000
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Flow:
|
|
46
|
+
|
|
47
|
+
1. Build URL: `${baseUrl.replace(/\/$/, '')}/${endpoint.fullPath}` so trailing slashes on `baseUrl` are tolerated.
|
|
48
|
+
2. Build body: `inputFor?.(endpoint) ?? {}`. For `GET` the body is dropped entirely; otherwise it is `JSON.stringify`d.
|
|
49
|
+
3. Send the request with `AbortController` wired to `requestTimeoutMs`. A timeout surfaces as a `fetch` error rather than hanging the sweep.
|
|
50
|
+
4. Parse the JSON body. Any parse error is a contract failure (the framework always returns JSON).
|
|
51
|
+
5. Assert `parsed.status` is exactly `'success'` or `'error'`. Anything else (`null`, `undefined`, missing key, a custom shape) fails with `reason: 'Response missing standard `status` envelope'`.
|
|
52
|
+
6. If `parsed.status === 'error'`, assert `parsed.errorCode` is truthy. Missing `errorCode` on an error response is a contract violation — every framework error path is required to set one.
|
|
53
|
+
|
|
54
|
+
Pass criterion: a response that round-trips through the framework envelope, regardless of whether it's success or a typed error.
|
|
55
|
+
|
|
56
|
+
### `runContractTests(input)`
|
|
57
|
+
|
|
58
|
+
Full sweep version of `runContractCheck`. Iterates `walkEndpoints(input.apiMethodMap)` serially and aggregates results.
|
|
59
|
+
|
|
60
|
+
Signature:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
runContractTests(input: RunContractTestsInput): Promise<RunContractSummary>
|
|
64
|
+
|
|
65
|
+
interface RunContractTestsInput {
|
|
66
|
+
apiMethodMap: ApiMethodMap;
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
skip?: string[];
|
|
69
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
70
|
+
headers?: Record<string, string>;
|
|
71
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Behavior:
|
|
76
|
+
|
|
77
|
+
- **Serial, not parallel.** The runner walks endpoints one by one. This avoids tripping shared rate-limit buckets, keeps log output linear, and stops endpoint-ordering bugs from being masked by races. A pool option may land later; do not assume concurrency.
|
|
78
|
+
- **`skip` matching is two-tier.** `skip: ['billing/getInvoice']` skips every version of that endpoint. `skip: ['billing/getInvoice/v2']` skips only v2. Both forms compose: pass both to skip every version AND keep the specific one explicit.
|
|
79
|
+
- **`onResult` is fired after every endpoint** including skipped ones, so progress reporters update in real time. Throwing inside `onResult` propagates out of the sweep — wrap your own handler in `tryCatch` if you want it isolated.
|
|
80
|
+
- **Skipped results carry `status: 'skipped'` with `reason: 'Explicitly skipped'`** and `durationMs: 0`. They count toward `summary.skipped`, not `passed` or `failed`.
|
|
81
|
+
|
|
82
|
+
Returned summary:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
interface RunContractSummary {
|
|
86
|
+
total: number; // results.length, including skipped
|
|
87
|
+
passed: number;
|
|
88
|
+
failed: number;
|
|
89
|
+
skipped: number;
|
|
90
|
+
results: ContractCheckResult[];
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `sampleSchemaInput(schema)`
|
|
95
|
+
|
|
96
|
+
Walk a Zod schema and produce a deterministic minimal-valid value. The contract layer does not call this automatically — consumers use it inside a custom `inputFor` when an endpoint's Zod validator rejects an empty object.
|
|
97
|
+
|
|
98
|
+
Signature:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
sampleSchemaInput(schema: z.ZodTypeAny): unknown
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Type-to-sample table:
|
|
105
|
+
|
|
106
|
+
| Zod node | Sample |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `ZodString` | `'test'` |
|
|
109
|
+
| `ZodNumber` | `0` |
|
|
110
|
+
| `ZodBoolean` | `false` |
|
|
111
|
+
| `ZodNull` | `null` |
|
|
112
|
+
| `ZodUndefined` | `undefined` |
|
|
113
|
+
| `ZodAny` / `ZodUnknown` | `null` |
|
|
114
|
+
| `ZodLiteral` | the literal value |
|
|
115
|
+
| `ZodOptional` / `ZodNullable` | `undefined` (field omitted in objects) |
|
|
116
|
+
| `ZodUnion` | first option, recursively sampled |
|
|
117
|
+
| `ZodArray` | `[]` |
|
|
118
|
+
| `ZodObject` | object with every non-`undefined` child sampled |
|
|
119
|
+
| `ZodRecord` | `{}` |
|
|
120
|
+
| `ZodDate` | `new Date().toISOString()` |
|
|
121
|
+
| anything else | `null` |
|
|
122
|
+
|
|
123
|
+
`ZodOptional`/`ZodNullable` returning `undefined` is deliberate — the object branch drops keys whose sample is `undefined`, so optional fields stay absent rather than being sent as `null`. The result is the smallest payload that the validator will accept.
|
|
124
|
+
|
|
125
|
+
This is NOT a property-based generator. It always returns the same value for the same schema. Swap in `fast-check` if you need randomized fuzz that respects the schema.
|
|
126
|
+
|
|
127
|
+
## Types
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
131
|
+
|
|
132
|
+
interface EndpointDescriptor {
|
|
133
|
+
page: string;
|
|
134
|
+
name: string;
|
|
135
|
+
version: string;
|
|
136
|
+
method: HttpMethod;
|
|
137
|
+
fullPath: string; // `api/<page>/<name>/<version>`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface ContractCheckResult {
|
|
141
|
+
endpoint: EndpointDescriptor;
|
|
142
|
+
status: 'pass' | 'fail' | 'skipped';
|
|
143
|
+
httpStatus?: number;
|
|
144
|
+
responseStatus?: 'success' | 'error' | 'unknown';
|
|
145
|
+
errorCode?: string;
|
|
146
|
+
reason?: string;
|
|
147
|
+
durationMs: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface RunContractSummary {
|
|
151
|
+
total: number;
|
|
152
|
+
passed: number;
|
|
153
|
+
failed: number;
|
|
154
|
+
skipped: number;
|
|
155
|
+
results: ContractCheckResult[];
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`responseStatus: 'unknown'` only appears on failed results when the JSON either failed to parse or did not carry a `status` field. `errorCode` is set on pass results when `responseStatus === 'error'` and on fail results when the auth/rate-limit layers' `errorCode` mismatch path runs (contract layer never sets it on fail).
|
|
160
|
+
|
|
161
|
+
## Result shapes by branch
|
|
162
|
+
|
|
163
|
+
- **Pass / success**: `{ status: 'pass', httpStatus, responseStatus: 'success', errorCode: undefined, durationMs }`.
|
|
164
|
+
- **Pass / typed error**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: '<code>', durationMs }`. A typed framework error still satisfies the contract.
|
|
165
|
+
- **Fail / fetch error**: `{ status: 'fail', reason: <Error.message>, durationMs }`. No `httpStatus`. Network refused, DNS failure, or `AbortController` firing all land here.
|
|
166
|
+
- **Fail / JSON parse**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'JSON parse failed: ...', durationMs }`.
|
|
167
|
+
- **Fail / missing envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
|
|
168
|
+
- **Fail / error without code**: `{ status: 'fail', httpStatus, responseStatus: 'error', reason: 'Error response missing `errorCode`', durationMs }`.
|
|
169
|
+
- **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
170
|
+
|
|
171
|
+
## Examples
|
|
172
|
+
|
|
173
|
+
### CI sweep with default inputs
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
177
|
+
import {
|
|
178
|
+
runContractTests,
|
|
179
|
+
logContractResult,
|
|
180
|
+
logContractSummary,
|
|
181
|
+
} from '@luckystack/test-runner';
|
|
182
|
+
|
|
183
|
+
const summary = await runContractTests({
|
|
184
|
+
apiMethodMap,
|
|
185
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
186
|
+
onResult: logContractResult,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
logContractSummary(summary);
|
|
190
|
+
|
|
191
|
+
if (summary.failed > 0) {
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Endpoint-specific input via `inputFor`
|
|
197
|
+
|
|
198
|
+
Most validators reject `{}`. Use `inputFor` to return a shape the validator will accept for the specific endpoints that need it; fall through to `{}` for everything else.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { sampleSchemaInput } from '@luckystack/test-runner';
|
|
202
|
+
import { sendInviteSchema } from 'src/organization-settings/_api/sendInvite_v1';
|
|
203
|
+
|
|
204
|
+
await runContractTests({
|
|
205
|
+
apiMethodMap,
|
|
206
|
+
baseUrl,
|
|
207
|
+
inputFor: (endpoint) => {
|
|
208
|
+
if (endpoint.fullPath === 'api/organization-settings/sendInvite/v1') {
|
|
209
|
+
return sampleSchemaInput(sendInviteSchema);
|
|
210
|
+
}
|
|
211
|
+
return {}; // default
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Single-endpoint debug loop
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { runContractCheck, logContractResult } from '@luckystack/test-runner';
|
|
220
|
+
|
|
221
|
+
const result = await runContractCheck({
|
|
222
|
+
endpoint: {
|
|
223
|
+
page: 'billing',
|
|
224
|
+
name: 'getInvoice',
|
|
225
|
+
version: 'v1',
|
|
226
|
+
method: 'POST',
|
|
227
|
+
fullPath: 'api/billing/getInvoice/v1',
|
|
228
|
+
},
|
|
229
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
230
|
+
inputFor: () => ({ invoiceId: 'inv_1' }),
|
|
231
|
+
headers: { Cookie: 'session=abc' },
|
|
232
|
+
requestTimeoutMs: 15000, // bump for slow endpoints
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
logContractResult(result);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Skip noisy endpoints
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
await runContractTests({
|
|
242
|
+
apiMethodMap,
|
|
243
|
+
baseUrl,
|
|
244
|
+
skip: [
|
|
245
|
+
'uploads/fileUpload', // needs multipart, not JSON
|
|
246
|
+
'billing/getInvoice/v1', // deprecated, kept for backwards compat
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Edge cases and gotchas
|
|
252
|
+
|
|
253
|
+
- **`GET` endpoints ignore `inputFor`.** The handler still calls `inputFor`, but the body is dropped before `fetch` sees it. Query-string fuzz is not in scope for the contract layer.
|
|
254
|
+
- **`headers` are merged after `Content-Type: application/json`.** If you pass `Content-Type` yourself you will override the default; the framework parses based on the header so this is intentional.
|
|
255
|
+
- **`baseUrl` trailing slash is normalized once.** `http://localhost/` and `http://localhost` produce identical requests. Embedded `//` further down the path is not collapsed.
|
|
256
|
+
- **`requestTimeoutMs` is per-request, not per-sweep.** A 30-endpoint sweep with the default 5000ms timeout can run for over two minutes if every endpoint stalls. The sweep itself has no overall timeout.
|
|
257
|
+
- **An endpoint that returns `204 No Content`** fails the contract because there is no JSON to parse. The framework never emits 204 from `_api/*`; this only happens if you've added a custom route handler that intercepts the path.
|
|
258
|
+
- **A response with `status: 'success'` and an extra `errorCode`** still passes. The contract layer only validates the envelope keys it cares about; unknown extras are ignored.
|
|
259
|
+
- **Endpoints absent from `apiMetaMap` are still walked.** The contract layer never reads `apiMetaMap`. Auth and rate-limit layers do — see those docs.
|