User manual

Test a payment decline, retry, and reset

This recipe gives a test runner a complete local workflow: an empty order collection, a declined payment, a successful retry, a denied permission, and a fresh baseline. It models client-visible outcomes; it does not verify a payment provider or production authorization.

Install and generate

Follow the project installation setup, then keep the contract, authored code, and verification script together in that project for review.

Save this as orders.yaml:

openapi: 3.0.3
info: { title: Order workflow, version: 1.0.0 }
paths:
  /orders:
    post:
      operationId: placeOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: string }
      responses:
        "201":
          description: Order placed
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id: { type: string }
        "402": { description: Payment declined }
        "403": { description: Permission denied }
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Order found
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id: { type: string }
        "404": { description: Order absent }

Generate the project once:

npx --no-install counterfact orders.yaml api --generate --no-update-check

Define the state and HTTP behavior

Replace api/routes/_.context.ts:

export class Context {
  paymentOutcome: "approved" | "declined" = "approved";
  canPlaceOrders = true;
  private orders = new Set<string>();

  placeOrder(id: string): "placed" | "declined" | "forbidden" {
    if (!this.canPlaceOrders) return "forbidden";
    if (this.paymentOutcome === "declined") return "declined";
    this.orders.add(id);
    return "placed";
  }

  hasOrder(id: string): boolean {
    return this.orders.has(id);
  }
}

Replace the generated handlers:

// api/routes/orders.ts
import type { placeOrder } from "../types/paths/orders.types.js";

export const POST: placeOrder = function ($) {
  const outcome = $.context.placeOrder($.body.id);
  if (outcome === "forbidden") return $.response[403].empty();
  if (outcome === "declined") return $.response[402].empty();
  return $.response[201].json({ id: $.body.id });
};
// api/routes/orders/{orderId}.ts
import type { getOrder } from "../../types/paths/orders/{orderId}.types.js";

export const GET: getOrder = function ($) {
  return $.context.hasOrder($.path.orderId)
    ? $.response[200].json({ id: $.path.orderId })
    : $.response[404].empty();
};

Create api/scenarios/index.ts:

import type { Scenario } from "../types/_.context.js";

export const declinedPayment: Scenario = ($) => {
  $.context.paymentOutcome = "declined";
};

export const approvedPayment: Scenario = ($) => {
  $.context.paymentOutcome = "approved";
};

export const forbiddenOrders: Scenario = ($) => {
  $.context.canPlaceOrders = false;
};

Each scenario changes only its named condition. For example, applying approvedPayment after forbiddenOrders does not restore permission. reset() is the explicit operation that rebuilds the empty collection and default flags from the constructor. One order is enough here because the test asks whether a single attempted action was recorded.

Verify over real HTTP

Save as verify-orders.mjs and run node verify-orders.mjs:

import assert from "node:assert/strict";
import { counterfact } from "counterfact";

const api = await counterfact({
  basePath: "api",
  openApiPath: "orders.yaml",
  port: 0,
  generate: { routes: false, types: false },
  watch: { routes: false, types: false },
});
const { stop } = await api.start();
try {
  const address = api.status().address;
  assert.ok(address);
  const baseUrl = `http://127.0.0.1:${address.port}`;
  const read = (id) => fetch(`${baseUrl}/orders/${id}`);
  const place = (id) =>
    fetch(`${baseUrl}/orders`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ id }),
    });
  const expectStatus = async (responsePromise, status) => {
    const response = await responsePromise;
    const body = await response.text();
    assert.equal(response.status, status, body);
    assert.equal(response.headers.get("response-type-error"), null);
  };

  await api.reset();
  await api.scenario("declinedPayment");
  await expectStatus(read("order-juniper"), 404);
  await expectStatus(place("order-juniper"), 402);
  await expectStatus(read("order-juniper"), 404);

  await api.scenario("approvedPayment");
  await expectStatus(place("order-juniper"), 201);
  const found = await read("order-juniper");
  assert.equal(found.status, 200);
  assert.deepEqual(await found.json(), { id: "order-juniper" });

  await api.scenario("forbiddenOrders");
  await api.scenario("approvedPayment");
  await expectStatus(place("order-maple"), 403);
  await expectStatus(read("order-juniper"), 200);
  await expectStatus(read("order-maple"), 404);

  await api.reset();
  await expectStatus(read("order-juniper"), 404);
  await expectStatus(place("order-maple"), 201);
  console.log("Passed: decline, retry, permission composition, and reset");
} finally {
  await stop();
}

Consuming each response before reset keeps this sequence free of outstanding work. The header assertion turns supported advisory response-validation errors into test failures; it does not add response-body validation. Assertions on JSON above cover only the behavior explicitly exercised.

For parallel CI, use one simulator per worker, keep the assigned address with that worker, and await reset before each test. Readiness from start() means the listener is bound; a representative HTTP assertion establishes that the intended application route works. Retain targeted tests against the real backend for payment semantics, permission enforcement, and other behavior the model omits.