@klondikemarlen/expect-http-response 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marlen Brunner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,194 @@
1
- # Temporary Holding Version
1
+ # @klondikemarlen/expect-http-response
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One `expect` can describe an HTTP response contract: its status and complete body. This dependency-free ESM matcher works with Jest's `expect` and Vitest. Register it explicitly with your existing test runner.
4
+
5
+ ## Installation
6
+
7
+ Install a published release as a development dependency:
8
+
9
+ ```sh
10
+ npm install --save-dev @klondikemarlen/expect-http-response
11
+ ```
12
+
13
+ Alternatively, install directly from this focused repository:
14
+
15
+ ```sh
16
+ npm install --save-dev github:klondikemarlen/expect-http-response
17
+ ```
18
+
19
+ Use an ESM project (`"type": "module"`) or `.mjs` setup/config files. The package has no runtime dependencies and exports `toHaveHttpResponse` from its root only. Consumers supply their own test runner and explicitly register the matcher; registration is not automatic.
20
+
21
+ ## Registering the Matcher
22
+
23
+ Register once in the setup module for the runner you use. The examples use the runner's own `expect` instance.
24
+
25
+ ### Jest
26
+
27
+ Jest setup files run after Jest initializes its assertion environment:
28
+
29
+ ```js
30
+ // test/setup-http-response-matcher.mjs
31
+ import { expect } from "@jest/globals"
32
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
33
+
34
+ expect.extend({ toHaveHttpResponse })
35
+ ```
36
+
37
+ ```js
38
+ // jest.config.mjs
39
+ export default {
40
+ setupFilesAfterEnv: ["<rootDir>/test/setup-http-response-matcher.mjs"],
41
+ }
42
+ ```
43
+
44
+ For Jest's native ESM mode, invoke the existing Jest command with Node's VM-module support, for example:
45
+
46
+ ```sh
47
+ node --experimental-vm-modules node_modules/jest/bin/jest.js
48
+ ```
49
+
50
+ Preserve the project's existing transforms and test command when integrating the setup.
51
+
52
+ ### Vitest
53
+
54
+ ```js
55
+ // test/setup-http-response-matcher.mjs
56
+ import { expect } from "vitest"
57
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
58
+
59
+ expect.extend({ toHaveHttpResponse })
60
+ ```
61
+
62
+ ```js
63
+ // vitest.config.mjs
64
+ import { defineConfig } from "vitest/config"
65
+
66
+ export default defineConfig({
67
+ test: {
68
+ setupFiles: ["./test/setup-http-response-matcher.mjs"],
69
+ },
70
+ })
71
+ ```
72
+
73
+ ### Other Compatible Runners
74
+
75
+ Import the matcher from the package root and extend the active runner's `expect` instance explicitly. The matcher relies on the runner's equality function and custom matcher context; it does not bundle or select a runner.
76
+
77
+ ## TypeScript
78
+
79
+ The package root resolves to `index.d.mts`. Add a declaration augmentation for each runner you actually install. For Jest's `expect` package:
80
+
81
+ ```ts
82
+ // test/http-response-matchers.d.ts
83
+ import "expect"
84
+
85
+ declare module "expect" {
86
+ interface Matchers<R extends void | Promise<void>, T = unknown> {
87
+ toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
88
+ }
89
+ }
90
+ ```
91
+
92
+ For Vitest:
93
+
94
+ ```ts
95
+ // test/http-response-matchers.d.ts
96
+ import "vitest"
97
+
98
+ declare module "vitest" {
99
+ interface Matchers<R, T> {
100
+ toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
101
+ }
102
+ }
103
+ ```
104
+
105
+ Ensure TypeScript includes the augmentation as well as the tests (merge these entries into the existing `include` paths rather than replacing source entries):
106
+
107
+ ```json
108
+ {
109
+ "include": ["src/**/*.ts", "test/**/*.ts", "test/http-response-matchers.d.ts"]
110
+ }
111
+ ```
112
+
113
+ The TypeScript setup module must also be included if it is written in TypeScript. Explicitly importing `expect` from `vitest` or `@jest/globals` does not require runner globals in `compilerOptions.types`. Do not add an augmentation for a runner that is not installed.
114
+
115
+ For a Node.js project, keep `@types/node` installed and include `"node"` in `compilerOptions.types` if the project's configuration does not already load it. Node types are separate from Jest/Vitest globals.
116
+
117
+ ## Example: Express and Supertest
118
+
119
+ This makes a real request to an Express route. The single assertion is the response contract; it makes no separate claim about persistence or other effects.
120
+
121
+ ```ts
122
+ import express from "express"
123
+ import { expect, test } from "vitest"
124
+ import request from "supertest"
125
+ import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
126
+
127
+ expect.extend({ toHaveHttpResponse })
128
+
129
+ const app = express()
130
+ app.get("/users/:id", (req, res) => {
131
+ res.status(200).json({
132
+ user: { id: Number(req.params.id), name: "Ada" },
133
+ })
134
+ })
135
+
136
+ test("when the user exists, returns the complete user response", async () => {
137
+ const response = await request(app).get("/users/7")
138
+
139
+ expect(response).toHaveHttpResponse(200, {
140
+ user: { id: 7, name: "Ada" },
141
+ })
142
+ })
143
+ ```
144
+
145
+ ## Body Equality, Negation, and Invalid Arguments
146
+
147
+ `toHaveHttpResponse(status, body)` passes only when the numeric response `status` and complete `body` both match. Body comparison uses the active runner's equality function and custom equality testers, plus iterable equality when the runner exposes it, consistent with `toEqual`. Transport fields such as request, socket, or response internals are not inspected.
148
+
149
+ - Body equality is complete: unexpected body keys fail, just as they do with `toEqual`.
150
+ - Asymmetric values such as `expect.objectContaining(...)` deliberately request a partial body match.
151
+ - `.not` negates the combined status-and-body contract. It passes if either status or body differs and fails only when both match.
152
+ - The expected status and received `response.status` must be finite numbers. An Express `res.status()` method is not a sent response status.
153
+ - Invalid inputs throw even through `.not`; they are not treated as a successful negative assertion.
154
+ - Supply the body argument explicitly. Pass `undefined` when the expected response body is undefined.
155
+
156
+ ## Package Development
157
+
158
+ The package's focused test suite includes matcher unit tests using Jest's `expect`, a real Express/Supertest integration test using Vitest, and strict TypeScript registration checks for both runners.
159
+
160
+ ```sh
161
+ npm ci
162
+ npm test
163
+ npm run test:unit
164
+ npm run test:integration
165
+ npm run typecheck
166
+ ```
167
+
168
+ ## Release
169
+
170
+ From the repository's merged `main` branch, verify the package before tagging and publishing. Release publication is not complete until the registry artifact is installed and exercised:
171
+
172
+ ```sh
173
+ npm test
174
+ npm pack --dry-run
175
+ git switch main
176
+ git pull --ff-only origin main
177
+ git tag v1.0.0
178
+ git push origin v1.0.0
179
+ gh release create v1.0.0 --title v1.0.0 --generate-notes
180
+ npm publish --access public
181
+ npm view @klondikemarlen/expect-http-response@1.0.0 version
182
+ ```
183
+
184
+ In a separate consumer project, install the registry artifact:
185
+
186
+ ```sh
187
+ npm install --save-dev @klondikemarlen/expect-http-response@1.0.0
188
+ ```
189
+
190
+ After publishing, use the actual registry installation in a fresh consumer project and run its matcher and TypeScript registration checks. Do not claim npm availability until the publish and registry install have been observed.
191
+
192
+ ## License
193
+
194
+ MIT. See [LICENSE](LICENSE).
package/index.d.mts ADDED
@@ -0,0 +1,44 @@
1
+ type EqualityTester = (this: any, ...values: any[]) => boolean | undefined
2
+
3
+ type EqualityFunction = (
4
+ received: unknown,
5
+ expected: unknown,
6
+ customTesters?: EqualityTester[],
7
+ strictCheck?: boolean
8
+ ) => boolean
9
+
10
+ interface HttpResponseLike {
11
+ readonly status: number
12
+ readonly body?: unknown
13
+ }
14
+
15
+ interface MatcherUtils {
16
+ matcherHint?: (
17
+ matcherName: string,
18
+ received?: string,
19
+ expected?: string,
20
+ options?: { isNot?: boolean }
21
+ ) => string
22
+ printExpected?: (value: unknown) => string
23
+ printReceived?: (value: unknown) => string
24
+ iterableEquality?: EqualityTester
25
+ }
26
+
27
+ interface MatcherContext {
28
+ readonly isNot?: boolean
29
+ readonly customTesters?: EqualityTester[]
30
+ readonly equals: EqualityFunction
31
+ readonly utils?: MatcherUtils
32
+ }
33
+
34
+ interface MatcherResult {
35
+ readonly pass: boolean
36
+ readonly message: () => string
37
+ }
38
+
39
+ export declare function toHaveHttpResponse(
40
+ this: MatcherContext,
41
+ received: HttpResponseLike,
42
+ expectedStatus: number,
43
+ expectedBody: unknown
44
+ ): MatcherResult
package/index.mjs ADDED
@@ -0,0 +1,109 @@
1
+ const MATCHER_NAME = "toHaveHttpResponse"
2
+
3
+ export function toHaveHttpResponse(response, expectedStatus, expectedBody) {
4
+ if (arguments.length < 3) {
5
+ throw new TypeError(
6
+ `${MATCHER_NAME} requires an expected status and body. Pass undefined explicitly for an undefined body.`
7
+ )
8
+ }
9
+
10
+ if (typeof expectedStatus !== "number" || !Number.isFinite(expectedStatus)) {
11
+ throw new TypeError("expectedStatus must be a finite number.")
12
+ }
13
+
14
+ if (typeof response !== "object" || response === null) {
15
+ throw new TypeError("received value must be a non-null HTTP response object.")
16
+ }
17
+
18
+ const receivedStatus = response.status
19
+ if (typeof receivedStatus !== "number" || !Number.isFinite(receivedStatus)) {
20
+ throw new TypeError(
21
+ "received response.status must be a finite number; an Express status() method is not a response status value."
22
+ )
23
+ }
24
+
25
+ if (typeof this?.equals !== "function") {
26
+ throw new TypeError("the test runner must provide an equality function for custom matchers.")
27
+ }
28
+
29
+ const equalityTesters = [...(this.customTesters ?? [])]
30
+ const iterableEquality = this.utils?.iterableEquality
31
+ if (typeof iterableEquality === "function" && !equalityTesters.includes(iterableEquality)) {
32
+ equalityTesters.push(iterableEquality)
33
+ }
34
+
35
+ const receivedBody = response.body
36
+ const statusMatches = receivedStatus === expectedStatus
37
+ const bodyMatches = this.equals(receivedBody, expectedBody, equalityTesters)
38
+ const pass = statusMatches && bodyMatches
39
+
40
+ return {
41
+ pass,
42
+ message: () =>
43
+ formatMessage(this, {
44
+ expectedBody,
45
+ expectedStatus,
46
+ receivedBody,
47
+ receivedStatus,
48
+ statusMatches,
49
+ bodyMatches,
50
+ }),
51
+ }
52
+ }
53
+
54
+ function formatMessage(context, result) {
55
+ const hint = matcherHint(context)
56
+ if (context.isNot) {
57
+ return [
58
+ hint,
59
+ "",
60
+ "Expected the response not to match this HTTP contract:",
61
+ `Expected status: ${printValue(context, "printExpected", result.expectedStatus)}`,
62
+ `Received status: ${printValue(context, "printReceived", result.receivedStatus)}`,
63
+ `Expected body: ${printValue(context, "printExpected", result.expectedBody)}`,
64
+ `Received body: ${printValue(context, "printReceived", result.receivedBody)}`,
65
+ ].join("\n")
66
+ }
67
+
68
+ const lines = [hint]
69
+ if (!result.statusMatches) {
70
+ lines.push(
71
+ "",
72
+ `Status mismatch: expected ${printValue(context, "printExpected", result.expectedStatus)}, received ${printValue(context, "printReceived", result.receivedStatus)}.`
73
+ )
74
+ }
75
+
76
+ if (!result.bodyMatches) {
77
+ lines.push(
78
+ "",
79
+ "Body mismatch:",
80
+ `Expected body: ${printValue(context, "printExpected", result.expectedBody)}`,
81
+ `Received body: ${printValue(context, "printReceived", result.receivedBody)}`
82
+ )
83
+ }
84
+
85
+ return lines.join("\n")
86
+ }
87
+
88
+ function matcherHint(context) {
89
+ if (typeof context.utils?.matcherHint === "function") {
90
+ return context.utils.matcherHint(
91
+ `.${MATCHER_NAME}`,
92
+ undefined,
93
+ "expectedStatus, expectedBody",
94
+ { isNot: context.isNot }
95
+ )
96
+ }
97
+
98
+ const negation = context.isNot ? ".not" : ""
99
+ return `expect(received)${negation}.${MATCHER_NAME}(expectedStatus, expectedBody)`
100
+ }
101
+
102
+ function printValue(context, formatterName, value) {
103
+ const formatter = context.utils?.[formatterName]
104
+ if (typeof formatter === "function") {
105
+ return formatter(value)
106
+ }
107
+
108
+ return String(value)
109
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@klondikemarlen/expect-http-response",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "One-expect HTTP response contracts for Jest and Vitest",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.mts",
10
+ "import": "./index.mjs"
11
+ }
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "index.d.mts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "registry": "https://registry.npmjs.org/"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test tests/to-have-http-response.test.mjs && vitest run && tsc --noEmit -p tsconfig.json",
25
+ "test:unit": "node --test tests/to-have-http-response.test.mjs",
26
+ "test:integration": "vitest run",
27
+ "typecheck": "tsc --noEmit -p tsconfig.json"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/klondikemarlen/expect-http-response.git"
32
+ },
33
+ "homepage": "https://github.com/klondikemarlen/expect-http-response#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/klondikemarlen/expect-http-response/issues"
36
+ },
37
+ "engines": {
38
+ "node": ">=20.19.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/express": "^5.0.6",
42
+ "@types/node": "^26.6.5",
43
+ "@types/supertest": "^7.2.1",
44
+ "expect": "^30.5.2",
45
+ "express": "^5.3.0",
46
+ "supertest": "^7.3.1",
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.4"
49
+ }
50
+ }