# Gateway test recipes

The interesting assertions at a gateway are the rejections. A suite that only
sends well-formed requests with correct credentials passes while a policy is
attached to the wrong route — because the request still succeeds, for the wrong
caller.

Each recipe below is a complete file you can drop into `tests/`. They assume the
setup from [Get started with zuplo test](./testing-getting-started.mdx), which
installs chai for the assertions. The two schema recipes add one library each
and say so inline.

All of them use the same helper for building URLs:

```ts
const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
```

## Auth rejections

Test every way a request can be turned away, and finish with the case that
matters most: a valid token from one tenant asking for another tenant's object.
Broken object-level authorization is invisible to happy-path testing because the
request succeeds.

```ts title="/tests/auth.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

describe("auth policy", () => {
  it("rejects requests with no token", async () => {
    const response = await fetch(url("/v1/orders/ord_1001"));
    expect(response.status).to.equal(401);
  });

  it("rejects expired tokens", async () => {
    const response = await fetch(url("/v1/orders/ord_1001"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.EXPIRED_JWT}`,
      },
    });
    expect(response.status).to.equal(401);
  });

  it("rejects tokens issued for a different audience", async () => {
    const response = await fetch(url("/v1/orders/ord_1001"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.WRONG_AUDIENCE_JWT}`,
      },
    });
    expect(response.status).to.equal(401);
  });

  it("does not serve another tenant's order", async () => {
    // Tenant A's valid token, tenant B's order ID.
    const response = await fetch(url("/v1/orders/ord_2002"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
      },
    });
    expect([403, 404]).to.include(response.status);
  });
});
```

Run the same file against every route that shares the policy. A route added
later without the policy attached is exactly the defect this catches.

## Rate limits, deterministically

Most rate limit tests are flaky for two reasons: tests share a bucket, and tests
sleep through real time windows. Fix both.

Give each test its own bucket by generating a fresh value for whatever the
policy keys on — an API key, a client ID, a header. Then send requests one at a
time and stop at the first `429`.

```ts title="/tests/rate-limit.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

describe("rate limit policy", () => {
  it("returns 429 with Retry-After when the limit is exceeded", async () => {
    // A unique bucket per run: no other test, and no previous run of this
    // test, can have consumed any of this budget.
    const clientId = `rl-test-${crypto.randomUUID()}`;

    let response!: Response;
    for (let i = 0; i < 50; i++) {
      response = await fetch(url("/v1/search?q=gateways"), {
        headers: { "client-id": clientId },
      });
      if (response.status === 429) break;
    }

    expect(response.status).to.equal(429);
    expect(response.headers.get("retry-after")).to.not.be.null;
  });
});
```

Four details are load-bearing:

- **Point the test at a low limit.** Test a route configured for a handful of
  requests, not your production 10,000/minute tier. The behavior you are
  verifying — that the policy is attached, keyed correctly, and returns the
  right response — is identical either way. A test that has to send ten thousand
  requests to prove it is slow, expensive, and unkind to shared infrastructure.
  If your real limits are high, add a test-only route with a small limit rather
  than exercising the big one.
- **A generous upper bound, not the exact limit.** Asserting "request N succeeds
  and request N+1 fails" breaks the moment the configured limit changes or
  anything else touches the window.
- **Sequential, not concurrent.** Firing a burst with `Promise.all` can
  under-count against a distributed bucket, so the burst sometimes slips through
  and the test fails for no reason you can reproduce.
- **Assert the whole contract.** The status and the `Retry-After` header, both
  of which are settled standards from RFC 6585.

### Distributed counters are eventually consistent

Rate limiting runs at the edge, across regions, and the counter behind it
converges rather than updating everywhere at once. Two consequences for tests:

- **Never assert an exact count.** "The 11th request is the one that 429s" is
  not a property the system guarantees. A request that lands in a region whose
  view of the counter is a moment stale can succeed past the nominal limit, and
  a retry can be counted twice. Assert that a 429 arrives _within a bounded
  number of attempts_, which is what the loop above does.
- **Give propagation a moment.** Immediately after the first request opens a
  window, a request served from a different region may not see it yet. If a test
  needs the counter to be shared, poll for the condition with a deadline instead
  of sleeping a fixed amount and hoping.

The same reasoning applies to anything else backed by distributed state —
caches, quotas, and metering all trade exactness for latency. Test the
_behavior_ (a limit is enforced, a cached response is returned) rather than the
arithmetic.

:::caution

Do not assert on `RateLimit-Limit`, `RateLimit-Remaining`, or `RateLimit-Reset`.
Zuplo's [Rate Limit policy](../policies/rate-limit-inbound.mdx) emits
`Retry-After` and nothing else, so those assertions fail. They are also still an
IETF Internet-Draft rather than a standard, and their names and semantics have
changed between draft revisions.

:::

## Response shape

Asserting a couple of fields leaves most of the response untested. A schema
checks the whole body in one line and tells you exactly which field is wrong
when it fails.

[Zod](https://zod.dev) is the recommended way to do this. The schema is ordinary
TypeScript, so there is no second language to learn and no build step:

```bash
npm install --save-dev zod
```

```ts title="/tests/order-shape.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";
import { z } from "zod";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

const Order = z.object({
  id: z.string(),
  status: z.enum(["pending", "shipped", "cancelled"]),
  total: z.number(),
  customerId: z.string(),
  note: z.string().optional(),
});

describe("order shape", () => {
  it("returns a well-formed order", async () => {
    const response = await fetch(url("/v1/orders/ord_1001"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
      },
    });
    expect(response.status).to.equal(200);

    const result = Order.safeParse(await response.json());
    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be
      .true;
  });
});
```

Two things to know. `safeParse` returns a result rather than throwing, so the
assertion message can carry every problem at once instead of only the first. And
`z.infer<typeof Order>` gives you a TypeScript type for free, so the rest of the
test is typed against the same definition it validated.

:::tip

Zod is strict about extra keys only if you ask. By default, unknown properties
are stripped and the parse still succeeds, which is usually what you want at a
gateway — an upstream adding a field should not fail your suite. Opt into a
strict object schema when you specifically want an unexpected field to fail.

:::

## OpenAPI conformance

The recipe above tests against a shape you wrote by hand, which can drift from
the spec. When you want `config/routes.oas.json` itself to be the oracle — it
**is** the gateway configuration, so this closes the loop between the spec and
the deployed behavior — validate against the document instead.

That means JSON Schema, and Zod is the wrong tool for it. Zod schemas are
authored in TypeScript, not derived from a JSON Schema document. Use a JSON
Schema validator:

```bash
npm install --save-dev ajv
```

```ts title="/tests/conformance.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";
import Ajv from "ajv";
import oas from "../config/routes.oas.json";

const ajv = new Ajv({ strict: false });
const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

const orderSchema =
  oas.paths["/v1/orders/{orderId}"].get.responses["200"].content[
    "application/json"
  ].schema;

describe("OpenAPI conformance", () => {
  it("GET /v1/orders/{orderId} matches its declared schema", async () => {
    const response = await fetch(url("/v1/orders/ord_1001"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
      },
    });
    expect(response.status).to.equal(200);

    const body = await response.json();
    const valid = ajv.validate(orderSchema, body);
    expect(valid, ajv.errorsText(ajv.errors)).to.be.true;
  });
});
```

The path, method, and status key in the `orderSchema` lookup must exist in your
own `routes.oas.json` — TypeScript infers the type of an imported JSON module
from the file's actual contents, so a typo is a compile error rather than a
runtime surprise.

Pick one: Zod when you want a readable assertion about a response, Ajv when you
want the spec to be the thing under test.

## The error contract

Errors are part of your API's contract, and they are the part a gateway is most
likely to change without anyone noticing. A policy swap can turn a structured
problem response into a bare 500, and no dashboard flags it.

Zuplo returns [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details
with a `content-type` of `application/problem+json`. Assert the shape, not the
status alone:

```ts title="/tests/errors.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";
import { z } from "zod";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

// RFC 9457 problem details.
const Problem = z.object({
  type: z.string(),
  title: z.string(),
  status: z.number(),
  detail: z.string(),
});

describe("error contract", () => {
  it("returns problem+json for a malformed body", async () => {
    const response = await fetch(url("/v1/orders"), {
      method: "POST",
      headers: {
        Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({ quantity: "several" }),
    });

    expect(response.status).to.equal(400);
    expect(response.headers.get("content-type")).to.include(
      "application/problem+json",
    );

    const result = Problem.safeParse(await response.json());
    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be
      .true;
  });
});
```

A hand-rolled `["type", "title"].every((key) => key in problem)` passes on
`{ type: 123 }` and, when it does fail, tells you only that `true` was expected.
The schema checks the types too and names the offending field.

## Routing and CORS

Routing bugs are cheap to catch and expensive to miss. Assert that unknown paths
return 404, that a deprecated alias still resolves, and that the preflight
response carries the headers a browser needs.

```ts title="/tests/routing.test.ts"
import { describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

describe("routing", () => {
  it("404s an unknown path", async () => {
    const response = await fetch(url("/v1/does-not-exist"));
    expect(response.status).to.equal(404);
  });

  it("keeps the legacy alias working", async () => {
    const response = await fetch(url("/orders/ord_1001"), {
      headers: {
        Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
      },
    });
    expect(response.status).to.equal(200);
  });

  it("answers a CORS preflight", async () => {
    const response = await fetch(url("/v1/orders"), {
      method: "OPTIONS",
      headers: {
        Origin: "https://example.com",
        "Access-Control-Request-Method": "GET",
      },
    });
    expect(response.headers.get("access-control-allow-origin")).to.exist;
  });
});
```

An alias route is worth its own test for the same reason as the cross-tenant
case: it is the route most likely to be missing a policy that the canonical
route has.

## Test data hygiene

Two tests that touch the same order, the same API key, or the same rate-limit
bucket eventually run at the same time and corrupt each other. Test files run in
parallel by default.

Each test creates what it needs through the API, owns it exclusively, and cleans
up after itself:

```ts title="/tests/orders.test.ts"
import { afterEach, describe, it, TestHelper } from "@zuplo/test";
import { expect } from "chai";

const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
const auth = {
  Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
};

describe("orders", () => {
  const created: string[] = [];

  afterEach(async () => {
    while (created.length > 0) {
      await fetch(url(`/v1/orders/${created.pop()}`), {
        method: "DELETE",
        headers: auth,
      });
    }
  });

  it("returns an order it created", async () => {
    const create = await fetch(url("/v1/orders"), {
      method: "POST",
      headers: { ...auth, "content-type": "application/json" },
      body: JSON.stringify({ sku: "widget", quantity: 1 }),
    });
    expect(create.status).to.equal(201);

    const { id } = await create.json();
    created.push(id);

    const read = await fetch(url(`/v1/orders/${id}`), { headers: auth });
    expect(read.status).to.equal(200);
  });
});
```

Two rules follow from this:

- **No order dependency.** If test B passes only because test A ran first, you
  have one long test with a hidden seam, and any filter or reorder exposes it.
- **A retry is not a fix.** If a test needs a rerun to pass, it has a shared
  resource, a sleep, or the pipeline is reporting readiness before the gateway
  is serving. See [testing GitHub deployments](./github-deployment-testing.mdx)
  for the readiness poll.

## Related

- [Get started with zuplo test](./testing-getting-started.mdx)
- [Testing preview environments](./testing-preview-environments.mdx)
- [Testing overview](./testing.mdx)
