From 5a046a311febd5b355e5c19f2d5a6ee2c7647377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tu=C4=9Fkan=20Boz?= Date: Wed, 3 Jun 2026 13:26:21 +0300 Subject: [PATCH] feat: make extended HTTP assertions chainable on the request builder http-assertions.js now also installs queueing wrappers on RequestBuilder for the extended assertions, so they can be chained before awaiting and replay on the resolved response. expectValue is excluded since it returns an Expectation. Types and a unit test added. --- CHANGELOG.md | 8 +++++ src/client.d.ts | 24 +++++++++++++ src/http-assertions.js | 20 +++++++++++ test/unit/builder-assertions.test.mjs | 52 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 test/unit/builder-assertions.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 032b814..7c0808a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project are documented here. This project follows ## [Unreleased] +### Changed + +- The extended HTTP assertions (`expectClientError`, `expectJsonSchema`, + `expectJsonContains`, `expectCookie`, `expectSorted`, and the rest) are now + chainable on the request builder too, not only on a resolved response. So + `api.get("/x").expectStatus(200).expectJsonSchema(schema)` works. `expectValue` + stays response-only since it returns an `expect()`. + ### Added - **BDD layer** (`two-go/bdd`): runner-agnostic `given` / `when` / `then` / diff --git a/src/client.d.ts b/src/client.d.ts index 6bdf5e4..5fd23e5 100644 --- a/src/client.d.ts +++ b/src/client.d.ts @@ -61,6 +61,30 @@ export declare class RequestBuilder implements PromiseLike { expectTimeBelow(ms: number): this; check(fn: (response: GoResponse) => unknown): this; + // --- extended HTTP assertions, also chainable here (added by http-assertions.js) --- + expectClientError(): this; + expectServerError(): this; + expectRedirect(): this; + expectCreated(): this; + expectAccepted(): this; + expectNoContent(): this; + expectBadRequest(): this; + expectUnauthorized(): this; + expectForbidden(): this; + expectNotFound(): this; + expectContentType(type: string): this; + expectHeaderContains(name: string, substr: string): this; + expectHeaderAbsent(name: string): this; + expectJsonSchema(schema: unknown): this; + expectJsonLength(path: string, n: number): this; + expectJsonContains(path: string, value: unknown): this; + expectArrayLength(path: string, n: number): this; + expectSorted(path: string, options?: { key?: string; order?: "asc" | "desc" }): this; + expectCookie(name: string, matcher?: unknown): this; + expectBodyContains(substr: string): this; + expectEmpty(): this; + expectNotEmpty(): this; + // --- run + thenable --- run(): Promise; diff --git a/src/http-assertions.js b/src/http-assertions.js index 6608b25..cebef8d 100644 --- a/src/http-assertions.js +++ b/src/http-assertions.js @@ -6,6 +6,7 @@ // with messages formatted as `${this.method} ${this.url} -> `. import { GoResponse } from "./response.js"; +import { RequestBuilder } from "./client.js"; import { AssertionError, resolvePath, matches } from "./assertions.js"; import { validate } from "./schema.js"; import { expect } from "./expect.js"; @@ -411,3 +412,22 @@ for (const [name, fn] of Object.entries(methods)) { configurable: true, }); } + +// Also make these assertions chainable on the (thenable) RequestBuilder, so you +// can write api.get("/x").expectStatus(200).expectJsonSchema(schema) instead of +// awaiting first. They are queued and replayed on the resolved GoResponse, just +// like the core assertions. expectValue is excluded because it returns an +// Expectation rather than the response, so it only makes sense post-await. +for (const name of Object.keys(methods)) { + if (name === "expectValue") continue; + if (typeof RequestBuilder.prototype[name] === "function") continue; + Object.defineProperty(RequestBuilder.prototype, name, { + value: function (...args) { + this._assertions.push({ name, args }); + return this; + }, + writable: true, + enumerable: false, + configurable: true, + }); +} diff --git a/test/unit/builder-assertions.test.mjs b/test/unit/builder-assertions.test.mjs new file mode 100644 index 0000000..975fe62 --- /dev/null +++ b/test/unit/builder-assertions.test.mjs @@ -0,0 +1,52 @@ +// The extended HTTP assertions should be chainable on the (thenable) builder, +// not only on a resolved GoResponse. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { go } from "../../src/index.js"; + +let server; +let base; + +test.before(async () => { + server = http.createServer((req, res) => { + if (req.url === "/users") { + res.writeHead(201, { "content-type": "application/json", "set-cookie": "sid=abc" }); + res.end(JSON.stringify({ data: [{ id: 1 }, { id: 2 }], count: 2 })); + } else { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + } + }); + await new Promise((r) => server.listen(0, r)); + base = `http://localhost:${server.address().port}`; +}); + +test.after(() => server.close()); + +test("extended assertions chain on the builder and replay on the response", async () => { + await go(base) + .post("/users") + .json({ name: "Ada" }) + .expectStatus(201) + .expectCreated() + .expectContentType("json") + .expectJsonSchema({ type: "object", required: ["data", "count"] }) + .expectJsonLength("data", 2) + .expectJsonContains("data", { id: 2 }) + .expectCookie("sid"); +}); + +test("a queued extended assertion still fails when it should", async () => { + await assert.rejects( + () => go(base).get("/users").expectNotFound(), + /404/ + ); +}); + +test("expectValue is not queued on the builder (only on the response)", async () => { + const builder = go(base).get("/users"); + assert.equal(typeof builder.expectValue, "undefined"); + const res = await builder; + res.expectValue("count").toBe(2); +});