From fa373877d11e8ff53c8aa6e3bac512df1d9ac9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tu=C4=9Fkan=20Boz?= Date: Wed, 3 Jun 2026 14:06:18 +0300 Subject: [PATCH] feat: graphql helper, request retry/backoff, cookie jar (v1.1.0) - client.graphql(query, variables, { path }) posts a GraphQL query. - builder .retry({ attempts, delay, factor, on }) retries on error or predicate. - go({ cookies: true }) captures Set-Cookie and replays Cookie per client. - Types, unit tests, CHANGELOG; version 1.1.0. --- CHANGELOG.md | 29 ++++++---- package.json | 2 +- src/client.d.ts | 17 ++++++ src/client.js | 70 ++++++++++++++++++++++- src/graphql.js | 16 ++++++ src/index.js | 2 + test/unit/graphql-retry-cookies.test.mjs | 72 ++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 src/graphql.js create mode 100644 test/unit/graphql-retry-cookies.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e8dc5..80c3ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,26 +3,31 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). -## [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()`. +## [1.1.0] ### Added +- **GraphQL helper**: `client.graphql(query, variables, { path })` POSTs + `{ query, variables }` and returns the usual chainable builder. +- **Request retry/backoff**: `.retry({ attempts, delay, factor, on })` on the + builder retries on a thrown error or when `on(response)` is truthy. +- **Cookie jar**: `go({ cookies: true })` (or pass a `Map`) captures Set-Cookie + and replays Cookie on later requests from that client. - **Reporters** (`two-go/reporters`): `toJUnit(result)` and `toJSON(result)` turn a `run()` result into CI-friendly output. The CLI gained `--reporter junit|json [--out ]`, and `run()` now returns a `tests` array with per-test status, duration, and error. - **BDD layer** (`two-go/bdd`): runner-agnostic `given` / `when` / `then` / - `and` plus `scenario(steps)` and `feature(...)`. `scenario` returns an async - function for your runner's `test()`, with steps sharing a `world`. Does not - import any runner, so it works with node:test, Jest, Vitest, and Mocha. + `and` plus `scenario(steps)` and `feature(...)`. Works with node:test, Jest, + Vitest, and Mocha. + +### 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()`. ## [1.0.0] - 2026-06-02 diff --git a/package.json b/package.json index c1b7b66..bd91aa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "two-go", - "version": "1.0.0", + "version": "1.1.0", "description": "Zero-dependency fluent service/API testing library: chainable HTTP requests, Jest-style + HTTP assertions, and a lodash-inspired utility belt. Works standalone or inside node:test, Jest, Vitest, and Mocha.", "type": "module", "main": "src/index.js", diff --git a/src/client.d.ts b/src/client.d.ts index 5fd23e5..b3b500f 100644 --- a/src/client.d.ts +++ b/src/client.d.ts @@ -5,6 +5,15 @@ export interface GoClientOptions { baseURL?: string; headers?: Record; timeout?: number; + /** Enable a cookie jar (true) or supply your own Map of name to value. */ + cookies?: boolean | Map; +} + +export interface RetryOptions { + attempts?: number; + delay?: number; + factor?: number; + on?: (response: GoResponse) => boolean; } export type HttpMethod = @@ -32,6 +41,13 @@ export declare class GoClient { options(path: string): RequestBuilder; send(req: RequestBuilder): Promise; + + /** POST a GraphQL query (added by graphql.js). */ + graphql( + query: string, + variables?: Record, + options?: { path?: string } + ): RequestBuilder; } export declare class RequestBuilder implements PromiseLike { @@ -50,6 +66,7 @@ export declare class RequestBuilder implements PromiseLike { form(obj: Record | URLSearchParams): this; text(str: string): this; timeout(ms: number): this; + retry(options?: RetryOptions): this; // --- queued assertions (chainable) --- expectStatus(status: number): this; diff --git a/src/client.js b/src/client.js index d07bb6b..69db4c1 100644 --- a/src/client.js +++ b/src/client.js @@ -5,11 +5,15 @@ import { GoResponse } from "./response.js"; export class GoClient { - constructor({ baseURL = "", headers = {}, timeout = 30000 } = {}) { + constructor({ baseURL = "", headers = {}, timeout = 30000, cookies = false } = {}) { // Strip a single trailing slash from the base URL. this.baseURL = String(baseURL).replace(/\/+$/, ""); this.timeout = timeout; + // Optional cookie jar: captures Set-Cookie and replays Cookie on later + // requests from this client. Pass cookies: true, or your own Map. + this._jar = cookies instanceof Map ? cookies : cookies ? new Map() : null; + // Lowercase default header keys for consistent merging. this.headers = {}; for (const [key, value] of Object.entries(headers)) { @@ -32,6 +36,11 @@ export class GoClient { // Merge default headers with per-request headers (all lowercase keys). const headers = { ...this.headers, ...req._headers }; + // Attach stored cookies unless the request already set its own. + if (this._jar && this._jar.size > 0 && headers.cookie === undefined) { + headers.cookie = [...this._jar].map(([k, v]) => `${k}=${v}`).join("; "); + } + const controller = new AbortController(); const timeout = req._timeout ?? this.timeout; const timer = setTimeout(() => controller.abort(), timeout); @@ -62,6 +71,22 @@ export class GoClient { resHeaders[key.toLowerCase()] = value; } + // Capture Set-Cookie into the jar (use getSetCookie so multiple cookies + // are not lost to header joining). + if (this._jar) { + const setCookies = + typeof res.headers.getSetCookie === "function" + ? res.headers.getSetCookie() + : resHeaders["set-cookie"] + ? [resHeaders["set-cookie"]] + : []; + for (const cookie of setCookies) { + const pair = String(cookie).split(";")[0]; + const eq = pair.indexOf("="); + if (eq > 0) this._jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } + const text = await res.text(); // Parse JSON when the content type indicates JSON; fall back to text. @@ -116,6 +141,39 @@ function ensureLeadingSlash(path) { return path.startsWith("/") ? path : "/" + path; } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Send a builder with retry/backoff. Retries on a thrown error, or when the +// retry's on(response) predicate returns truthy, until attempts run out. +async function sendWithRetry(builder) { + const r = builder._retry || {}; + const attempts = r.attempts != null ? r.attempts : 3; + const factor = r.factor != null ? r.factor : 2; + let delay = r.delay != null ? r.delay : 0; + let lastError; + + for (let i = 0; i < attempts; i += 1) { + const isLast = i === attempts - 1; + try { + const res = await builder.client.send(builder); + if (!isLast && typeof r.on === "function" && r.on(res)) { + await sleep(delay); + delay *= factor; + continue; + } + return res; + } catch (err) { + lastError = err; + if (isLast) throw err; + await sleep(delay); + delay *= factor; + } + } + throw lastError; +} + // The set of assertion method names that can be queued on a RequestBuilder. // Each one maps to a same-named method on GoResponse. const QUEUEABLE = [ @@ -139,6 +197,7 @@ export class RequestBuilder { this._query = {}; this._body = undefined; this._timeout = undefined; + this._retry = undefined; // Queued assertions, replayed in order against the GoResponse. this._assertions = []; @@ -193,11 +252,18 @@ export class RequestBuilder { return this; } + // Retry the send on a thrown error (network/timeout) or when on(response) is + // truthy. options: { attempts = 3, delay = 0, factor = 2, on }. + retry(options = {}) { + this._retry = options; + return this; + } + // --- run + thenable --- // Send the request, then replay every queued assertion in order. async run() { - const response = await this.client.send(this); + const response = this._retry ? await sendWithRetry(this) : await this.client.send(this); for (const { name, args } of this._assertions) { response[name](...args); } diff --git a/src/graphql.js b/src/graphql.js new file mode 100644 index 0000000..82cc388 --- /dev/null +++ b/src/graphql.js @@ -0,0 +1,16 @@ +// Adds a graphql() helper to GoClient. It POSTs { query, variables } as JSON +// and returns the usual thenable RequestBuilder, so you can chain assertions. +// +// await go(endpoint).graphql("{ user { id } }").expectStatus(200) +// .expectJson("data.user.id", 1); +import { GoClient } from "./client.js"; + +Object.defineProperty(GoClient.prototype, "graphql", { + value: function graphql(query, variables, options = {}) { + const path = options.path || ""; + return this.post(path).json({ query, variables: variables || {} }); + }, + writable: true, + enumerable: false, + configurable: true, +}); diff --git a/src/index.js b/src/index.js index 427b29f..9fb6bdc 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,8 @@ import { chain } from "./utils/chain.js"; // Side-effect import: augments GoResponse.prototype with the extra HTTP // assertions (expectClientError, expectJsonSchema, expectValue, ...). import "./http-assertions.js"; +// Side-effect import: adds GoClient.prototype.graphql. +import "./graphql.js"; // Differentiating features (API-testing specific, beyond a utility belt). import { soft, softly } from "./soft.js"; diff --git a/test/unit/graphql-retry-cookies.test.mjs b/test/unit/graphql-retry-cookies.test.mjs new file mode 100644 index 0000000..78140d3 --- /dev/null +++ b/test/unit/graphql-retry-cookies.test.mjs @@ -0,0 +1,72 @@ +// Tests for the cookie jar, request retry, and the graphql() helper. +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; +let flakyHits = 0; + +test.before(async () => { + server = http.createServer((req, res) => { + if (req.method === "POST" && req.url === "/login") { + res.writeHead(200, { "content-type": "application/json", "set-cookie": "sid=abc123" }); + return res.end(JSON.stringify({ ok: true })); + } + if (req.method === "GET" && req.url === "/me") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ cookie: req.headers.cookie || null })); + } + if (req.method === "GET" && req.url === "/flaky") { + flakyHits += 1; + const status = flakyHits < 3 ? 503 : 200; + res.writeHead(status, { "content-type": "application/json" }); + return res.end(JSON.stringify({ attempt: flakyHits })); + } + if (req.method === "POST" && req.url === "/graphql") { + let raw = ""; + req.on("data", (c) => { raw += c; }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ echo: JSON.parse(raw || "{}") })); + }); + return; + } + 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("cookie jar replays a Set-Cookie on later requests", async () => { + const api = go({ baseURL: base, cookies: true }); + await api.post("/login").expectStatus(200); + await api.get("/me").expectStatus(200).expectJson("cookie", "sid=abc123"); +}); + +test("without the jar, cookies are not carried over", async () => { + const api = go(base); // no cookie jar + await api.post("/login").expectStatus(200); + await api.get("/me").expectJson("cookie", null); +}); + +test("retry recovers from transient 5xx responses", async () => { + flakyHits = 0; + const res = await go(base) + .get("/flaky") + .retry({ attempts: 3, delay: 1, on: (r) => r.status >= 500 }) + .expectStatus(200); + res.expectJson("attempt", 3); +}); + +test("graphql posts query and variables as JSON", async () => { + const res = await go(base) + .graphql("{ user { id } }", { id: 1 }, { path: "/graphql" }) + .expectStatus(200); + res.expectJson("echo.query", "{ user { id } }"); + res.expectJson("echo.variables.id", 1); +});