Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 0 additions & 187 deletions packages/plugin/test/admin-rules-client.test.ts

This file was deleted.

160 changes: 160 additions & 0 deletions packages/plugin/test/commerce-client-contract.http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* The HTTP tier of `commerceClientContract` (work order 02, INC-A7 / D7 tier
* T5(a)).
*
* This file binds the transport-agnostic contract to `HttpCommerceClient` and
* the four admin HTTP clients over a LIVE `@otta-sh/service` (Postgres-backed)
* — the same harness the eight source client test files used, and the same
* `PG_CONNECTION_STRING` gating, so a run without a database skips exactly what
* it skipped before.
*
* It REPLACES the transport-agnostic cases of
* `http-commerce-client.test.ts`, `http-commerce-client-cart.test.ts` and
* `admin-rules-client.test.ts`. Each of those files keeps only its HTTP-wire
* cases (request shape, headers, base-URL joining, status→error mapping); see
* `test/contracts/README.md` for the classification rule.
*
* ⚠ THIS FILE IS DELETED AT INC-D3b together with the HTTP transport. The
* contract it invokes is what survives — INC-B10a/b/c add a second tier that
* runs the very same cases with no HTTP anywhere.
*/
import { afterAll, describe } from "vitest";
import { AdminOrdersClient } from "../src/admin/admin-orders-client.js";
import { AdminProductsClient } from "../src/admin/admin-products-client.js";
import { AdminRulesClient } from "../src/admin/admin-rules-client.js";
import { ReportingSettingsClient } from "../src/admin/reporting-client.js";
import type { CommerceClient } from "../src/product-commerce/commerce-client.js";
import { HttpCommerceClient } from "../src/product-commerce/http-commerce-client.js";
import {
adminOrdersProductsClientContract,
adminRulesReportingClientContract,
type AdminClientSurfaces,
type CommerceClientTier,
storefrontCommerceClientContract,
} from "./contracts/commerce-client-contract.js";
import { startLiveService, type LiveService } from "./helpers/start-live-service.js";

const PG = process.env.PG_CONNECTION_STRING;

/** The admin gate + write gate secrets the admin tier's service enforces —
* exactly the pair `admin-rules-client.test.ts` booted its service with. */
const ADMIN_TOKEN = "admin-secret";
const ADMIN_SERVICE_TOKEN = "svc-secret";

interface HttpTierOptions {
name: string;
/** Boot the service with the admin + write gates closed, and thread both
* tokens into every client. Omitted ⇒ a gate-open service and tokenless
* clients, which is how the storefront client tests always ran. */
gated?: boolean;
}

/**
* The HTTP tier. `arrange` programs backend state the way the source files
* already did — through the client's own writes against the live service — so
* nothing here is invented: `arrange.product` is their `seedProduct` /
* `parentProduct` helpers, and `arrange.cart` is their `createCart` setup call.
*/
function httpTier(options: HttpTierOptions): CommerceClientTier {
let service: LiveService | undefined;
let client: CommerceClient | undefined;

function baseOptions(): { fetch: typeof globalThis.fetch; baseUrl: string } {
if (service === undefined) throw new Error("tier not set up");
return { fetch: globalThis.fetch, baseUrl: service.baseUrl };
}

async function clientOrThrow(): Promise<CommerceClient> {
if (client === undefined) throw new Error("tier not set up");
return client;
}

return {
name: options.name,
async setup() {
if (service !== undefined) return; // one service per tier, however many slices ask
service = await startLiveService(
options.gated === true
? { internalToken: ADMIN_TOKEN, serviceToken: ADMIN_SERVICE_TOKEN }
: {},
);
client = new HttpCommerceClient({
...baseOptions(),
...(options.gated === true ? { serviceToken: ADMIN_SERVICE_TOKEN } : {}),
});
},
async teardown() {
if (service === undefined) return;
await service.stop();
service = undefined;
client = undefined;
},
async reset() {
// A DOCUMENTED NO-OP for this tier. The live service owns one isolated
// Postgres schema for the whole slice and the lifted cases address
// disjoint product ids, skus, cart ids and idempotency keys — which is
// how they always ran. Dropping and re-migrating a schema per case
// would be a behavioural change (and minutes of runtime) for no gained
// assertion. A tier whose backend is cheap to rebuild does the real
// thing here instead.
},
makeClient: clientOrThrow,
async makeAdminClients(): Promise<AdminClientSurfaces> {
const shared = {
...baseOptions(),
...(options.gated === true
? { adminToken: ADMIN_TOKEN, serviceToken: ADMIN_SERVICE_TOKEN }
: {}),
};
return {
orders: new AdminOrdersClient(shared),
products: new AdminProductsClient(shared),
rules: new AdminRulesClient(shared),
reporting: new ReportingSettingsClient(shared),
};
},
arrange: {
async product(spec) {
const c = await clientOrThrow();
await c.upsertProductCommerce(
spec.productId,
{
sku: spec.sku,
...(spec.price !== undefined ? { price: spec.price } : {}),
...(spec.title !== undefined ? { title: spec.title } : {}),
...(spec.onHand !== undefined ? { initialOnHand: spec.onHand } : {}),
},
spec.idempotencyKey,
);
return spec.productId;
},
async cart(currency) {
const c = await clientOrThrow();
const { cartId } = await c.createCart(currency);
return cartId;
},
},
};
}

const storefront = httpTier({ name: "http, live @otta-sh/service, Postgres" });
const admin = httpTier({ name: "http, live @otta-sh/service, Postgres", gated: true });

describe.skipIf(PG === undefined)("commerceClientContract over HttpCommerceClient", () => {
afterAll(async () => {
await storefront.teardown();
});
storefrontCommerceClientContract(storefront);
});

describe.skipIf(PG === undefined)("commerceClientContract over the admin HTTP clients", () => {
afterAll(async () => {
await admin.teardown();
});
adminRulesReportingClientContract(admin);
// Bound to the GATED tier like its sibling slice: the admin surface needs both
// the admin gate and the write gate closed, or INC-B10b's first case would be
// written against a service that never enforces them. It contributes no cases
// yet — it only holds this tier to the slice's requirements.
adminOrdersProductsClientContract(admin);
});
40 changes: 40 additions & 0 deletions packages/plugin/test/contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# `commerceClientContract`

The behavioural spec of the commerce client surface, expressed so **more than one transport can run
it**. Extracted from the HTTP client's own test files (INC-A7); it survives the deletion of
`HttpCommerceClient`, the four admin HTTP clients and both harnesses at the service-removal
increment — `commerce-client-contract.http.test.ts` is the tier that dies then, this directory is
not. Three slices: `storefrontCommerceClientContract` (the 25-method `CommerceClient`),
`adminOrdersProductsClientContract`, `adminRulesReportingClientContract` — one per INC-B10a/b/c.

## The tier interface

All a transport supplies: `name` (labels every `describe`), `setup()`/`teardown()` (once per
slice), `reset()`, `makeClient()`, `makeAdminClients()`, `arrange.product(spec)` (one commerce row
— sku plus optional price in integer minor units, title, on-hand), `arrange.cart(currency?)`.

- `makeAdminClients()` is **optional**. The storefront slice never asks; an admin slice handed a
tier without it throws at collection rather than running empty.
- `reset()` may be a no-op **only while every case uses disjoint ids and no case depends on
another's leftovers**, true today; the first real one lands at INC-B10a with the in-process tier.
- No clock/id/hold-expiry hooks: the cases pass watermarks and idempotency keys explicitly.
- **Which seeding path:** a case whose *subject* is a write method calls it directly —
`upsertProductCommerce`, `createCart`, `addCartLine` are under test in their own cases and must
not hide behind `arrange`. A case that merely needs a product or cart uses `tier.arrange.*`.

## Classification rule

**Transport-agnostic** (→ contract) when the assertion is about the client's *method* contract:
inputs, returned values, typed result tokens, typed rejections, idempotency replay, money as
integer minor units, snapshot semantics. **HTTP-wire-specific** (→ the transport's own file) when
it asserts request shape or method, any header (gate tokens included), base-URL joining or path
encoding, status → error mapping, retry on 5xx, `allowedHosts` egress, or a stub server's recorded
requests. Never weaken an assertion to move it; a case may **split** instead — the quote cases
assert computed totals and typed reason through `quoteCheckout` here, and only the HTTP status
stays behind. The HTTP tier's stub *server* and live service stand in for the **wire**, never for a
database (real databases, never mocks) — the wire being the one thing this contract ignores.

**Rejections are forward-looking.** Every failure the lifted cases assert is a typed result value,
so the contract holds no rejection assertion yet — the two that asserted a thrown error asserted an
HTTP status with it and stayed behind. The gap cases must assert an *awaited* rejection, never a
synchronous throw.
Loading
Loading