@suluk/testgen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/generate.ts +181 -0
- package/src/index.ts +8 -0
- package/src/money.ts +121 -0
- package/test/testgen.test.ts +121 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/testgen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate a DETERMINISTIC conformance test suite from a v4 'Suluk' contract — the executable form of the contract's claims. Asserts the SERVER ENFORCES x-suluk-access on the real wire (anon rejected on non-public ops; public ops reachable), smoke-tests declared statuses, validates 2xx bodies against their declared schemas, and checks every declared cost is well-formed. A pure function of the document — same contract in, same suite out, no network at generate-time. CANDIDATE tooling.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/testgen"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/core": "^0.1.7"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@cfworker/json-schema": "^4.1.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@cfworker/json-schema": {
|
|
29
|
+
"optional": false
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "latest",
|
|
34
|
+
"@suluk/stripe": "^0.1.2"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit -p ."
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a DETERMINISTIC conformance test suite from a v4 "Suluk" document — the contract's claims made
|
|
3
|
+
* EXECUTABLE (council whuovh6gs, L2; the executable form of the C022 access subset-checker). The generated suite,
|
|
4
|
+
* run against a deployment, asserts:
|
|
5
|
+
* • ACCESS ENFORCEMENT, on the REAL WIRE (the ceiling-raiser): the server actually enforces x-suluk-access — anon
|
|
6
|
+
* gets NO success on a non-public op; a public op is NOT auth-blocked for anon. x-suluk-access is used only as
|
|
7
|
+
* the EXPECTATION; the test passes iff the WIRE agrees with it. It never asserts over a projection, and never
|
|
8
|
+
* treats the facet as if it were the enforcement (C022 inv.3).
|
|
9
|
+
* • STATUS SMOKE + SCHEMA CONFORMANCE (L1): a public, parameter-free GET returns a declared status, and its 2xx
|
|
10
|
+
* body validates against the declared response schema (same generic engine the SDK uses).
|
|
11
|
+
* • COST DECLARED (L2, static): every op that prices itself declares a WELL-FORMED x-suluk-cost — never a literal
|
|
12
|
+
* µ$ amount (that couples tests to billing internals and goes flaky).
|
|
13
|
+
* A pure function of the document: same contract in, same suite out, NO network at generate-time. Each op's tests
|
|
14
|
+
* are LABELLED with its provenance (x-suluk-source) so a failure points at the authoring source.
|
|
15
|
+
*/
|
|
16
|
+
import type { OpenAPIv4Document, SulukSource } from "@suluk/core";
|
|
17
|
+
|
|
18
|
+
interface AccessFacet { requires?: string; scope?: string }
|
|
19
|
+
interface CostModel { estimateMicroUsd?: number; components?: { microUsd?: number }[] }
|
|
20
|
+
interface RawReq {
|
|
21
|
+
method: string; summary?: string; contentSchema?: unknown;
|
|
22
|
+
parameterSchema?: { path?: unknown; query?: unknown; body?: unknown };
|
|
23
|
+
responses?: Record<string, { status: string | number; contentSchema?: unknown }>;
|
|
24
|
+
["x-suluk-cost"]?: CostModel; ["x-suluk-access"]?: AccessFacet; ["x-suluk-source"]?: SulukSource;
|
|
25
|
+
}
|
|
26
|
+
interface OpInfo {
|
|
27
|
+
name: string; method: string; uri: string; filledPath: string; requires: string;
|
|
28
|
+
declaredStatuses: number[]; okSchema?: unknown; cost: number | null; source?: SulukSource;
|
|
29
|
+
hasBody: boolean; paramFree: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fillPath = (uri: string) => ("/" + uri.replace(/^\/+/, "")).replace(/\{\+?([^}?&]+)\}/g, "1");
|
|
33
|
+
const requiredKeys = (s: unknown): string[] => (s && typeof s === "object" && Array.isArray((s as { required?: unknown }).required) ? ((s as { required: string[] }).required) : []);
|
|
34
|
+
function costOf(req: RawReq): number | null {
|
|
35
|
+
const c = req["x-suluk-cost"]; if (!c) return null;
|
|
36
|
+
return c.estimateMicroUsd ?? (c.components ?? []).reduce((s, x) => s + Number(x.microUsd ?? 0), 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walkOps(doc: OpenAPIv4Document): OpInfo[] {
|
|
40
|
+
const ops: OpInfo[] = [];
|
|
41
|
+
for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
42
|
+
const pi = piRaw as { requests?: Record<string, RawReq> };
|
|
43
|
+
for (const [name, req] of Object.entries(pi.requests ?? {})) {
|
|
44
|
+
const method = req.method.toUpperCase();
|
|
45
|
+
const ps = req.parameterSchema ?? {};
|
|
46
|
+
const statuses = Object.values(req.responses ?? {}).map((r) => Number(r.status)).filter((n) => Number.isFinite(n));
|
|
47
|
+
const ok = Object.values(req.responses ?? {}).find((r) => String(r.status).startsWith("2") && r.contentSchema != null);
|
|
48
|
+
const hasPathParam = /\{/.test(uri);
|
|
49
|
+
const reqQuery = requiredKeys(ps.query);
|
|
50
|
+
ops.push({
|
|
51
|
+
name, method, uri, filledPath: fillPath(uri),
|
|
52
|
+
requires: req["x-suluk-access"]?.requires ?? "anyone",
|
|
53
|
+
declaredStatuses: [...new Set(statuses)].sort((a, b) => a - b),
|
|
54
|
+
okSchema: ok?.contentSchema, cost: costOf(req), source: req["x-suluk-source"],
|
|
55
|
+
hasBody: req.contentSchema != null || ps.body != null,
|
|
56
|
+
paramFree: method === "GET" && !hasPathParam && reqQuery.length === 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ops.sort((a, b) => (a.uri + a.name).localeCompare(b.uri + b.name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Embed a response schema as a self-contained literal: splice components in as $defs + rewrite pointers, so the
|
|
64
|
+
// generic validator resolves $ref without the whole document (same trick @suluk/sdk uses).
|
|
65
|
+
function schemaLiteral(doc: OpenAPIv4Document, s: unknown): string {
|
|
66
|
+
const defs = (doc.components?.schemas ?? {}) as Record<string, unknown>;
|
|
67
|
+
const str = JSON.stringify(s ?? {});
|
|
68
|
+
if (!Object.keys(defs).length || !str.includes("#/components/schemas/")) return str;
|
|
69
|
+
const merged = { $defs: JSON.parse(JSON.stringify(defs)), ...JSON.parse(str) };
|
|
70
|
+
return JSON.stringify(merged).replace(/#\/components\/schemas\//g, "#/$defs/");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TestgenOptions {
|
|
74
|
+
/** the deployment under test; the generated suite reads SULUK_BASE_URL first, then falls back to this. */
|
|
75
|
+
baseURL?: string;
|
|
76
|
+
/** which test runner's imports to emit (both share the test/expect/describe API). Default "bun". */
|
|
77
|
+
framework?: "bun" | "vitest";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lbl = (s: string) => s.replace(/`/g, "'").replace(/\$\{/g, "\\${");
|
|
81
|
+
|
|
82
|
+
function emitOp(doc: OpenAPIv4Document, op: OpInfo): string {
|
|
83
|
+
const src = op.source ? ` ← ${op.source.file}#${op.source.symbol}` : "";
|
|
84
|
+
const head = `${op.name} [${op.method} ${op.uri}] · ${op.requires}${src}`;
|
|
85
|
+
const tests: string[] = [];
|
|
86
|
+
const public_ = op.requires === "anyone";
|
|
87
|
+
|
|
88
|
+
// ── L2: access enforcement, on the real wire (the ceiling-raiser) ──────────────────────────────────────────
|
|
89
|
+
if (public_) {
|
|
90
|
+
tests.push(` test("access — public: anon is NOT auth-blocked (x-suluk-access: anyone, verified on the wire)", async () => {
|
|
91
|
+
const r = await call("${op.method}", "${op.filledPath}"${op.hasBody ? ", { body: {} }" : ""});
|
|
92
|
+
expect([401, 403], "a public op must be reachable by anon — got " + r.status).not.toContain(r.status);
|
|
93
|
+
});`);
|
|
94
|
+
} else {
|
|
95
|
+
tests.push(` test("access — ENFORCED: anon gets NO success (x-suluk-access: ${op.requires}, verified on the wire — not the facet)", async () => {
|
|
96
|
+
const r = await call("${op.method}", "${op.filledPath}"${op.hasBody ? ", { body: {} }" : ""});
|
|
97
|
+
// the server is the boundary (C022 inv.3): a non-public op must DENY anon. Success here is a real hole.
|
|
98
|
+
expect([200, 201, 204], "anon succeeded on a ${op.requires} op — the server is NOT enforcing x-suluk-access").not.toContain(r.status);
|
|
99
|
+
});`);
|
|
100
|
+
tests.push(` test("access — admin/owner principal reaches it (positive; skipped without SULUK_${op.requires === "admin" ? "ADMIN" : "USER"}_TOKEN)", async () => {
|
|
101
|
+
const tok = ${op.requires === "admin" ? "ADMIN" : "USER"};
|
|
102
|
+
if (!tok) return; // optional: provide a synthetic principal token to assert the positive side
|
|
103
|
+
const r = await call("${op.method}", "${op.filledPath}", { token: tok${op.hasBody ? ", body: {}" : ""} });
|
|
104
|
+
expect([401, 403], "a valid principal was rejected (status " + r.status + ")").not.toContain(r.status);
|
|
105
|
+
});`);
|
|
106
|
+
// ── error-conformance (B1 envelope): a deny is a well-formed RFC-9457 Problem Details body ────────────────
|
|
107
|
+
tests.push(` test("error-conformance — a denied request returns a well-formed Problem Details body (RFC-9457)", async () => {
|
|
108
|
+
const r = await call("${op.method}", "${op.filledPath}"${op.hasBody ? ", { body: {} }" : ""});
|
|
109
|
+
if (r.status < 400) return; // a success here is already flagged by the access test above
|
|
110
|
+
const body = isJson(r) ? await r.json() : {};
|
|
111
|
+
// the shared @suluk/hono envelope: title (string) + status (number) === HTTP status (isProblemDetails shape).
|
|
112
|
+
expect(typeof body.title === "string" && body.status === r.status, "deny body is not RFC-9457 Problem Details: " + JSON.stringify(body)).toBe(true);
|
|
113
|
+
});`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── L1: status smoke + schema conformance (public, parameter-free GET) ─────────────────────────────────────
|
|
117
|
+
if (op.paramFree && public_) {
|
|
118
|
+
if (op.declaredStatuses.length) tests.push(` test("status — returns a declared status", async () => {
|
|
119
|
+
const r = await call("GET", "${op.filledPath}");
|
|
120
|
+
expect(${JSON.stringify(op.declaredStatuses)}, "undeclared status " + r.status).toContain(r.status);
|
|
121
|
+
});`);
|
|
122
|
+
if (op.okSchema != null) tests.push(` test("conformance — a 2xx body validates against the declared response schema", async () => {
|
|
123
|
+
const r = await call("GET", "${op.filledPath}");
|
|
124
|
+
if (r.status >= 300 || !isJson(r)) return;
|
|
125
|
+
const v = validate(${schemaLiteral(doc, op.okSchema)}, await r.json());
|
|
126
|
+
expect(v.valid, "response body does not conform: " + JSON.stringify(v.errors?.slice(0, 3))).toBe(true);
|
|
127
|
+
});`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── L2 (static): a declared cost is well-formed (never a literal µ$ amount) ────────────────────────────────
|
|
131
|
+
if (op.cost != null) tests.push(` test("cost — declares a well-formed x-suluk-cost", () => {
|
|
132
|
+
expect(Number.isFinite(${op.cost}) && ${op.cost} >= 0, "x-suluk-cost is malformed").toBe(true);
|
|
133
|
+
});`);
|
|
134
|
+
|
|
135
|
+
return `describe("${lbl(head)}", () => {\n${tests.join("\n")}\n});`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function generateTests(doc: OpenAPIv4Document, opts: TestgenOptions = {}): string {
|
|
139
|
+
const ops = walkOps(doc);
|
|
140
|
+
const importLine = opts.framework === "vitest" ? `import { test, expect, describe } from "vitest";` : `import { test, expect, describe } from "bun:test";`;
|
|
141
|
+
const title = doc.info?.title ?? "API";
|
|
142
|
+
const version = doc.openapi ?? "4.0.0-candidate";
|
|
143
|
+
const enforced = ops.filter((o) => o.requires !== "anyone").length;
|
|
144
|
+
const body = ops.map((o) => emitOp(doc, o)).join("\n\n");
|
|
145
|
+
|
|
146
|
+
return `/**
|
|
147
|
+
* ${title} — CONFORMANCE suite. AUTO-GENERATED by @suluk/testgen from the v4 contract (OpenAPI ${version}). Do not edit.
|
|
148
|
+
*
|
|
149
|
+
* The contract's claims, made executable. Run it against a deployment:
|
|
150
|
+
* SULUK_BASE_URL=${opts.baseURL ?? "https://your.api"} bun test ${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.conformance.test.ts
|
|
151
|
+
*
|
|
152
|
+
* It asserts the SERVER ENFORCES x-suluk-access on the real wire (${enforced}/${ops.length} ops are non-public and must
|
|
153
|
+
* deny anon), smoke-tests declared statuses, validates 2xx bodies against their declared schemas, and checks every
|
|
154
|
+
* declared cost is well-formed. x-suluk-access is the EXPECTATION; the WIRE is the truth — a green run proves the
|
|
155
|
+
* two AGREE. The server is the only authz boundary (C022 inv.3); this never asserts over a projection.
|
|
156
|
+
*
|
|
157
|
+
* Optional positive-side checks: set SULUK_ADMIN_TOKEN / SULUK_USER_TOKEN to SYNTHETIC principals (never a
|
|
158
|
+
* production credential) to also assert that a valid principal IS allowed through.
|
|
159
|
+
*
|
|
160
|
+
* Requires: \`npm i @cfworker/json-schema\` (for response-schema conformance) + a fetch-capable runtime.
|
|
161
|
+
*/
|
|
162
|
+
${importLine}
|
|
163
|
+
import { Validator } from "@cfworker/json-schema";
|
|
164
|
+
|
|
165
|
+
const BASE = (typeof process !== "undefined" && process.env.SULUK_BASE_URL) || ${JSON.stringify(opts.baseURL ?? "")};
|
|
166
|
+
const ADMIN = typeof process !== "undefined" ? process.env.SULUK_ADMIN_TOKEN : undefined;
|
|
167
|
+
const USER = typeof process !== "undefined" ? process.env.SULUK_USER_TOKEN : undefined;
|
|
168
|
+
|
|
169
|
+
const isJson = (r: Response) => (r.headers.get("content-type") || "").includes("json");
|
|
170
|
+
function validate(schema: unknown, value: unknown) { return new Validator(schema as object, "2020-12").validate(value); }
|
|
171
|
+
async function call(method: string, path: string, opts: { token?: string; body?: unknown } = {}): Promise<Response> {
|
|
172
|
+
const headers: Record<string, string> = {};
|
|
173
|
+
if (opts.token) headers.authorization = "Bearer " + opts.token;
|
|
174
|
+
let body: string | undefined;
|
|
175
|
+
if (opts.body !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.body); }
|
|
176
|
+
return fetch(BASE + path, { method, headers, body });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
${body}
|
|
180
|
+
`;
|
|
181
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/testgen — generate a DETERMINISTIC conformance test suite from a v4 "Suluk" contract. The contract's
|
|
3
|
+
* claims made executable: the server ENFORCES x-suluk-access on the wire, declared statuses hold, 2xx bodies
|
|
4
|
+
* conform to their schemas, declared costs are well-formed. A pure function of the document. CANDIDATE tooling.
|
|
5
|
+
*/
|
|
6
|
+
export { generateTests, type TestgenOptions } from "./generate";
|
|
7
|
+
// money-correctness conformance (PARITY §2 checkout-resilience over the @suluk/stripe pricing primitives).
|
|
8
|
+
export { generateMoneyTests, type MoneyTestsOptions } from "./money";
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateMoneyTests — PARITY §2 "Checkout & E-commerce Resilience" made an EXECUTABLE, in-process conformance
|
|
3
|
+
* suite over the @suluk/stripe pricing primitives (saastarter-parity Phase 0). Unlike the wire conformance suite,
|
|
4
|
+
* the money invariants are properties of the SHARED, app-independent primitives (there is nothing in a v4 document
|
|
5
|
+
* to walk for verifyAmount) — so this is a separate emitter that produces a self-contained `bun test` file an app
|
|
6
|
+
* commits + runs. No network, no app coupling, no document input.
|
|
7
|
+
*
|
|
8
|
+
* Provenance note (honesty, adopt-by-receipt): the anti-tampering + integer-cents + never-over-discount invariants
|
|
9
|
+
* are faithful encodings of saastarter's checkout intent. The exact-sum proration + deterministic-idempotency
|
|
10
|
+
* invariants are STRONGER than saastarter's actual code (PARITY records a real cart/order proration drift bug and
|
|
11
|
+
* an ad-hoc retry path) — they assert what @suluk/stripe was authored to GUARANTEE, an origination inspired by the
|
|
12
|
+
* parity goal, not a behavioral port. The generated header says so.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface MoneyTestsOptions {
|
|
16
|
+
/** which runner's imports to emit (both share test/expect/describe). Default "bun". */
|
|
17
|
+
framework?: "bun" | "vitest";
|
|
18
|
+
/** the import specifier for the pricing primitives. Default "@suluk/stripe". */
|
|
19
|
+
stripeModule?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Emit the money-correctness conformance suite as a self-contained test-file string. */
|
|
23
|
+
export function generateMoneyTests(opts: MoneyTestsOptions = {}): string {
|
|
24
|
+
const importLine = opts.framework === "vitest"
|
|
25
|
+
? `import { test, expect, describe } from "vitest";`
|
|
26
|
+
: `import { test, expect, describe } from "bun:test";`;
|
|
27
|
+
const mod = opts.stripeModule ?? "@suluk/stripe";
|
|
28
|
+
|
|
29
|
+
return `/**
|
|
30
|
+
* Checkout money-correctness — CONFORMANCE suite. AUTO-GENERATED by @suluk/testgen. Do not edit.
|
|
31
|
+
*
|
|
32
|
+
* PARITY §2 "Checkout & E-commerce Resilience" made executable over the @suluk/stripe pricing primitives — the
|
|
33
|
+
* arithmetic that separates a toy cart from one you'd trust with money. In-process, no network: it asserts the
|
|
34
|
+
* SHARED primitives uphold the invariants, so the cart-drawer total and the order-summary total can never drift.
|
|
35
|
+
*
|
|
36
|
+
* Honesty: the exact-sum proration + deterministic-idempotency checks assert what @suluk/stripe GUARANTEES — they
|
|
37
|
+
* are stronger than saastarter's actual impl (which has a known proration-drift bug), an origination inspired by
|
|
38
|
+
* the parity goal rather than a behavioral port.
|
|
39
|
+
*
|
|
40
|
+
* Run: \`bun test\` (requires ${mod}).
|
|
41
|
+
*/
|
|
42
|
+
${importLine}
|
|
43
|
+
import {
|
|
44
|
+
subtotal, computeDiscountAmount, validateDiscount, prorateDiscount, orderTotal, verifyAmount,
|
|
45
|
+
cartFingerprint, idempotencyKey, type CartLine, type Discount,
|
|
46
|
+
} from ${JSON.stringify(mod)};
|
|
47
|
+
|
|
48
|
+
const sum = (ns: number[]) => ns.reduce((a, b) => a + b, 0);
|
|
49
|
+
|
|
50
|
+
describe("money — anti-tampering (never trust the amount the browser sends)", () => {
|
|
51
|
+
const lines: CartLine[] = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }];
|
|
52
|
+
test("verifyAmount accepts the exact recomputed total and rejects a tampered one", () => {
|
|
53
|
+
const expected = orderTotal(lines, null).totalCents; // 4498
|
|
54
|
+
expect(verifyAmount(lines, null, expected).ok).toBe(true);
|
|
55
|
+
const tampered = verifyAmount(lines, null, expected - 100);
|
|
56
|
+
expect(tampered.ok).toBe(false);
|
|
57
|
+
expect(tampered.reason).toBe("amount-mismatch");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("money — integer cents only, a discount never exceeds the subtotal", () => {
|
|
62
|
+
const lines: CartLine[] = [{ unitCents: 1234, qty: 3 }];
|
|
63
|
+
test("subtotal + total are integers", () => {
|
|
64
|
+
const t = orderTotal(lines, null);
|
|
65
|
+
expect(Number.isInteger(t.subtotalCents)).toBe(true);
|
|
66
|
+
expect(Number.isInteger(t.totalCents)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
test("a fixed discount larger than the subtotal clamps — the total is never negative", () => {
|
|
69
|
+
const huge: Discount = { type: "fixed", value: 9_999_999 };
|
|
70
|
+
const sub = subtotal(lines);
|
|
71
|
+
expect(computeDiscountAmount(sub, huge)).toBe(sub); // clamped to subtotal
|
|
72
|
+
expect(orderTotal(lines, huge).totalCents).toBe(0); // never below zero
|
|
73
|
+
});
|
|
74
|
+
test("a percent discount is rounded to whole cents and clamped to [0, subtotal]", () => {
|
|
75
|
+
const amt = computeDiscountAmount(1000, { type: "percent", value: 33 });
|
|
76
|
+
expect(amt).toBe(330);
|
|
77
|
+
expect(amt).toBeLessThanOrEqual(1000);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("money — proration sums EXACTLY to the order discount (cart drawer ⊨ order summary)", () => {
|
|
82
|
+
const carts: CartLine[][] = [
|
|
83
|
+
[{ unitCents: 1999, qty: 2 }, { unitCents: 500, qty: 1 }, { unitCents: 333, qty: 3 }],
|
|
84
|
+
[{ unitCents: 100, qty: 1 }, { unitCents: 100, qty: 1 }, { unitCents: 100, qty: 1 }],
|
|
85
|
+
[{ unitCents: 4500, qty: 1 }],
|
|
86
|
+
];
|
|
87
|
+
for (let i = 0; i < carts.length; i++) {
|
|
88
|
+
for (const d of [101, 333, 777]) {
|
|
89
|
+
test(\`cart \${i} · discount \${d}¢ prorates to an exact sum, each share within its line\`, () => {
|
|
90
|
+
const lines = carts[i];
|
|
91
|
+
const want = Math.min(d, subtotal(lines));
|
|
92
|
+
const shares = prorateDiscount(lines, want);
|
|
93
|
+
expect(sum(shares)).toBe(want); // exact — no lost/created cent
|
|
94
|
+
shares.forEach((s, j) => expect(s).toBeLessThanOrEqual(subtotal([lines[j]])));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("money — specific discount-rejection reasons (tell the shopper WHY)", () => {
|
|
101
|
+
test("no-discount / non-positive-value / percent-out-of-range / below-minimum", () => {
|
|
102
|
+
expect(validateDiscount(1000, null).reason).toBe("no-discount");
|
|
103
|
+
expect(validateDiscount(1000, { type: "fixed", value: 0 }).reason).toBe("non-positive-value");
|
|
104
|
+
expect(validateDiscount(1000, { type: "percent", value: 150 }).reason).toBe("percent-out-of-range");
|
|
105
|
+
expect(validateDiscount(500, { type: "fixed", value: 100, minSubtotalCents: 1000 }).reason).toBe("below-minimum");
|
|
106
|
+
expect(validateDiscount(2000, { type: "percent", value: 10 }).valid).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("money — deterministic idempotency (a retry reuses one payment intent, never double-charges)", () => {
|
|
111
|
+
const lines: CartLine[] = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }];
|
|
112
|
+
test("same cart + scope ⇒ same key; a changed cart ⇒ a different key", () => {
|
|
113
|
+
const k1 = idempotencyKey("user_1", lines);
|
|
114
|
+
expect(idempotencyKey("user_1", [...lines].reverse())).toBe(k1); // order-independent — same charge
|
|
115
|
+
expect(idempotencyKey("user_1", [{ unitCents: 1999, qty: 3, id: "a" }, { unitCents: 500, qty: 1, id: "b" }])).not.toBe(k1);
|
|
116
|
+
expect(idempotencyKey("user_2", lines)).not.toBe(k1); // a different principal is a different attempt
|
|
117
|
+
expect(cartFingerprint(lines)).toBe(cartFingerprint([...lines].reverse()));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { generateTests, generateMoneyTests } from "../src/index";
|
|
3
|
+
import { orderTotal, verifyAmount, prorateDiscount, idempotencyKey } from "@suluk/stripe";
|
|
4
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
5
|
+
|
|
6
|
+
const doc = {
|
|
7
|
+
openapi: "4.0.0-candidate",
|
|
8
|
+
info: { title: "Store API" },
|
|
9
|
+
paths: {
|
|
10
|
+
product: {
|
|
11
|
+
requests: {
|
|
12
|
+
listProduct: { method: "get", responses: { ok: { status: 200, contentSchema: { type: "array", items: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], additionalProperties: false } } } }, "x-suluk-access": { requires: "anyone" }, "x-suluk-cost": { estimateMicroUsd: 10 }, "x-suluk-source": { file: "src/schema.ts", symbol: "product", kind: "drizzle-table" } },
|
|
13
|
+
createProduct: { method: "post", contentSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, responses: { created: { status: 201 } }, "x-suluk-access": { requires: "admin" }, "x-suluk-cost": { estimateMicroUsd: 145 }, "x-suluk-source": { file: "src/schema.ts", symbol: "product", kind: "drizzle-table" } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"cart/{id}": { requests: { getCart: { method: "get", responses: { ok: { status: 200 } }, "x-suluk-access": { requires: "authenticated", scope: "owner" }, "x-suluk-source": { file: "src/ops.ts", symbol: "getCart", kind: "operation" } } } },
|
|
17
|
+
},
|
|
18
|
+
} as unknown as OpenAPIv4Document;
|
|
19
|
+
|
|
20
|
+
describe("@suluk/testgen — generate a conformance suite from a v4 contract", () => {
|
|
21
|
+
const suite = generateTests(doc, { baseURL: "https://api.example.com" });
|
|
22
|
+
|
|
23
|
+
test("emits a self-contained, fetch-based suite (bun:test default) with the generic validator", () => {
|
|
24
|
+
expect(suite).toContain('import { test, expect, describe } from "bun:test"');
|
|
25
|
+
expect(suite).toContain('import { Validator } from "@cfworker/json-schema"');
|
|
26
|
+
expect(suite).toContain("async function call(method: string, path: string");
|
|
27
|
+
expect(suite).toContain('process.env.SULUK_BASE_URL');
|
|
28
|
+
expect(suite).toContain('"https://api.example.com"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ACCESS ENFORCEMENT on the wire — non-public ops must DENY anon a success; public ops must be reachable", () => {
|
|
32
|
+
// a public op: anon must NOT be auth-blocked
|
|
33
|
+
expect(suite).toContain('access — public: anon is NOT auth-blocked');
|
|
34
|
+
expect(suite).toMatch(/expect\(\[401, 403\],[^)]*\)\.not\.toContain\(r\.status\)/);
|
|
35
|
+
// an admin op: anon must get NO 2xx (the server enforces; the facet is only the expectation)
|
|
36
|
+
expect(suite).toContain('access — ENFORCED: anon gets NO success');
|
|
37
|
+
expect(suite).toMatch(/expect\(\[200, 201, 204\],[^)]*\)\.not\.toContain\(r\.status\)/);
|
|
38
|
+
// it tests the WIRE, never a projection
|
|
39
|
+
expect(suite).toContain("verified on the wire");
|
|
40
|
+
expect(suite).not.toContain("?as=");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parameterized paths are filled; the positive side is token-gated (synthetic principal, optional)", () => {
|
|
44
|
+
expect(suite).toContain('"/cart/1"'); // {id} → a placeholder
|
|
45
|
+
expect(suite).toContain("SULUK_USER_TOKEN"); // owner/authenticated positive side
|
|
46
|
+
expect(suite).toContain("if (!tok) return;"); // skipped without a synthetic principal
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("L1 status smoke + schema conformance for a public, parameter-free GET", () => {
|
|
50
|
+
expect(suite).toContain('status — returns a declared status');
|
|
51
|
+
expect(suite).toContain("[200]"); // listProduct's declared status
|
|
52
|
+
expect(suite).toContain('conformance — a 2xx body validates against the declared response schema');
|
|
53
|
+
expect(suite).toContain('validate({"type":"array"'); // the response schema, embedded as data
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("cost is checked as DECLARED + well-formed, never a literal µ$ amount", () => {
|
|
57
|
+
expect(suite).toContain("declares a well-formed x-suluk-cost");
|
|
58
|
+
expect(suite).toContain("145 >= 0");
|
|
59
|
+
expect(suite).not.toMatch(/toBe\(145\)/); // never asserts the literal amount
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("each op group is LABELLED with its provenance (x-suluk-source) — a failure points at the source", () => {
|
|
63
|
+
expect(suite).toContain("createProduct [POST product] · admin ← src/schema.ts#product");
|
|
64
|
+
expect(suite).toContain("getCart [GET cart/{id}] · authenticated ← src/ops.ts#getCart");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("framework toggle emits vitest imports", () => {
|
|
68
|
+
expect(generateTests(doc, { framework: "vitest" })).toContain('from "vitest"');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("error-conformance: a non-public op asserts the deny body is RFC-9457 Problem Details (the B1 envelope)", () => {
|
|
72
|
+
expect(suite).toContain("error-conformance — a denied request returns a well-formed Problem Details body");
|
|
73
|
+
expect(suite).toContain('typeof body.title === "string" && body.status === r.status'); // the @suluk/hono envelope fields
|
|
74
|
+
// a public op never gets an error-conformance block (it isn't expected to deny)
|
|
75
|
+
expect(suite.split("access — public").length).toBeGreaterThan(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("@suluk/testgen — generateMoneyTests (PARITY §2 checkout-resilience, in-process)", () => {
|
|
80
|
+
const money = generateMoneyTests();
|
|
81
|
+
|
|
82
|
+
test("emits a self-contained bun:test suite importing the @suluk/stripe primitives", () => {
|
|
83
|
+
expect(money).toContain('import { test, expect, describe } from "bun:test"');
|
|
84
|
+
expect(money).toContain('} from "@suluk/stripe"');
|
|
85
|
+
expect(money).toContain("verifyAmount");
|
|
86
|
+
expect(money).toContain("prorateDiscount");
|
|
87
|
+
expect(money).toContain("idempotencyKey");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("encodes the load-bearing invariants: anti-tampering, never-over-discount, exact proration, idempotency", () => {
|
|
91
|
+
expect(money).toContain('expect(tampered.reason).toBe("amount-mismatch")'); // anti-tampering
|
|
92
|
+
expect(money).toContain("orderTotal(lines, huge).totalCents).toBe(0)"); // never negative
|
|
93
|
+
expect(money).toContain("expect(sum(shares)).toBe(want)"); // proration sums exactly
|
|
94
|
+
expect(money).toContain("idempotencyKey(\"user_1\", [...lines].reverse())).toBe(k1"); // retry reuses the key
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("honestly labels the originated (stronger-than-saastarter) invariants", () => {
|
|
98
|
+
expect(money).toContain("stronger than saastarter");
|
|
99
|
+
expect(money).toContain("origination inspired by"); // honestly flags the non-ported invariants
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("stripeModule + framework are configurable", () => {
|
|
103
|
+
const v = generateMoneyTests({ framework: "vitest", stripeModule: "../pricing" });
|
|
104
|
+
expect(v).toContain('from "vitest"');
|
|
105
|
+
expect(v).toContain('} from "../pricing"');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Smoke (closes the loop): the invariants the emitter ENCODES actually hold for the real @suluk/stripe build.
|
|
110
|
+
describe("@suluk/testgen — money smoke against the real @suluk/stripe primitives", () => {
|
|
111
|
+
test("verifyAmount rejects tampering; proration sums exactly; idempotency is deterministic", () => {
|
|
112
|
+
const lines = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }, { unitCents: 333, qty: 3 }];
|
|
113
|
+
const exact = orderTotal(lines, null).totalCents;
|
|
114
|
+
expect(verifyAmount(lines, null, exact).ok).toBe(true);
|
|
115
|
+
expect(verifyAmount(lines, null, exact - 1).ok).toBe(false);
|
|
116
|
+
const shares = prorateDiscount(lines, 777);
|
|
117
|
+
expect(shares.reduce((a, b) => a + b, 0)).toBe(777);
|
|
118
|
+
expect(idempotencyKey("u1", lines)).toBe(idempotencyKey("u1", [...lines].reverse()));
|
|
119
|
+
expect(orderTotal(lines, { type: "fixed", value: 9_999_999 }).totalCents).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|