From 272034443b2d2988d8579ebbef9b41c4f888e824 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 18:45:36 -0800 Subject: [PATCH 1/9] chore(supertest): scaffold @bupkis/supertest package structure Add package directory structure with package.json, tsconfig.json, and LICENSE.md. This provides the foundation for HTTP response assertions that work with supertest, fetch, and axios responses. --- package-lock.json | 851 +++++++++++++++++++++++++++++++ packages/supertest/LICENSE.md | 55 ++ packages/supertest/package.json | 73 +++ packages/supertest/src/index.ts | 20 + packages/supertest/tsconfig.json | 5 + 5 files changed, 1004 insertions(+) create mode 100644 packages/supertest/LICENSE.md create mode 100644 packages/supertest/package.json create mode 100644 packages/supertest/src/index.ts create mode 100644 packages/supertest/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 46ca2fc0..1acf17ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -628,6 +628,10 @@ "resolved": "packages/sinon", "link": true }, + "node_modules/@bupkis/supertest": { + "resolved": "packages/supertest", + "link": true + }, "node_modules/@commitlint/cli": { "version": "20.2.0", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.2.0.tgz", @@ -3278,6 +3282,19 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3596,6 +3613,16 @@ "win32" ] }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4237,6 +4264,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4365,6 +4399,13 @@ "@types/unist": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -4463,6 +4504,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -5222,6 +5287,47 @@ "dev": true, "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5496,6 +5602,13 @@ "node": ">=0.10.0" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5525,6 +5638,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5766,6 +5886,31 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boxen": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", @@ -6529,6 +6674,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", @@ -6575,6 +6733,16 @@ "dot-prop": "^5.1.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -6641,6 +6809,16 @@ "node": ">= 0.6" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog-angular": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", @@ -6693,6 +6871,33 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7192,6 +7397,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -7226,6 +7451,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -7303,6 +7539,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -7330,6 +7573,16 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7640,6 +7893,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -8250,6 +8510,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -8342,6 +8612,90 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fast-check": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.2.tgz", @@ -8424,6 +8778,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -8513,6 +8874,28 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", @@ -8584,6 +8967,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -8600,6 +9000,44 @@ "node": ">=18.3.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9115,6 +9553,27 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -9141,6 +9600,23 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9285,6 +9761,16 @@ "node": ">= 0.4" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/irregular-plurals": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", @@ -9673,6 +10159,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -12508,6 +13001,16 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -12530,6 +13033,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -12546,6 +13062,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -13130,6 +13656,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -13640,6 +14179,19 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/on-headers": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", @@ -13844,6 +14396,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -14213,6 +14775,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -14250,6 +14826,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14290,6 +14882,22 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -14706,6 +15314,34 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14812,6 +15448,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -14825,6 +15468,60 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/serve": { "version": "14.2.5", "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", @@ -14901,6 +15598,26 @@ "node": "*" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serve/node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -15207,6 +15924,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -15529,6 +16253,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -15804,6 +16538,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", + "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -16150,6 +16920,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -16468,6 +17248,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -16736,6 +17548,16 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -17809,6 +18631,35 @@ "bupkis": ">=0.15.0", "sinon": ">=17.0.0" } + }, + "packages/supertest": { + "name": "@bupkis/supertest", + "version": "0.0.0", + "license": "BlueOak-1.0.0", + "devDependencies": { + "@types/express": "5.0.3", + "@types/supertest": "6.0.3", + "express": "5.1.0", + "supertest": "7.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "bupkis": ">=0.15.0" + } + }, + "packages/supertest/node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } } } } diff --git a/packages/supertest/LICENSE.md b/packages/supertest/LICENSE.md new file mode 100644 index 00000000..8cb5cc6e --- /dev/null +++ b/packages/supertest/LICENSE.md @@ -0,0 +1,55 @@ +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** diff --git a/packages/supertest/package.json b/packages/supertest/package.json new file mode 100644 index 00000000..23d5172f --- /dev/null +++ b/packages/supertest/package.json @@ -0,0 +1,73 @@ +{ + "name": "@bupkis/supertest", + "version": "0.0.0", + "type": "module", + "description": "HTTP response assertions for Bupkis - works with supertest, fetch, and axios", + "repository": { + "directory": "packages/supertest", + "type": "git", + "url": "git+https://github.com/boneskull/bupkis.git" + }, + "author": { + "email": "boneskull@boneskull.com", + "name": "Christopher Hiller" + }, + "license": "BlueOak-1.0.0", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.cts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "CHANGELOG.md", + "dist", + "src" + ], + "keywords": [ + "bupkis", + "supertest", + "http", + "response", + "assert", + "assertion", + "test", + "fetch", + "axios" + ], + "scripts": { + "build": "zshy", + "prepublishOnly": "npm run build", + "test": "npm run test:base -- \"test/*.test.ts\"", + "test:base": "node --import tsx --test --test-reporter=spec", + "test:dev": "npm run test:base -- --watch \"test/*.test.ts\"", + "test:node20": "npm run test:base -- test/*.test.ts", + "test:types": "tsd" + }, + "peerDependencies": { + "bupkis": ">=0.15.0" + }, + "devDependencies": { + "@types/express": "5.0.3", + "@types/supertest": "6.0.3", + "express": "5.1.0", + "supertest": "7.1.1" + }, + "publishConfig": { + "access": "public" + }, + "zshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + } +} diff --git a/packages/supertest/src/index.ts b/packages/supertest/src/index.ts new file mode 100644 index 00000000..ca59e41e --- /dev/null +++ b/packages/supertest/src/index.ts @@ -0,0 +1,20 @@ +/** + * HTTP response assertions for Bupkis. + * + * @module @bupkis/supertest + * @example + * + * ```ts + * import { use } from 'bupkis'; + * import supertestAssertions from '@bupkis/supertest'; + * import request from 'supertest'; + * + * const { expect } = use(supertestAssertions); + * + * const response = await request(app).get('/api/users'); + * expect(response, 'to have status', 200); + * ``` + */ + +// Placeholder - will be populated with exports +export {}; diff --git a/packages/supertest/tsconfig.json b/packages/supertest/tsconfig.json new file mode 100644 index 00000000..fe916db5 --- /dev/null +++ b/packages/supertest/tsconfig.json @@ -0,0 +1,5 @@ +{ + "exclude": ["dist", "node_modules", "**/test/**/*.snap.cjs"], + "extends": "../../.config/tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} From cc1c158ebca9f14ddd2b485753f9d45d801522ba Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:00:21 -0800 Subject: [PATCH 2/9] feat(supertest): implement HTTP response assertions Add comprehensive HTTP response assertions for testing with supertest, superagent, fetch, or axios responses. Includes: - Status assertions (exact code and categories: ok/redirect/client error/server error) - Header assertions (existence, exact match, regex pattern) - Body assertions (existence, exact string, JSON, partial object match, regex) - Redirect assertions (is redirect, redirect to URL/pattern) All assertions support case-insensitive header matching and both 'headers' and 'header' property names for superagent compatibility. Co-Authored-By: Claude Opus 4.5 --- packages/supertest/README.md | 670 ++++++++++++++++++ packages/supertest/src/assertions.ts | 750 +++++++++++++++++++++ packages/supertest/src/guards.ts | 95 +++ packages/supertest/src/index.ts | 6 +- packages/supertest/src/schema.ts | 28 + packages/supertest/test/assertions.test.ts | 565 ++++++++++++++++ packages/supertest/test/guards.test.ts | 79 +++ 7 files changed, 2191 insertions(+), 2 deletions(-) create mode 100644 packages/supertest/README.md create mode 100644 packages/supertest/src/assertions.ts create mode 100644 packages/supertest/src/guards.ts create mode 100644 packages/supertest/src/schema.ts create mode 100644 packages/supertest/test/assertions.test.ts create mode 100644 packages/supertest/test/guards.test.ts diff --git a/packages/supertest/README.md b/packages/supertest/README.md new file mode 100644 index 00000000..ae537bcf --- /dev/null +++ b/packages/supertest/README.md @@ -0,0 +1,670 @@ +# @bupkis/supertest + +HTTP response assertions for [Bupkis](https://bupkis.zip). + +Works with [supertest](https://github.com/ladjs/supertest), [superagent](https://github.com/ladjs/superagent), fetch responses, axios responses, or any object with a numeric `status` property. + +## Installation + +```bash +npm install @bupkis/supertest bupkis +``` + +## Usage + +```typescript +import { use } from 'bupkis'; +import supertestAssertions from '@bupkis/supertest'; +import request from 'supertest'; + +const { expect } = use(supertestAssertions); + +// Status assertions +const response = await request(app).get('/api/users'); +expect(response, 'to have status', 200); +expect(response, 'to have status', 'ok'); + +// Header assertions +expect(response, 'to have header', 'content-type'); +expect(response, 'to have header', 'content-type', 'application/json'); +expect(response, 'to have header', 'content-type', /json/); + +// Body assertions +expect(response, 'to have body'); +expect(response, 'to have JSON body'); +expect(response, 'to have JSON body satisfying', { users: [] }); + +// Redirect assertions +const redirect = await request(app).get('/old-page'); +expect(redirect, 'to redirect'); +expect(redirect, 'to redirect to', '/new-page'); +``` + +## Assertions + +### {Response} to have status {number} + +> Aliases: +> +> {Response} to have status {number} +> {Response} to respond with status {number} + +Asserts that a response has a specific HTTP status code. + +**Success**: + +```js +expect({ status: 200 }, 'to have status', 200); +expect({ status: 404 }, 'to have status', 404); +expect({ status: 500 }, 'to respond with status', 500); +``` + +**Failure**: + +```js +expect({ status: 404 }, 'to have status', 200); +// AssertionError: Expected response to have status 200 +``` + +**Negation**: + +```js +expect({ status: 404 }, 'not to have status', 200); +``` + +### {Response} to have status {category} + +> Aliases: +> +> {Response} to have status {category} +> {Response} to respond with status {category} + +Asserts that a response has a status code within a category. Valid categories are: + +- `'ok'` - 2xx status codes (200-299) +- `'redirect'` - 3xx status codes (300-399) +- `'client error'` - 4xx status codes (400-499) +- `'server error'` - 5xx status codes (500-599) + +**Success**: + +```js +expect({ status: 200 }, 'to have status', 'ok'); +expect({ status: 201 }, 'to have status', 'ok'); +expect({ status: 301 }, 'to have status', 'redirect'); +expect({ status: 404 }, 'to have status', 'client error'); +expect({ status: 500 }, 'to have status', 'server error'); +``` + +**Failure**: + +```js +expect({ status: 404 }, 'to have status', 'ok'); +// AssertionError: Expected response to have ok status +``` + +**Negation**: + +```js +expect({ status: 404 }, 'not to have status', 'ok'); +``` + +### {Response} to have header {string} + +> Aliases: +> +> {Response} to have header {string} +> {Response} to include header {string} + +Asserts that a response has a specific header (existence check only). Header names are matched case-insensitively. + +**Success**: + +```js +const response = { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}; +expect(response, 'to have header', 'content-type'); +expect(response, 'to have header', 'Content-Type'); +expect(response, 'to include header', 'CONTENT-TYPE'); +``` + +**Failure**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'application/json' }, +}; +expect(response, 'to have header', 'x-custom-header'); +// AssertionError: Expected response to have header "x-custom-header" +``` + +**Negation**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'application/json' }, +}; +expect(response, 'not to have header', 'x-custom-header'); +``` + +### {Response} to have header {string} {string} + +> Aliases: +> +> {Response} to have header {string} {string} +> {Response} to include header {string} {string} + +Asserts that a response has a header with an exact value. + +**Success**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'application/json' }, +}; +expect(response, 'to have header', 'content-type', 'application/json'); +``` + +**Failure**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'text/html' }, +}; +expect(response, 'to have header', 'content-type', 'application/json'); +// AssertionError: Expected header "content-type" to equal "application/json" +``` + +**With array header values** (e.g., multiple `Set-Cookie` headers): + +```js +const response = { + status: 200, + headers: { 'set-cookie': ['a=1', 'b=2'] }, +}; +expect(response, 'to have header', 'set-cookie', 'a=1, b=2'); +``` + +**Negation**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'text/html' }, +}; +expect(response, 'not to have header', 'content-type', 'application/json'); +``` + +### {Response} to have header {string} {RegExp} + +> Aliases: +> +> {Response} to have header {string} {RegExp} +> {Response} to include header {string} {RegExp} + +Asserts that a response has a header matching a regex pattern. + +**Success**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, +}; +expect(response, 'to have header', 'content-type', /json/); +expect(response, 'to have header', 'content-type', /^application\//); + +const cacheResponse = { + status: 200, + headers: { 'cache-control': 'max-age=3600, public' }, +}; +expect(cacheResponse, 'to have header', 'cache-control', /max-age=\d+/); +``` + +**Failure**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'text/html' }, +}; +expect(response, 'to have header', 'content-type', /json/); +// AssertionError: Expected header "content-type" to match /json/ +``` + +**Negation**: + +```js +const response = { + status: 200, + headers: { 'content-type': 'text/html' }, +}; +expect(response, 'not to have header', 'content-type', /json/); +``` + +### {Response} to have body + +Asserts that a response has a non-empty body. Empty objects `{}` and empty arrays `[]` are considered to have a body (they're valid JSON responses). + +**Success**: + +```js +expect({ status: 200, text: 'Hello' }, 'to have body'); +expect({ status: 200, body: { users: [] } }, 'to have body'); +expect({ status: 200, body: {} }, 'to have body'); // empty object counts +expect({ status: 200, body: [] }, 'to have body'); // empty array counts +``` + +**Failure**: + +```js +expect({ status: 200 }, 'to have body'); +// AssertionError: Expected response to have a body + +expect({ status: 200, text: '', body: '' }, 'to have body'); +// AssertionError: Expected response to have a body +``` + +**Negation**: + +```js +expect({ status: 204 }, 'not to have body'); +``` + +### {Response} to have body {string} + +Asserts that a response has an exact string body. + +**Success**: + +```js +expect({ status: 200, text: 'Hello, World!' }, 'to have body', 'Hello, World!'); + +// Object bodies are stringified for comparison +expect({ status: 200, body: { id: 1 } }, 'to have body', '{"id":1}'); +``` + +**Failure**: + +```js +expect({ status: 200, text: 'Hello' }, 'to have body', 'Goodbye'); +// AssertionError: Expected response body to equal string +``` + +**Negation**: + +```js +expect({ status: 200, text: 'Hello' }, 'not to have body', 'Goodbye'); +``` + +### {Response} to have JSON body + +Asserts that a response has a JSON content-type and a body. Checks for `application/json` in either the `type` property or `content-type` header. + +**Success**: + +```js +const response = { + status: 200, + type: 'application/json', + body: { users: [] }, +}; +expect(response, 'to have JSON body'); + +// Also works with content-type header +const response2 = { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: { data: 'test' }, +}; +expect(response2, 'to have JSON body'); +``` + +**Failure**: + +```js +const htmlResponse = { + status: 200, + type: 'text/html', + body: '', +}; +expect(htmlResponse, 'to have JSON body'); +// AssertionError: Expected response to have JSON content-type + +const emptyResponse = { + status: 204, + type: 'application/json', +}; +expect(emptyResponse, 'to have JSON body'); +// AssertionError: Expected response to have a JSON body +``` + +**Negation**: + +```js +const htmlResponse = { + status: 200, + type: 'text/html', + body: '', +}; +expect(htmlResponse, 'not to have JSON body'); +``` + +### {Response} to have JSON body satisfying {object} + +Asserts that a response has a JSON body containing all specified properties with matching values. Uses partial/subset matching - the response may contain additional properties beyond those specified. + +**Success**: + +```js +const response = { + status: 200, + body: { id: 1, name: 'John', email: 'john@example.com' }, +}; + +// Partial match - only checks specified properties +expect(response, 'to have JSON body satisfying', { id: 1 }); +expect(response, 'to have JSON body satisfying', { name: 'John' }); +expect(response, 'to have JSON body satisfying', { id: 1, name: 'John' }); + +// Nested objects +const nestedResponse = { + status: 200, + body: { + user: { id: 1, profile: { name: 'John', age: 30 } }, + meta: { version: '1.0' }, + }, +}; +expect(nestedResponse, 'to have JSON body satisfying', { + user: { profile: { name: 'John' } }, +}); + +// Arrays +const arrayResponse = { + status: 200, + body: { users: [{ id: 1 }, { id: 2 }] }, +}; +expect(arrayResponse, 'to have JSON body satisfying', { + users: [{ id: 1 }, { id: 2 }], +}); +``` + +**Failure**: + +```js +const response = { + status: 200, + body: { id: 1 }, +}; +expect(response, 'to have JSON body satisfying', { name: 'John' }); +// AssertionError: Expected response body to satisfy specification + +const response2 = { + status: 200, + body: { id: 1, name: 'Jane' }, +}; +expect(response2, 'to have JSON body satisfying', { name: 'John' }); +// AssertionError: Expected response body to satisfy specification +``` + +**Negation**: + +```js +const response = { + status: 200, + body: { id: 1, name: 'Jane' }, +}; +expect(response, 'not to have JSON body satisfying', { name: 'John' }); +``` + +### {Response} to have body satisfying {RegExp} + +Asserts that a response body (as text) matches a regex pattern. + +**Success**: + +```js +expect( + { status: 200, text: 'Hello, World!' }, + 'to have body satisfying', + /World/, +); +expect( + { status: 200, text: '{"id":123}' }, + 'to have body satisfying', + /"id":\d+/, +); +``` + +**Failure**: + +```js +expect({ status: 200, text: 'Hello' }, 'to have body satisfying', /Goodbye/); +// AssertionError: Expected response body to match /Goodbye/ + +expect({ status: 200 }, 'to have body satisfying', /anything/); +// AssertionError: Expected response to have a body +``` + +**Negation**: + +```js +expect( + { status: 200, text: 'Hello' }, + 'not to have body satisfying', + /Goodbye/, +); +``` + +### {Response} to have body satisfying {object} + +Asserts that a response body satisfies a partial object match. Similar to `to have JSON body satisfying` but doesn't require JSON content-type. + +**Success**: + +```js +const response = { + status: 200, + body: { id: 1, name: 'John', extra: 'ignored' }, +}; +expect(response, 'to have body satisfying', { id: 1 }); +``` + +**Failure**: + +```js +const response = { + status: 200, + body: { id: 1 }, +}; +expect(response, 'to have body satisfying', { name: 'John' }); +// AssertionError: Expected response body to satisfy specification +``` + +**Negation**: + +```js +const response = { + status: 200, + body: { id: 1 }, +}; +expect(response, 'not to have body satisfying', { name: 'John' }); +``` + +### {Response} to redirect + +Asserts that a response is a redirect (has a 3xx status code). + +**Success**: + +```js +expect({ status: 301 }, 'to redirect'); +expect({ status: 302 }, 'to redirect'); +expect({ status: 307 }, 'to redirect'); +expect({ status: 308 }, 'to redirect'); +``` + +**Failure**: + +```js +expect({ status: 200 }, 'to redirect'); +// AssertionError: Expected response to be a redirect, but got status 200 + +expect({ status: 404 }, 'to redirect'); +// AssertionError: Expected response to be a redirect, but got status 404 +``` + +**Negation**: + +```js +expect({ status: 200 }, 'not to redirect'); +``` + +### {Response} to redirect to {string} + +Asserts that a response redirects to a specific URL. The response must be a redirect (3xx) and have a `Location` header matching the expected URL exactly. + +**Success**: + +```js +const response = { + status: 302, + headers: { location: '/login' }, +}; +expect(response, 'to redirect to', '/login'); + +const fullUrl = { + status: 301, + headers: { location: 'https://example.com/new-page' }, +}; +expect(fullUrl, 'to redirect to', 'https://example.com/new-page'); +``` + +**Failure**: + +```js +// Not a redirect +const okResponse = { + status: 200, + headers: { location: '/somewhere' }, +}; +expect(okResponse, 'to redirect to', '/somewhere'); +// AssertionError: Expected response to be a redirect, but got status 200 + +// Missing Location header +expect({ status: 302 }, 'to redirect to', '/login'); +// AssertionError: Expected redirect response to have a Location header + +// Location doesn't match +const response = { + status: 302, + headers: { location: '/dashboard' }, +}; +expect(response, 'to redirect to', '/login'); +// AssertionError: Expected redirect to "/login" +``` + +**Negation**: + +```js +const response = { + status: 302, + headers: { location: '/dashboard' }, +}; +expect(response, 'not to redirect to', '/login'); +``` + +### {Response} to redirect to {RegExp} + +Asserts that a response redirects to a URL matching a pattern. The response must be a redirect (3xx) and have a `Location` header matching the regex. + +**Success**: + +```js +const response = { + status: 302, + headers: { location: '/auth/login?redirect=/dashboard' }, +}; +expect(response, 'to redirect to', /\/auth/); +expect(response, 'to redirect to', /redirect=/); +expect(response, 'to redirect to', /^\/auth\/login/); +``` + +**Failure**: + +```js +// Not a redirect +const okResponse = { + status: 200, + headers: { location: '/somewhere' }, +}; +expect(okResponse, 'to redirect to', /somewhere/); +// AssertionError: Expected response to be a redirect, but got status 200 + +// Location doesn't match pattern +const response = { + status: 302, + headers: { location: '/dashboard' }, +}; +expect(response, 'to redirect to', /login/); +// AssertionError: Expected redirect Location to match /login/ +``` + +**Negation**: + +```js +const response = { + status: 302, + headers: { location: '/dashboard' }, +}; +expect(response, 'not to redirect to', /login/); +``` + +## Compatible Response Objects + +This library works with any object that has a numeric `status` property. It's designed to be compatible with: + +- **supertest** responses +- **superagent** responses (uses `header` instead of `headers`) +- **fetch** responses (after calling `.json()` or similar) +- **axios** responses +- Plain objects for testing + +```js +// Minimal response +expect({ status: 200 }, 'to have status', 200); + +// supertest/superagent style +expect( + { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { users: [] }, + text: '{"users":[]}', + type: 'application/json', + }, + 'to have JSON body', +); + +// superagent uses 'header' (singular) +expect( + { + status: 200, + header: { 'content-type': 'text/html' }, + }, + 'to have header', + 'content-type', +); +``` + +## License + +Copyright © 2026 [Christopher "boneskull" Hiller][boneskull]. Licensed under [BlueOak-1.0.0](https://blueoakcouncil.org/license/1.0.0). + +[boneskull]: https://github.com/boneskull diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts new file mode 100644 index 00000000..52ab63f4 --- /dev/null +++ b/packages/supertest/src/assertions.ts @@ -0,0 +1,750 @@ +/** + * HTTP response assertions for Bupkis. + * + * @packageDocumentation + */ + +import { expect, schema, z } from 'bupkis'; + +import type { HttpResponse } from './guards.js'; + +const { isArray } = Array; +const { entries, keys } = Object; + +import { HttpResponseSchema } from './schema.js'; + +// #region Status Code Assertions + +/** + * Valid status category names for status assertions. + */ +const StatusCategorySchema = z.enum([ + 'ok', + 'redirect', + 'client error', + 'server error', +]); + +type StatusCategory = z.infer; + +/** + * Maps status category names to their HTTP status code ranges. + */ +const STATUS_RANGES: Record = { + 'client error': { max: 499, min: 400 }, + ok: { max: 299, min: 200 }, + redirect: { max: 399, min: 300 }, + 'server error': { max: 599, min: 500 }, +}; + +/** + * Determines the category name for a given status code. + * + * @function + */ +const getStatusCategory = (status: number): string => { + if (status >= 200 && status < 300) { + return 'ok (2xx)'; + } + if (status >= 300 && status < 400) { + return 'redirect (3xx)'; + } + if (status >= 400 && status < 500) { + return 'client error (4xx)'; + } + if (status >= 500 && status < 600) { + return 'server error (5xx)'; + } + return `unknown (${status})`; +}; + +/** + * Asserts that a response has a specific HTTP status code. + * + * @example + * + * ```ts + * expect(response, 'to have status', 200); + * expect(response, 'to have status', 404); + * ``` + */ +export const toHaveStatusAssertion = expect.createAssertion( + [ + HttpResponseSchema, + ['to have status', 'to respond with status'], + z.number(), + ], + (response: HttpResponse, expected: number) => { + if (response.status === expected) { + return true; + } + return { + actual: response.status, + expected, + message: `Expected response to have status ${expected}`, + }; + }, +); + +/** + * Asserts that a response has a status code within a category. + * + * @example + * + * ```ts + * expect(response, 'to have status', 'ok'); // 2xx + * expect(response, 'to have status', 'redirect'); // 3xx + * expect(response, 'to have status', 'client error'); // 4xx + * expect(response, 'to have status', 'server error'); // 5xx + * ``` + */ +export const toHaveStatusCategoryAssertion = expect.createAssertion( + [ + HttpResponseSchema, + ['to have status', 'to respond with status'], + StatusCategorySchema, + ], + (response: HttpResponse, category: StatusCategory) => { + const range = STATUS_RANGES[category]; + if (response.status >= range.min && response.status <= range.max) { + return true; + } + return { + actual: `${response.status} (${getStatusCategory(response.status)})`, + expected: `${category} (${range.min}-${range.max})`, + message: `Expected response to have ${category} status`, + }; + }, +); + +// #endregion + +// #region Header Assertions + +/** + * Gets the headers object from a response, handling both `headers` and `header` + * property names. + * + * @function + */ +const getHeaders = ( + response: HttpResponse, +): Record | undefined => + response.headers ?? response.header; + +/** + * Gets a header value by name (case-insensitive). + * + * @function + * @param response - The HTTP response + * @param name - The header name to look up + * @returns The header value, or undefined if not found + */ +const getHeaderValue = ( + response: HttpResponse, + name: string, +): string | string[] | undefined => { + const headers = getHeaders(response); + if (!headers) { + return undefined; + } + + // HTTP headers are case-insensitive, so we need to search + const lowerName = name.toLowerCase(); + for (const [key, value] of entries(headers)) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + return undefined; +}; + +/** + * Asserts that a response has a specific header (existence check only). + * + * @example + * + * ```ts + * expect(response, 'to have header', 'content-type'); + * expect(response, 'to have header', 'X-Request-Id'); + * ``` + */ +export const toHaveHeaderAssertion = expect.createAssertion( + [HttpResponseSchema, ['to have header', 'to include header'], z.string()], + (response: HttpResponse, headerName: string) => { + const value = getHeaderValue(response, headerName); + if (value !== undefined) { + return true; + } + const headers = getHeaders(response); + return { + actual: headers ? keys(headers).join(', ') : 'no headers', + expected: headerName, + message: `Expected response to have header "${headerName}"`, + }; + }, +); + +/** + * Asserts that a response has a header with an exact value. + * + * @example + * + * ```ts + * expect(response, 'to have header', 'content-type', 'application/json'); + * expect(response, 'to have header', 'cache-control', 'no-cache'); + * ``` + */ +export const toHaveHeaderValueAssertion = expect.createAssertion( + [ + HttpResponseSchema, + ['to have header', 'to include header'], + z.string(), + z.string(), + ], + (response: HttpResponse, headerName: string, expectedValue: string) => { + const actualValue = getHeaderValue(response, headerName); + + if (actualValue === undefined) { + const headers = getHeaders(response); + return { + actual: headers ? keys(headers).join(', ') : 'no headers', + expected: `${headerName}: ${expectedValue}`, + message: `Expected response to have header "${headerName}"`, + }; + } + + // Handle array values (multiple headers with same name) + const actualString = isArray(actualValue) + ? actualValue.join(', ') + : actualValue; + + if (actualString === expectedValue) { + return true; + } + + return { + actual: actualString, + expected: expectedValue, + message: `Expected header "${headerName}" to equal "${expectedValue}"`, + }; + }, +); + +/** + * Asserts that a response has a header matching a regex pattern. + * + * @example + * + * ```ts + * expect(response, 'to have header', 'content-type', /json/); + * expect(response, 'to have header', 'cache-control', /max-age=\d+/); + * ``` + */ +export const toHaveHeaderMatchingAssertion = expect.createAssertion( + [ + HttpResponseSchema, + ['to have header', 'to include header'], + z.string(), + z.instanceof(RegExp), + ], + (response: HttpResponse, headerName: string, pattern: RegExp) => { + const actualValue = getHeaderValue(response, headerName); + + if (actualValue === undefined) { + const headers = getHeaders(response); + return { + actual: headers ? keys(headers).join(', ') : 'no headers', + expected: `${headerName} matching ${pattern}`, + message: `Expected response to have header "${headerName}"`, + }; + } + + // Handle array values (multiple headers with same name) + const actualString = isArray(actualValue) + ? actualValue.join(', ') + : actualValue; + + if (pattern.test(actualString)) { + return true; + } + + return { + actual: actualString, + expected: `matching ${pattern}`, + message: `Expected header "${headerName}" to match ${pattern}`, + }; + }, +); + +// #endregion + +// #region Body Assertions + +const { stringify } = JSON; + +/** + * Gets the body content as a string for display/comparison. + * + * @function + */ +const getBodyText = (response: HttpResponse): string | undefined => { + if (response.text !== undefined) { + return response.text; + } + if (response.body !== undefined) { + return typeof response.body === 'string' + ? response.body + : stringify(response.body); + } + return undefined; +}; + +/** + * Checks if the response has a non-empty body. + * + * @function + */ +const hasBody = (response: HttpResponse): boolean => { + // Check text first (raw body) + if (response.text !== undefined && response.text !== '') { + return true; + } + // Check parsed body + if (response.body !== undefined) { + // Empty object {} or empty array [] still counts as "has body" + if (typeof response.body === 'object' && response.body !== null) { + return true; + } + // Non-empty string + if (typeof response.body === 'string' && response.body !== '') { + return true; + } + // Other truthy values + if (response.body) { + return true; + } + } + return false; +}; + +/** + * Checks if the response appears to be JSON based on content-type. + * + * @function + */ +const isJsonResponse = (response: HttpResponse): boolean => { + // Check the type property (supertest normalizes this) + if (response.type) { + return ( + response.type.includes('json') || + response.type.includes('application/json') + ); + } + + // Fall back to checking headers + const contentType = getHeaderValue(response, 'content-type'); + if (contentType) { + const value = isArray(contentType) ? contentType.join(', ') : contentType; + return value.includes('json'); + } + + return false; +}; + +/** + * Performs a deep partial match - checks if all properties in expected exist in + * actual with matching values. + * + * @function + */ +const deepPartialMatch = (actual: unknown, expected: unknown): boolean => { + // Exact match for primitives + if (expected === actual) { + return true; + } + + // Handle null + if (expected === null || actual === null) { + return expected === actual; + } + + // Both must be objects for partial matching + if (typeof expected !== 'object' || typeof actual !== 'object') { + return false; + } + + // Handle arrays - must have same length and matching elements + if (isArray(expected)) { + if (!isArray(actual) || actual.length !== expected.length) { + return false; + } + return expected.every((item, index) => + deepPartialMatch(actual[index], item), + ); + } + + // Object partial match - all keys in expected must exist and match in actual + const expectedObj = expected as Record; + const actualObj = actual as Record; + + for (const key of keys(expectedObj)) { + if (!(key in actualObj)) { + return false; + } + if (!deepPartialMatch(actualObj[key], expectedObj[key])) { + return false; + } + } + + return true; +}; + +/** + * Asserts that a response has a non-empty body. + * + * @example + * + * ```ts + * expect(response, 'to have body'); + * ``` + */ +export const toHaveBodyAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have body'], + (response: HttpResponse) => { + if (hasBody(response)) { + return true; + } + return { + actual: 'empty or no body', + expected: 'a response body', + message: 'Expected response to have a body', + }; + }, +); + +/** + * Asserts that a response has an exact string body. + * + * @example + * + * ```ts + * expect(response, 'to have body', 'Hello, World!'); + * expect(response, 'to have body', '{"users":[]}'); + * ``` + */ +export const toHaveBodyStringAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have body', z.string()], + (response: HttpResponse, expected: string) => { + const bodyText = getBodyText(response); + + if (bodyText === expected) { + return true; + } + + return { + actual: bodyText ?? 'no body', + expected, + message: 'Expected response body to equal string', + }; + }, +); + +/** + * Asserts that a response has a JSON content-type and a body. + * + * @example + * + * ```ts + * expect(response, 'to have JSON body'); + * ``` + */ +export const toHaveJsonBodyAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have JSON body'], + (response: HttpResponse) => { + if (!isJsonResponse(response)) { + const contentType = + response.type ?? getHeaderValue(response, 'content-type') ?? 'unknown'; + return { + actual: contentType, + expected: 'application/json', + message: 'Expected response to have JSON content-type', + }; + } + + if (!hasBody(response)) { + return { + actual: 'empty body', + expected: 'JSON body', + message: 'Expected response to have a JSON body', + }; + } + + return true; + }, +); + +/** + * Asserts that a response has a JSON body satisfying a partial match. + * + * @example + * + * ```ts + * expect(response, 'to have JSON body satisfying', { users: [] }); + * expect(response, 'to have JSON body satisfying', { status: 'ok' }); + * ``` + */ +export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have JSON body satisfying', schema.AnyObjectSchema], + (response: HttpResponse, expected: Record) => { + const body = response.body; + + if (body === undefined || body === null) { + return { + actual: 'no body', + expected: stringify(expected), + message: 'Expected response to have a body', + }; + } + + if (typeof body !== 'object') { + return { + actual: typeof body, + expected: 'object', + message: 'Expected response body to be an object', + }; + } + + if (deepPartialMatch(body, expected)) { + return true; + } + + return { + actual: stringify(body), + expected: stringify(expected), + message: 'Expected response body to satisfy specification', + }; + }, +); + +/** + * Asserts that a response body matches a regex pattern. + * + * @example + * + * ```ts + * expect(response, 'to have body satisfying', /success/); + * expect(response, 'to have body satisfying', /"id":\d+/); + * ``` + */ +export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have body satisfying', z.instanceof(RegExp)], + (response: HttpResponse, pattern: RegExp) => { + const bodyText = getBodyText(response); + + if (bodyText === undefined) { + return { + actual: 'no body', + expected: `matching ${pattern}`, + message: 'Expected response to have a body', + }; + } + + if (pattern.test(bodyText)) { + return true; + } + + return { + actual: bodyText, + expected: `matching ${pattern}`, + message: `Expected response body to match ${pattern}`, + }; + }, +); + +/** + * Asserts that a response body satisfies a partial object match. + * + * @example + * + * ```ts + * expect(response, 'to have body satisfying', { users: [] }); + * ``` + */ +export const toHaveBodySatisfyingObjectAssertion = expect.createAssertion( + [HttpResponseSchema, 'to have body satisfying', schema.AnyObjectSchema], + (response: HttpResponse, expected: Record) => { + const body = response.body; + + if (body === undefined || body === null) { + return { + actual: 'no body', + expected: stringify(expected), + message: 'Expected response to have a body', + }; + } + + if (typeof body !== 'object') { + return { + actual: typeof body, + expected: 'object', + message: 'Expected response body to be an object', + }; + } + + if (deepPartialMatch(body, expected)) { + return true; + } + + return { + actual: stringify(body), + expected: stringify(expected), + message: 'Expected response body to satisfy specification', + }; + }, +); + +// #endregion + +// #region Redirect Assertions + +/** + * Checks if the response is a redirect (3xx status code). + * + * @function + */ +const isRedirect = (response: HttpResponse): boolean => + response.status >= 300 && response.status < 400; + +/** + * Asserts that a response is a redirect (3xx status code). + * + * @example + * + * ```ts + * expect(response, 'to redirect'); + * ``` + */ +export const toRedirectAssertion = expect.createAssertion( + [HttpResponseSchema, 'to redirect'], + (response: HttpResponse) => { + if (isRedirect(response)) { + return true; + } + return { + actual: response.status, + expected: '3xx redirect status', + message: `Expected response to be a redirect, but got status ${response.status}`, + }; + }, +); + +/** + * Asserts that a response redirects to a specific URL. + * + * @example + * + * ```ts + * expect(response, 'to redirect to', '/login'); + * expect(response, 'to redirect to', 'https://example.com/auth'); + * ``` + */ +export const toRedirectToUrlAssertion = expect.createAssertion( + [HttpResponseSchema, 'to redirect to', z.string()], + (response: HttpResponse, expectedUrl: string) => { + if (!isRedirect(response)) { + return { + actual: response.status, + expected: '3xx redirect status', + message: `Expected response to be a redirect, but got status ${response.status}`, + }; + } + + const location = getHeaderValue(response, 'location'); + if (location === undefined) { + return { + actual: 'no Location header', + expected: expectedUrl, + message: 'Expected redirect response to have a Location header', + }; + } + + const locationString = isArray(location) ? location[0] : location; + + if (locationString === expectedUrl) { + return true; + } + + return { + actual: locationString, + expected: expectedUrl, + message: `Expected redirect to "${expectedUrl}"`, + }; + }, +); + +/** + * Asserts that a response redirects to a URL matching a pattern. + * + * @example + * + * ```ts + * expect(response, 'to redirect to', /\/auth/); + * expect(response, 'to redirect to', /login\?redirect=/); + * ``` + */ +export const toRedirectToPatternAssertion = expect.createAssertion( + [HttpResponseSchema, 'to redirect to', z.instanceof(RegExp)], + (response: HttpResponse, pattern: RegExp) => { + if (!isRedirect(response)) { + return { + actual: response.status, + expected: '3xx redirect status', + message: `Expected response to be a redirect, but got status ${response.status}`, + }; + } + + const location = getHeaderValue(response, 'location'); + if (location === undefined) { + return { + actual: 'no Location header', + expected: `matching ${pattern}`, + message: 'Expected redirect response to have a Location header', + }; + } + + const locationString = isArray(location) ? (location[0] ?? '') : location; + + if (pattern.test(locationString)) { + return true; + } + + return { + actual: locationString, + expected: `matching ${pattern}`, + message: `Expected redirect Location to match ${pattern}`, + }; + }, +); + +// #endregion + +/** + * All HTTP response assertions for use with `expect.use()`. + */ +export const supertestAssertions = [ + toHaveStatusAssertion, + toHaveStatusCategoryAssertion, + toHaveHeaderAssertion, + toHaveHeaderValueAssertion, + toHaveHeaderMatchingAssertion, + toHaveBodyAssertion, + toHaveBodyStringAssertion, + toHaveJsonBodyAssertion, + toHaveJsonBodySatisfyingAssertion, + toHaveBodySatisfyingRegexAssertion, + toHaveBodySatisfyingObjectAssertion, + toRedirectAssertion, + toRedirectToUrlAssertion, + toRedirectToPatternAssertion, +] as const; diff --git a/packages/supertest/src/guards.ts b/packages/supertest/src/guards.ts new file mode 100644 index 00000000..c18c54a8 --- /dev/null +++ b/packages/supertest/src/guards.ts @@ -0,0 +1,95 @@ +/** + * Type guards for HTTP response detection. + * + * @packageDocumentation + */ + +/** + * Represents an HTTP response object. + * + * This interface is designed to be compatible with: + * + * - Supertest/superagent Response objects + * - Fetch Response objects (after conversion) + * - Axios Response objects + * + * @example + * + * ```ts + * // With supertest + * const response = await request(app).get('/api/users'); + * // response satisfies HttpResponse + * + * // With fetch (after conversion) + * const fetchResponse = await fetch('/api/users'); + * const response: HttpResponse = { + * status: fetchResponse.status, + * headers: Object.fromEntries(fetchResponse.headers), + * body: await fetchResponse.json(), + * }; + * ``` + */ +export interface HttpResponse { + /** + * Parsed response body. + * + * For JSON responses, this is the parsed object. For other responses, it may + * be undefined or a string. + */ + body?: unknown; + + /** + * HTTP response headers (alternative property name). + * + * Superagent uses `header` (singular) for the same purpose. + */ + header?: Record; + + /** + * HTTP response headers. + * + * Supertest uses `headers` as a record. + */ + headers?: Record; + + /** + * HTTP status code (e.g., 200, 404, 500). + */ + status: number; + + /** + * Raw response body as text. + */ + text?: string; + + /** + * Content-Type of the response (without parameters). + * + * Supertest normalizes this from the Content-Type header. + */ + type?: string; +} + +/** + * Checks if a value is an HTTP response object. + * + * @example + * + * ```ts + * const response = await request(app).get('/api/users'); + * if (isHttpResponse(response)) { + * console.log(response.status); // TypeScript knows this is a number + * } + * ``` + * + * @function + * @param value - The value to check + * @returns `true` if the value has a numeric `status` property + */ +export const isHttpResponse = (value: unknown): value is HttpResponse => { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return typeof obj.status === 'number'; +}; diff --git a/packages/supertest/src/index.ts b/packages/supertest/src/index.ts index ca59e41e..e9a56ec0 100644 --- a/packages/supertest/src/index.ts +++ b/packages/supertest/src/index.ts @@ -16,5 +16,7 @@ * ``` */ -// Placeholder - will be populated with exports -export {}; +export { + supertestAssertions as default, + supertestAssertions, +} from './assertions.js'; diff --git a/packages/supertest/src/schema.ts b/packages/supertest/src/schema.ts new file mode 100644 index 00000000..4ccc59d2 --- /dev/null +++ b/packages/supertest/src/schema.ts @@ -0,0 +1,28 @@ +/** + * Zod schemas for HTTP response types. + * + * @packageDocumentation + */ + +import { z } from 'bupkis'; + +import { type HttpResponse, isHttpResponse } from './guards.js'; + +/** + * Schema that validates HTTP response objects. + * + * @example + * + * ```ts + * import { HttpResponseSchema } from '@bupkis/supertest'; + * + * const result = HttpResponseSchema.safeParse(response); + * if (result.success) { + * // result.data is typed as HttpResponse + * } + * ``` + */ +export const HttpResponseSchema = z.custom( + isHttpResponse, + 'Expected an HTTP response object with a status property', +); diff --git a/packages/supertest/test/assertions.test.ts b/packages/supertest/test/assertions.test.ts new file mode 100644 index 00000000..c4c1ac2c --- /dev/null +++ b/packages/supertest/test/assertions.test.ts @@ -0,0 +1,565 @@ +/** + * Tests for HTTP response assertions. + */ + +import { use } from 'bupkis'; +import { describe, it } from 'node:test'; + +import { supertestAssertions } from '../src/assertions.js'; + +const { expect } = use(supertestAssertions); + +describe('@bupkis/supertest assertions', () => { + describe('to have status', () => { + describe('with numeric status code', () => { + it('should pass when status matches exactly', () => { + expect({ status: 200 }, 'to have status', 200); + expect({ status: 404 }, 'to have status', 404); + expect({ status: 500 }, 'to have status', 500); + }); + + it('should fail when status does not match', () => { + expect( + () => expect({ status: 404 }, 'to have status', 200), + 'to throw', + /Expected response to have status 200/, + ); + }); + + it('should work with the alternate phrase "to respond with status"', () => { + expect({ status: 200 }, 'to respond with status', 200); + }); + }); + + describe('with status category "ok"', () => { + it('should pass for 2xx status codes', () => { + expect({ status: 200 }, 'to have status', 'ok'); + expect({ status: 201 }, 'to have status', 'ok'); + expect({ status: 204 }, 'to have status', 'ok'); + expect({ status: 299 }, 'to have status', 'ok'); + }); + + it('should fail for non-2xx status codes', () => { + expect( + () => expect({ status: 404 }, 'to have status', 'ok'), + 'to throw', + /Expected response to have ok status/, + ); + expect( + () => expect({ status: 500 }, 'to have status', 'ok'), + 'to throw', + /Expected response to have ok status/, + ); + }); + }); + + describe('with status category "redirect"', () => { + it('should pass for 3xx status codes', () => { + expect({ status: 300 }, 'to have status', 'redirect'); + expect({ status: 301 }, 'to have status', 'redirect'); + expect({ status: 302 }, 'to have status', 'redirect'); + expect({ status: 307 }, 'to have status', 'redirect'); + expect({ status: 308 }, 'to have status', 'redirect'); + }); + + it('should fail for non-3xx status codes', () => { + expect( + () => expect({ status: 200 }, 'to have status', 'redirect'), + 'to throw', + /Expected response to have redirect status/, + ); + }); + }); + + describe('with status category "client error"', () => { + it('should pass for 4xx status codes', () => { + expect({ status: 400 }, 'to have status', 'client error'); + expect({ status: 401 }, 'to have status', 'client error'); + expect({ status: 403 }, 'to have status', 'client error'); + expect({ status: 404 }, 'to have status', 'client error'); + expect({ status: 422 }, 'to have status', 'client error'); + expect({ status: 429 }, 'to have status', 'client error'); + }); + + it('should fail for non-4xx status codes', () => { + expect( + () => expect({ status: 500 }, 'to have status', 'client error'), + 'to throw', + /Expected response to have client error status/, + ); + }); + }); + + describe('with status category "server error"', () => { + it('should pass for 5xx status codes', () => { + expect({ status: 500 }, 'to have status', 'server error'); + expect({ status: 502 }, 'to have status', 'server error'); + expect({ status: 503 }, 'to have status', 'server error'); + expect({ status: 504 }, 'to have status', 'server error'); + }); + + it('should fail for non-5xx status codes', () => { + expect( + () => expect({ status: 200 }, 'to have status', 'server error'), + 'to throw', + /Expected response to have server error status/, + ); + }); + }); + }); + + describe('to have header', () => { + describe('header existence', () => { + it('should pass when header exists (using headers property)', () => { + const response = { + headers: { 'content-type': 'application/json' }, + status: 200, + }; + expect(response, 'to have header', 'content-type'); + }); + + it('should pass when header exists (using header property)', () => { + const response = { + header: { 'content-type': 'application/json' }, + status: 200, + }; + expect(response, 'to have header', 'content-type'); + }); + + it('should be case-insensitive for header names', () => { + const response = { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }; + expect(response, 'to have header', 'content-type'); + expect(response, 'to have header', 'CONTENT-TYPE'); + expect(response, 'to have header', 'Content-Type'); + }); + + it('should fail when header does not exist', () => { + const response = { + headers: { 'content-type': 'application/json' }, + status: 200, + }; + expect( + () => expect(response, 'to have header', 'x-custom-header'), + 'to throw', + /Expected response to have header "x-custom-header"/, + ); + }); + + it('should fail when response has no headers', () => { + const response = { status: 200 }; + expect( + () => expect(response, 'to have header', 'content-type'), + 'to throw', + /Expected response to have header "content-type"/, + ); + }); + + it('should work with alternate phrase "to include header"', () => { + const response = { + headers: { 'content-type': 'application/json' }, + status: 200, + }; + expect(response, 'to include header', 'content-type'); + }); + }); + + describe('header with exact value', () => { + it('should pass when header matches exact value', () => { + const response = { + headers: { 'content-type': 'application/json' }, + status: 200, + }; + expect(response, 'to have header', 'content-type', 'application/json'); + }); + + it('should fail when header value does not match', () => { + const response = { + headers: { 'content-type': 'text/html' }, + status: 200, + }; + expect( + () => + expect( + response, + 'to have header', + 'content-type', + 'application/json', + ), + 'to throw', + /Expected header "content-type" to equal "application\/json"/, + ); + }); + + it('should handle array header values', () => { + const response = { + headers: { 'set-cookie': ['a=1', 'b=2'] }, + status: 200, + }; + expect(response, 'to have header', 'set-cookie', 'a=1, b=2'); + }); + }); + + describe('header with regex match', () => { + it('should pass when header matches regex', () => { + const response = { + headers: { 'content-type': 'application/json; charset=utf-8' }, + status: 200, + }; + expect(response, 'to have header', 'content-type', /json/); + expect(response, 'to have header', 'content-type', /^application\//); + }); + + it('should fail when header does not match regex', () => { + const response = { + headers: { 'content-type': 'text/html' }, + status: 200, + }; + expect( + () => expect(response, 'to have header', 'content-type', /json/), + 'to throw', + /Expected header "content-type" to match/, + ); + }); + + it('should handle complex regex patterns', () => { + const response = { + headers: { 'cache-control': 'max-age=3600, public' }, + status: 200, + }; + expect(response, 'to have header', 'cache-control', /max-age=\d+/); + }); + }); + }); + + describe('to have body', () => { + it('should pass when response has text body', () => { + expect({ status: 200, text: 'Hello' }, 'to have body'); + }); + + it('should pass when response has object body', () => { + expect({ body: { users: [] }, status: 200 }, 'to have body'); + }); + + it('should pass when response has empty object body', () => { + // Empty object still counts as having a body + expect({ body: {}, status: 200 }, 'to have body'); + }); + + it('should pass when response has empty array body', () => { + expect({ body: [], status: 200 }, 'to have body'); + }); + + it('should fail when response has no body', () => { + expect( + () => expect({ status: 200 }, 'to have body'), + 'to throw', + /Expected response to have a body/, + ); + }); + + it('should fail when response has empty string body', () => { + expect( + () => expect({ body: '', status: 200, text: '' }, 'to have body'), + 'to throw', + /Expected response to have a body/, + ); + }); + }); + + describe('to have body (exact string)', () => { + it('should pass when body text matches exactly', () => { + expect( + { status: 200, text: 'Hello, World!' }, + 'to have body', + 'Hello, World!', + ); + }); + + it('should pass when body object stringifies to expected', () => { + expect({ body: { id: 1 }, status: 200 }, 'to have body', '{"id":1}'); + }); + + it('should fail when body does not match', () => { + expect( + () => expect({ status: 200, text: 'Hello' }, 'to have body', 'Goodbye'), + 'to throw', + /Expected response body to equal string/, + ); + }); + }); + + describe('to have JSON body', () => { + it('should pass when response has JSON content-type and body', () => { + const response = { + body: { users: [] }, + status: 200, + type: 'application/json', + }; + expect(response, 'to have JSON body'); + }); + + it('should pass when JSON content-type is in headers', () => { + const response = { + body: { data: 'test' }, + headers: { 'content-type': 'application/json; charset=utf-8' }, + status: 200, + }; + expect(response, 'to have JSON body'); + }); + + it('should fail when content-type is not JSON', () => { + const response = { + body: '', + status: 200, + type: 'text/html', + }; + expect( + () => expect(response, 'to have JSON body'), + 'to throw', + /Expected response to have JSON content-type/, + ); + }); + + it('should fail when response has no body', () => { + const response = { + status: 204, + type: 'application/json', + }; + expect( + () => expect(response, 'to have JSON body'), + 'to throw', + /Expected response to have a JSON body/, + ); + }); + }); + + describe('to have JSON body satisfying', () => { + it('should pass when body contains expected properties', () => { + const response = { + body: { email: 'john@example.com', id: 1, name: 'John' }, + status: 200, + }; + expect(response, 'to have JSON body satisfying', { id: 1 }); + expect(response, 'to have JSON body satisfying', { name: 'John' }); + expect(response, 'to have JSON body satisfying', { id: 1, name: 'John' }); + }); + + it('should pass with nested objects', () => { + const response = { + body: { + meta: { version: '1.0' }, + user: { id: 1, profile: { age: 30, name: 'John' } }, + }, + status: 200, + }; + expect(response, 'to have JSON body satisfying', { + user: { profile: { name: 'John' } }, + }); + }); + + it('should pass with arrays', () => { + const response = { + body: { users: [{ id: 1 }, { id: 2 }] }, + status: 200, + }; + expect(response, 'to have JSON body satisfying', { + users: [{ id: 1 }, { id: 2 }], + }); + }); + + it('should fail when property is missing', () => { + const response = { + body: { id: 1 }, + status: 200, + }; + expect( + () => + expect(response, 'to have JSON body satisfying', { name: 'John' }), + 'to throw', + /Expected response body to satisfy specification/, + ); + }); + + it('should fail when property value differs', () => { + const response = { + body: { id: 1, name: 'Jane' }, + status: 200, + }; + expect( + () => + expect(response, 'to have JSON body satisfying', { name: 'John' }), + 'to throw', + /Expected response body to satisfy specification/, + ); + }); + }); + + describe('to have body satisfying (regex)', () => { + it('should pass when body matches regex', () => { + expect( + { status: 200, text: 'Hello, World!' }, + 'to have body satisfying', + /World/, + ); + expect( + { status: 200, text: '{"id":123}' }, + 'to have body satisfying', + /"id":\d+/, + ); + }); + + it('should fail when body does not match regex', () => { + expect( + () => + expect( + { status: 200, text: 'Hello' }, + 'to have body satisfying', + /Goodbye/, + ), + 'to throw', + /Expected response body to match/, + ); + }); + + it('should fail when response has no body', () => { + expect( + () => expect({ status: 200 }, 'to have body satisfying', /anything/), + 'to throw', + /Expected response to have a body/, + ); + }); + }); + + describe('to have body satisfying (object)', () => { + it('should pass when body contains expected properties', () => { + const response = { + body: { extra: 'ignored', id: 1, name: 'John' }, + status: 200, + }; + expect(response, 'to have body satisfying', { id: 1 }); + }); + + it('should fail when body is missing expected property', () => { + const response = { + body: { id: 1 }, + status: 200, + }; + expect( + () => expect(response, 'to have body satisfying', { name: 'John' }), + 'to throw', + /Expected response body to satisfy specification/, + ); + }); + }); + + describe('to redirect', () => { + it('should pass for 3xx status codes', () => { + expect({ status: 301 }, 'to redirect'); + expect({ status: 302 }, 'to redirect'); + expect({ status: 307 }, 'to redirect'); + expect({ status: 308 }, 'to redirect'); + }); + + it('should fail for non-3xx status codes', () => { + expect( + () => expect({ status: 200 }, 'to redirect'), + 'to throw', + /Expected response to be a redirect/, + ); + expect( + () => expect({ status: 404 }, 'to redirect'), + 'to throw', + /Expected response to be a redirect/, + ); + }); + }); + + describe('to redirect to (URL)', () => { + it('should pass when Location header matches exactly', () => { + const response = { + headers: { location: '/login' }, + status: 302, + }; + expect(response, 'to redirect to', '/login'); + }); + + it('should pass with full URL', () => { + const response = { + headers: { location: 'https://example.com/new-page' }, + status: 301, + }; + expect(response, 'to redirect to', 'https://example.com/new-page'); + }); + + it('should fail when not a redirect', () => { + const response = { + headers: { location: '/somewhere' }, + status: 200, + }; + expect( + () => expect(response, 'to redirect to', '/somewhere'), + 'to throw', + /Expected response to be a redirect/, + ); + }); + + it('should fail when Location header is missing', () => { + const response = { status: 302 }; + expect( + () => expect(response, 'to redirect to', '/login'), + 'to throw', + /Expected redirect response to have a Location header/, + ); + }); + + it('should fail when Location does not match', () => { + const response = { + headers: { location: '/dashboard' }, + status: 302, + }; + expect( + () => expect(response, 'to redirect to', '/login'), + 'to throw', + /Expected redirect to "\/login"/, + ); + }); + }); + + describe('to redirect to (regex)', () => { + it('should pass when Location header matches pattern', () => { + const response = { + headers: { location: '/auth/login?redirect=/dashboard' }, + status: 302, + }; + expect(response, 'to redirect to', /\/auth/); + expect(response, 'to redirect to', /redirect=/); + }); + + it('should fail when not a redirect', () => { + const response = { + headers: { location: '/somewhere' }, + status: 200, + }; + expect( + () => expect(response, 'to redirect to', /somewhere/), + 'to throw', + /Expected response to be a redirect/, + ); + }); + + it('should fail when Location does not match pattern', () => { + const response = { + headers: { location: '/dashboard' }, + status: 302, + }; + expect( + () => expect(response, 'to redirect to', /login/), + 'to throw', + /Expected redirect Location to match/, + ); + }); + }); +}); diff --git a/packages/supertest/test/guards.test.ts b/packages/supertest/test/guards.test.ts new file mode 100644 index 00000000..d2ac31ec --- /dev/null +++ b/packages/supertest/test/guards.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for HTTP response type guards. + */ + +import { expect } from 'bupkis'; +import { describe, it } from 'node:test'; + +import { isHttpResponse } from '../src/guards.js'; + +describe('@bupkis/supertest', () => { + describe('isHttpResponse()', () => { + it('should return true for object with numeric status', () => { + expect(isHttpResponse({ status: 200 }), 'to be', true); + }); + + it('should return true for supertest-like response', () => { + const response = { + body: { users: [] }, + headers: { 'content-type': 'application/json' }, + status: 200, + text: '{"users":[]}', + type: 'application/json', + }; + expect(isHttpResponse(response), 'to be', true); + }); + + it('should return true for response with header property (superagent style)', () => { + const response = { + body: {}, + header: { 'content-type': 'text/plain' }, + status: 404, + text: 'Not Found', + }; + expect(isHttpResponse(response), 'to be', true); + }); + + it('should return false for null', () => { + expect(isHttpResponse(null), 'to be', false); + }); + + it('should return false for undefined', () => { + expect(isHttpResponse(undefined), 'to be', false); + }); + + it('should return false for primitive values', () => { + expect(isHttpResponse(42), 'to be', false); + expect(isHttpResponse('string'), 'to be', false); + expect(isHttpResponse(true), 'to be', false); + }); + + it('should return false for object without status', () => { + expect(isHttpResponse({ headers: {} }), 'to be', false); + }); + + it('should return false for object with non-numeric status', () => { + expect(isHttpResponse({ status: '200' }), 'to be', false); + expect(isHttpResponse({ status: null }), 'to be', false); + }); + + it('should return false for array', () => { + expect(isHttpResponse([200]), 'to be', false); + }); + + it('should return true for minimal response with only status', () => { + expect(isHttpResponse({ status: 500 }), 'to be', true); + }); + + it('should return true for response with extra properties', () => { + const response = { + body: {}, + customProperty: 'value', + headers: {}, + request: {}, + status: 200, + }; + expect(isHttpResponse(response), 'to be', true); + }); + }); +}); From f02518666c113bf04cac51dbf70b24e8475d4cba Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:14:14 -0800 Subject: [PATCH 3/9] chore(supertest): fix knip issues - Remove unused devDependencies (express, supertest, @types/*) - Remove individual assertion exports (only export supertestAssertions array) Co-Authored-By: Claude Opus 4.5 --- packages/supertest/package.json | 6 ------ packages/supertest/src/assertions.ts | 28 ++++++++++++++-------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/supertest/package.json b/packages/supertest/package.json index 23d5172f..9424c307 100644 --- a/packages/supertest/package.json +++ b/packages/supertest/package.json @@ -55,12 +55,6 @@ "peerDependencies": { "bupkis": ">=0.15.0" }, - "devDependencies": { - "@types/express": "5.0.3", - "@types/supertest": "6.0.3", - "express": "5.1.0", - "supertest": "7.1.1" - }, "publishConfig": { "access": "public" }, diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts index 52ab63f4..e35f4090 100644 --- a/packages/supertest/src/assertions.ts +++ b/packages/supertest/src/assertions.ts @@ -68,7 +68,7 @@ const getStatusCategory = (status: number): string => { * expect(response, 'to have status', 404); * ``` */ -export const toHaveStatusAssertion = expect.createAssertion( +const toHaveStatusAssertion = expect.createAssertion( [ HttpResponseSchema, ['to have status', 'to respond with status'], @@ -98,7 +98,7 @@ export const toHaveStatusAssertion = expect.createAssertion( * expect(response, 'to have status', 'server error'); // 5xx * ``` */ -export const toHaveStatusCategoryAssertion = expect.createAssertion( +const toHaveStatusCategoryAssertion = expect.createAssertion( [ HttpResponseSchema, ['to have status', 'to respond with status'], @@ -169,7 +169,7 @@ const getHeaderValue = ( * expect(response, 'to have header', 'X-Request-Id'); * ``` */ -export const toHaveHeaderAssertion = expect.createAssertion( +const toHaveHeaderAssertion = expect.createAssertion( [HttpResponseSchema, ['to have header', 'to include header'], z.string()], (response: HttpResponse, headerName: string) => { const value = getHeaderValue(response, headerName); @@ -195,7 +195,7 @@ export const toHaveHeaderAssertion = expect.createAssertion( * expect(response, 'to have header', 'cache-control', 'no-cache'); * ``` */ -export const toHaveHeaderValueAssertion = expect.createAssertion( +const toHaveHeaderValueAssertion = expect.createAssertion( [ HttpResponseSchema, ['to have header', 'to include header'], @@ -241,7 +241,7 @@ export const toHaveHeaderValueAssertion = expect.createAssertion( * expect(response, 'to have header', 'cache-control', /max-age=\d+/); * ``` */ -export const toHaveHeaderMatchingAssertion = expect.createAssertion( +const toHaveHeaderMatchingAssertion = expect.createAssertion( [ HttpResponseSchema, ['to have header', 'to include header'], @@ -409,7 +409,7 @@ const deepPartialMatch = (actual: unknown, expected: unknown): boolean => { * expect(response, 'to have body'); * ``` */ -export const toHaveBodyAssertion = expect.createAssertion( +const toHaveBodyAssertion = expect.createAssertion( [HttpResponseSchema, 'to have body'], (response: HttpResponse) => { if (hasBody(response)) { @@ -433,7 +433,7 @@ export const toHaveBodyAssertion = expect.createAssertion( * expect(response, 'to have body', '{"users":[]}'); * ``` */ -export const toHaveBodyStringAssertion = expect.createAssertion( +const toHaveBodyStringAssertion = expect.createAssertion( [HttpResponseSchema, 'to have body', z.string()], (response: HttpResponse, expected: string) => { const bodyText = getBodyText(response); @@ -459,7 +459,7 @@ export const toHaveBodyStringAssertion = expect.createAssertion( * expect(response, 'to have JSON body'); * ``` */ -export const toHaveJsonBodyAssertion = expect.createAssertion( +const toHaveJsonBodyAssertion = expect.createAssertion( [HttpResponseSchema, 'to have JSON body'], (response: HttpResponse) => { if (!isJsonResponse(response)) { @@ -494,7 +494,7 @@ export const toHaveJsonBodyAssertion = expect.createAssertion( * expect(response, 'to have JSON body satisfying', { status: 'ok' }); * ``` */ -export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion( +const toHaveJsonBodySatisfyingAssertion = expect.createAssertion( [HttpResponseSchema, 'to have JSON body satisfying', schema.AnyObjectSchema], (response: HttpResponse, expected: Record) => { const body = response.body; @@ -537,7 +537,7 @@ export const toHaveJsonBodySatisfyingAssertion = expect.createAssertion( * expect(response, 'to have body satisfying', /"id":\d+/); * ``` */ -export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion( +const toHaveBodySatisfyingRegexAssertion = expect.createAssertion( [HttpResponseSchema, 'to have body satisfying', z.instanceof(RegExp)], (response: HttpResponse, pattern: RegExp) => { const bodyText = getBodyText(response); @@ -571,7 +571,7 @@ export const toHaveBodySatisfyingRegexAssertion = expect.createAssertion( * expect(response, 'to have body satisfying', { users: [] }); * ``` */ -export const toHaveBodySatisfyingObjectAssertion = expect.createAssertion( +const toHaveBodySatisfyingObjectAssertion = expect.createAssertion( [HttpResponseSchema, 'to have body satisfying', schema.AnyObjectSchema], (response: HttpResponse, expected: Record) => { const body = response.body; @@ -625,7 +625,7 @@ const isRedirect = (response: HttpResponse): boolean => * expect(response, 'to redirect'); * ``` */ -export const toRedirectAssertion = expect.createAssertion( +const toRedirectAssertion = expect.createAssertion( [HttpResponseSchema, 'to redirect'], (response: HttpResponse) => { if (isRedirect(response)) { @@ -649,7 +649,7 @@ export const toRedirectAssertion = expect.createAssertion( * expect(response, 'to redirect to', 'https://example.com/auth'); * ``` */ -export const toRedirectToUrlAssertion = expect.createAssertion( +const toRedirectToUrlAssertion = expect.createAssertion( [HttpResponseSchema, 'to redirect to', z.string()], (response: HttpResponse, expectedUrl: string) => { if (!isRedirect(response)) { @@ -693,7 +693,7 @@ export const toRedirectToUrlAssertion = expect.createAssertion( * expect(response, 'to redirect to', /login\?redirect=/); * ``` */ -export const toRedirectToPatternAssertion = expect.createAssertion( +const toRedirectToPatternAssertion = expect.createAssertion( [HttpResponseSchema, 'to redirect to', z.instanceof(RegExp)], (response: HttpResponse, pattern: RegExp) => { if (!isRedirect(response)) { From 1fb475cca6fd773a77a2e34297a9852d64187251 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:25:16 -0800 Subject: [PATCH 4/9] chore(supertest): address PR review feedback - Add prettier config to match other packages - Simplify redundant isJsonResponse check Co-Authored-By: Claude Opus 4.5 --- packages/supertest/package.json | 10 ++++++++++ packages/supertest/src/assertions.ts | 5 +---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/supertest/package.json b/packages/supertest/package.json index 9424c307..6f4fad59 100644 --- a/packages/supertest/package.json +++ b/packages/supertest/package.json @@ -58,6 +58,16 @@ "publishConfig": { "access": "public" }, + "prettier": { + "jsdocCommentLineStrategy": "keep", + "jsdocPreferCodeFences": true, + "plugins": [ + "prettier-plugin-jsdoc", + "prettier-plugin-pkg" + ], + "singleQuote": true, + "tsdoc": true + }, "zshy": { "exports": { ".": "./src/index.ts", diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts index e35f4090..ffe4ef0a 100644 --- a/packages/supertest/src/assertions.ts +++ b/packages/supertest/src/assertions.ts @@ -336,10 +336,7 @@ const hasBody = (response: HttpResponse): boolean => { const isJsonResponse = (response: HttpResponse): boolean => { // Check the type property (supertest normalizes this) if (response.type) { - return ( - response.type.includes('json') || - response.type.includes('application/json') - ); + return response.type.includes('json'); } // Fall back to checking headers From 9072e15366c20ed7a977ad2dedfaa1495f069abe Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:31:03 -0800 Subject: [PATCH 5/9] chore(supertest): add release-please config and monorepo docs Add @bupkis/supertest to release-please configuration files for automated versioning and to the monorepo README plugins section. --- .release-please-manifest.json | 3 ++- README.md | 3 ++- packages/supertest/package.json | 7 +++---- release-please-config.json | 6 ++++++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dee9d6f0..a7f9fffa 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -5,5 +5,6 @@ "packages/from-jest": "0.1.1", "packages/property-testing": "0.1.0", "packages/rxjs": "0.0.0", - "packages/sinon": "0.1.1" + "packages/sinon": "0.1.1", + "packages/supertest": "0.0.0" } diff --git a/README.md b/README.md index a7ff1fb3..5575e03c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

-* * * +--- This is the monorepo for [**BUPKIS**](https://bupkis.zip), the uncommonly extensible assertion library. @@ -21,6 +21,7 @@ This is the monorepo for [**BUPKIS**](https://bupkis.zip), the uncommonly extens - **[@bupkis/events](./packages/events/)** - Event emitter assertions - **[@bupkis/rxjs](./packages/rxjs/)** - RxJS Observable assertions - **[@bupkis/sinon](./packages/sinon/)** - Sinon spy/stub/mock assertions +- **[@bupkis/supertest](./packages/supertest/)** - HTTP response assertions ### Migration Tools diff --git a/packages/supertest/package.json b/packages/supertest/package.json index 6f4fad59..6753159c 100644 --- a/packages/supertest/package.json +++ b/packages/supertest/package.json @@ -21,9 +21,9 @@ "module": "./dist/index.js", "exports": { ".": { - "types": "./dist/index.d.cts", "import": "./dist/index.js", - "require": "./dist/index.cjs" + "require": "./dist/index.cjs", + "types": "./dist/index.d.cts" }, "./package.json": "./package.json" }, @@ -49,8 +49,7 @@ "test": "npm run test:base -- \"test/*.test.ts\"", "test:base": "node --import tsx --test --test-reporter=spec", "test:dev": "npm run test:base -- --watch \"test/*.test.ts\"", - "test:node20": "npm run test:base -- test/*.test.ts", - "test:types": "tsd" + "test:node20": "npm run test:base -- test/*.test.ts" }, "peerDependencies": { "bupkis": ">=0.15.0" diff --git a/release-please-config.json b/release-please-config.json index 3034e314..3e8de5c7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -44,6 +44,12 @@ "component": "@bupkis/sinon", "initial-version": "0.1.0", "release-type": "node" + }, + "packages/supertest": { + "changelog-path": "CHANGELOG.md", + "component": "@bupkis/supertest", + "initial-version": "0.1.0", + "release-type": "node" } } } From 9d5fd24f53783a9c5cd5030cce2d723e76c03000 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:33:00 -0800 Subject: [PATCH 6/9] fix(supertest): add consistent fallback for array location headers Add ?? '' fallback to line 669 to match the pattern used at line 713 for defensive coding and consistency. --- packages/supertest/src/assertions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts index ffe4ef0a..9b77e89d 100644 --- a/packages/supertest/src/assertions.ts +++ b/packages/supertest/src/assertions.ts @@ -666,7 +666,7 @@ const toRedirectToUrlAssertion = expect.createAssertion( }; } - const locationString = isArray(location) ? location[0] : location; + const locationString = isArray(location) ? (location[0] ?? '') : location; if (locationString === expectedUrl) { return true; From 32523ac255c88705324400d8465f2ce5f28daa9d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 21:38:24 -0800 Subject: [PATCH 7/9] test(supertest): add manual testing script for real HTTP responses Adds examples/manual-test.ts that spins up a test server and verifies all assertions work correctly with actual fetch responses. Tests: - Status assertions (exact and category) - Header assertions (existence, exact value, pattern) - Body assertions (string, JSON, partial match, regex) - Redirect assertions (existence, exact URL, pattern) - Negation assertions --- packages/supertest/examples/manual-test.ts | 256 +++++++++++++++++++++ packages/supertest/package.json | 1 + 2 files changed, 257 insertions(+) create mode 100644 packages/supertest/examples/manual-test.ts diff --git a/packages/supertest/examples/manual-test.ts b/packages/supertest/examples/manual-test.ts new file mode 100644 index 00000000..9f9ace77 --- /dev/null +++ b/packages/supertest/examples/manual-test.ts @@ -0,0 +1,256 @@ +/** + * Manual testing script for @bupkis/supertest with real HTTP responses. + * + * Run with: npx tsx examples/manual-test.ts + */ + +import { createServer, type Server } from 'node:http'; + +import { use } from 'bupkis'; + +import supertestAssertions from '../src/index.js'; + +const { expect } = use(supertestAssertions); + +/** + * Adapts a fetch Response to the shape expected by @bupkis/supertest. + */ +async function adaptFetchResponse(response: Response) { + const text = await response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text; + } + + // Convert Headers to plain object + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + return { + status: response.status, + headers, + body, + text, + type: response.headers.get('content-type') ?? undefined, + }; +} + +/** + * Creates a test server with various endpoints. + */ +function createTestServer(): Server { + return createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost`); + + switch (url.pathname) { + case '/json': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'hello', users: [{ id: 1 }] })); + break; + + case '/text': + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello, World!'); + break; + + case '/redirect': + res.writeHead(302, { Location: '/destination' }); + res.end(); + break; + + case '/redirect-pattern': + res.writeHead(301, { Location: '/auth/login?redirect=/dashboard' }); + res.end(); + break; + + case '/not-found': + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + break; + + case '/server-error': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + break; + + case '/custom-header': + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'X-Request-Id': 'abc-123', + 'X-Rate-Limit': '100', + }); + res.end('OK'); + break; + + default: + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('OK'); + } + }); +} + +async function runTests() { + const server = createTestServer(); + + await new Promise((resolve) => { + server.listen(0, () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to get server address'); + } + const baseUrl = `http://localhost:${address.port}`; + + console.log(`Test server running at ${baseUrl}\n`); + console.log('Running manual tests with real HTTP responses...\n'); + + let passed = 0; + let failed = 0; + + async function test(name: string, fn: () => Promise) { + try { + await fn(); + console.log(` \u2714 ${name}`); + passed++; + } catch (err) { + console.log(` \u2718 ${name}`); + console.log(` ${(err as Error).message}`); + failed++; + } + } + + // Status assertions + console.log('Status assertions:'); + await test('to have status (exact)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have status', 200); + }); + + await test('to have status (category: ok)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have status', 'ok'); + }); + + await test('to have status (category: client error)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/not-found`)); + expect(res, 'to have status', 'client error'); + }); + + await test('to have status (category: server error)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/server-error`), + ); + expect(res, 'to have status', 'server error'); + }); + + // Header assertions + console.log('\nHeader assertions:'); + await test('to have header (existence)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/custom-header`), + ); + expect(res, 'to have header', 'x-request-id'); + }); + + await test('to have header (exact value)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/custom-header`), + ); + expect(res, 'to have header', 'x-request-id', 'abc-123'); + }); + + await test('to have header (pattern)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/custom-header`), + ); + expect(res, 'to have header', 'x-rate-limit', /\d+/); + }); + + // Body assertions + console.log('\nBody assertions:'); + await test('to have body', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have body'); + }); + + await test('to have body (exact string)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/text`)); + expect(res, 'to have body', 'Hello, World!'); + }); + + await test('to have JSON body', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have JSON body'); + }); + + await test('to have JSON body satisfying', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have JSON body satisfying', { message: 'hello' }); + }); + + await test('to have body satisfying (regex)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/text`)); + expect(res, 'to have body satisfying', /Hello/); + }); + + await test('to have body satisfying (object)', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'to have body satisfying', { users: [{ id: 1 }] }); + }); + + // Redirect assertions + console.log('\nRedirect assertions:'); + await test('to redirect', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/redirect`, { redirect: 'manual' }), + ); + expect(res, 'to redirect'); + }); + + await test('to redirect to (exact URL)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/redirect`, { redirect: 'manual' }), + ); + expect(res, 'to redirect to', '/destination'); + }); + + await test('to redirect to (pattern)', async () => { + const res = await adaptFetchResponse( + await fetch(`${baseUrl}/redirect-pattern`, { redirect: 'manual' }), + ); + expect(res, 'to redirect to', /\/auth\/login/); + }); + + // Negation tests + console.log('\nNegation assertions:'); + await test('not to have status', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'not to have status', 404); + }); + + await test('not to redirect', async () => { + const res = await adaptFetchResponse(await fetch(`${baseUrl}/json`)); + expect(res, 'not to redirect'); + }); + + // Cleanup + server.close(); + + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed`); + console.log('='.repeat(40)); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/packages/supertest/package.json b/packages/supertest/package.json index 6753159c..44d218e1 100644 --- a/packages/supertest/package.json +++ b/packages/supertest/package.json @@ -49,6 +49,7 @@ "test": "npm run test:base -- \"test/*.test.ts\"", "test:base": "node --import tsx --test --test-reporter=spec", "test:dev": "npm run test:base -- --watch \"test/*.test.ts\"", + "test:manual": "tsx examples/manual-test.ts", "test:node20": "npm run test:base -- test/*.test.ts" }, "peerDependencies": { From 7323af40b83163d82acf074b67653c1f325dc8c6 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 14 Jan 2026 22:28:16 -0800 Subject: [PATCH 8/9] fix(supertest): handle circular references and fix misleading JSDoc - Wrap stringify() in try-catch to gracefully handle circular references in response bodies (returns '[unserializable body]' on failure) - Update HttpResponseSchema JSDoc to mark as @internal and remove misleading import example (schema is not exported from main entry) --- packages/supertest/src/assertions.ts | 12 +++++++++--- packages/supertest/src/schema.ts | 13 ++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/supertest/src/assertions.ts b/packages/supertest/src/assertions.ts index 9b77e89d..c8847694 100644 --- a/packages/supertest/src/assertions.ts +++ b/packages/supertest/src/assertions.ts @@ -293,9 +293,15 @@ const getBodyText = (response: HttpResponse): string | undefined => { return response.text; } if (response.body !== undefined) { - return typeof response.body === 'string' - ? response.body - : stringify(response.body); + if (typeof response.body === 'string') { + return response.body; + } + try { + return stringify(response.body); + } catch { + // Handle circular references or other unserializable values + return '[unserializable body]'; + } } return undefined; }; diff --git a/packages/supertest/src/schema.ts b/packages/supertest/src/schema.ts index 4ccc59d2..efb14c21 100644 --- a/packages/supertest/src/schema.ts +++ b/packages/supertest/src/schema.ts @@ -11,16 +11,11 @@ import { type HttpResponse, isHttpResponse } from './guards.js'; /** * Schema that validates HTTP response objects. * - * @example + * This schema is used internally by the assertions to validate that the subject + * is an HTTP response object. It uses duck typing to accept any object with a + * numeric `status` property. * - * ```ts - * import { HttpResponseSchema } from '@bupkis/supertest'; - * - * const result = HttpResponseSchema.safeParse(response); - * if (result.success) { - * // result.data is typed as HttpResponse - * } - * ``` + * @internal */ export const HttpResponseSchema = z.custom( isHttpResponse, From 68831d300344389aeefac5edf0c1002bf8ce08ea Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Thu, 15 Jan 2026 13:04:35 -0800 Subject: [PATCH 9/9] chore(supertest): normalize package.json --- packages/supertest/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/supertest/package.json b/packages/supertest/package.json index 44d218e1..3bf32e4c 100644 --- a/packages/supertest/package.json +++ b/packages/supertest/package.json @@ -21,9 +21,9 @@ "module": "./dist/index.js", "exports": { ".": { + "types": "./dist/index.d.cts", "import": "./dist/index.js", - "require": "./dist/index.cjs", - "types": "./dist/index.d.cts" + "require": "./dist/index.cjs" }, "./package.json": "./package.json" },