@suluk/testgen 0.1.0 → 0.1.2
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/README.md +141 -0
- package/package.json +3 -3
- package/src/generate.ts +11 -0
- package/src/index.ts +1 -1
- package/src/money.ts +6 -6
- package/test/testgen.test.ts +14 -6
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @suluk/testgen
|
|
2
|
+
|
|
3
|
+
**Generate a deterministic conformance test suite from a v4 "Suluk" contract — the contract's claims, made executable.**
|
|
4
|
+
|
|
5
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
6
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
7
|
+
> to ratify anything on the SIG's behalf.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @suluk/testgen @cfworker/json-schema
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`@cfworker/json-schema` is a peer dependency — the *generated* suite imports it to validate response
|
|
16
|
+
bodies against their declared schemas. `generateMoneyTests` additionally targets `@suluk/stripe` (the
|
|
17
|
+
emitted suite imports the pricing primitives from there).
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
`generateTests(doc)` walks a v4 document and emits a **self-contained test file** (a string) that runs
|
|
22
|
+
against a live deployment. It turns the `x-suluk-*` facets from decoration into load-bearing claims:
|
|
23
|
+
|
|
24
|
+
- **Access enforcement, on the real wire.** A non-public op must DENY anon a success; a public op must be
|
|
25
|
+
reachable by anon. `x-suluk-access` is only the *expectation* — the test passes iff the wire agrees. It
|
|
26
|
+
never asserts over a projection and never treats the facet as if it were the enforcement (C022 inv.3).
|
|
27
|
+
- **Status smoke + schema conformance.** A public, parameter-free GET returns a *declared* status, and its
|
|
28
|
+
2xx body validates against the declared response schema (same generic JSON-Schema engine the SDK uses).
|
|
29
|
+
- **Error-conformance.** A denied request returns a well-formed RFC-9457 Problem Details body (the shared
|
|
30
|
+
`@suluk/hono` envelope: `title` string + `status` matching the HTTP status).
|
|
31
|
+
- **Cost declared.** Every op that prices itself declares a well-formed `x-suluk-cost` — checked statically,
|
|
32
|
+
never as a literal µ$ amount (which would couple tests to billing internals and go flaky).
|
|
33
|
+
|
|
34
|
+
A **pure function of the document**: same contract in, same suite out, no network at generate-time. Each op's
|
|
35
|
+
tests are labelled with its provenance (`x-suluk-source`) so a failure points at the authoring source.
|
|
36
|
+
|
|
37
|
+
`generateMoneyTests()` is a separate emitter for the money path — an in-process suite over the `@suluk/stripe`
|
|
38
|
+
pricing primitives (anti-tampering, integer-cents, never-over-discount, exact proration, deterministic
|
|
39
|
+
idempotency). It takes no document; the invariants live in the shared primitives, not in any one contract.
|
|
40
|
+
|
|
41
|
+
## When to reach for it
|
|
42
|
+
|
|
43
|
+
Reach for it whenever you want the contract's facets to *mean something* — this is what makes `x-suluk-*`
|
|
44
|
+
enforced rather than aspirational. The common pattern is to expose the generated suite as a download endpoint
|
|
45
|
+
so any consumer can verify your deployment honours its own contract (see Usage). Add `generateMoneyTests` when
|
|
46
|
+
your app touches checkout/billing through `@suluk/stripe`.
|
|
47
|
+
|
|
48
|
+
This is a **generator**, not a runner: it produces a `bun:test` (or `vitest`) file you run yourself. It is the
|
|
49
|
+
testing facet of the projection model — siblings like `@suluk/scalar` / `@suluk/swagger` *render* the contract;
|
|
50
|
+
`@suluk/sdk` *generates a client*; this generates the *conformance proof*.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Generate a conformance suite from a v4 document
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { generateTests } from "@suluk/testgen";
|
|
58
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
59
|
+
|
|
60
|
+
const suite = generateTests(document, { baseURL: "https://api.example.com" });
|
|
61
|
+
// → a self-contained test-file string. Write it next to your tests and run it:
|
|
62
|
+
await Bun.write("api.conformance.test.ts", suite);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run the emitted suite against a deployment — `SULUK_BASE_URL` wins over the baked-in `baseURL`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
SULUK_BASE_URL=https://api.example.com bun test api.conformance.test.ts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Optional positive-side checks (a non-public op also asserting a *valid* principal IS allowed through) are
|
|
72
|
+
skipped unless you provide **synthetic** principal tokens — never a production credential:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
SULUK_ADMIN_TOKEN=… SULUK_USER_TOKEN=… bun test api.conformance.test.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Serve it as a download endpoint (real saasuluk usage)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { generateTests } from "@suluk/testgen";
|
|
82
|
+
|
|
83
|
+
app.get("/conformance.test.ts", (c) =>
|
|
84
|
+
new Response(generateTests(document, { baseURL: new URL(c.req.url).origin }), {
|
|
85
|
+
headers: {
|
|
86
|
+
"content-type": "application/typescript; charset=utf-8",
|
|
87
|
+
"content-disposition": 'attachment; filename="api.conformance.test.ts"',
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Options
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
generateTests(document, {
|
|
97
|
+
baseURL: "https://api.example.com", // baked default; SULUK_BASE_URL overrides at run-time
|
|
98
|
+
framework: "vitest", // "bun" (default) | "vitest" — toggles the emitted imports
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Money-correctness suite
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { generateMoneyTests } from "@suluk/testgen";
|
|
106
|
+
|
|
107
|
+
const money = generateMoneyTests(); // imports primitives from "@suluk/stripe"
|
|
108
|
+
await Bun.write("money.conformance.test.ts", money);
|
|
109
|
+
// then: bun test money.conformance.test.ts (no network, in-process)
|
|
110
|
+
|
|
111
|
+
// configurable runner + import specifier:
|
|
112
|
+
generateMoneyTests({ framework: "vitest", stripeModule: "../pricing" });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
| Export | What it does |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `generateTests(doc, opts?)` | Emit a wire-conformance suite (access / status / schema / cost) from a v4 document, as a test-file string. |
|
|
120
|
+
| `generateMoneyTests(opts?)` | Emit an in-process money-correctness suite over the `@suluk/stripe` pricing primitives. |
|
|
121
|
+
| `TestgenOptions` | `{ baseURL?: string; framework?: "bun" \| "vitest" }`. |
|
|
122
|
+
| `MoneyTestsOptions` | `{ framework?: "bun" \| "vitest"; stripeModule?: string }`. |
|
|
123
|
+
|
|
124
|
+
## Boundary
|
|
125
|
+
|
|
126
|
+
This package **generates, never hosts** (L3). It is a pure string-emitter: no network at generate-time, no
|
|
127
|
+
deployment of its own. The seams it leaves to the app:
|
|
128
|
+
|
|
129
|
+
- **The deployment under test** is injected via `SULUK_BASE_URL` (or the baked `baseURL`) at *run*-time — the
|
|
130
|
+
generated suite reads it from the environment.
|
|
131
|
+
- **Authorization is the server's job.** The access tests assert the *wire* enforces `x-suluk-access`; this
|
|
132
|
+
never re-implements authz, and never asserts over a projection (C022 inv.3 — the server is the only boundary).
|
|
133
|
+
- **Synthetic principals** for the optional positive-side checks come from you (`SULUK_*_TOKEN`), out of band.
|
|
134
|
+
- **Running** the emitted file is the app's responsibility (`bun test`); you decide where to write it and when
|
|
135
|
+
to run it in CI.
|
|
136
|
+
|
|
137
|
+
Contributing: a new facet means a new asserted claim here — that is what keeps `x-suluk-*` load-bearing.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Apache-2.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/testgen",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
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
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.13"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@cfworker/json-schema": "^4.1.1"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/bun": "latest",
|
|
34
|
-
"@suluk/
|
|
34
|
+
"@suluk/payments": "^0.1.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "bun test",
|
package/src/generate.ts
CHANGED
|
@@ -22,10 +22,13 @@ interface RawReq {
|
|
|
22
22
|
parameterSchema?: { path?: unknown; query?: unknown; body?: unknown };
|
|
23
23
|
responses?: Record<string, { status: string | number; contentSchema?: unknown }>;
|
|
24
24
|
["x-suluk-cost"]?: CostModel; ["x-suluk-access"]?: AccessFacet; ["x-suluk-source"]?: SulukSource;
|
|
25
|
+
["x-suluk-approval"]?: { required?: unknown; reason?: unknown };
|
|
25
26
|
}
|
|
26
27
|
interface OpInfo {
|
|
27
28
|
name: string; method: string; uri: string; filledPath: string; requires: string;
|
|
28
29
|
declaredStatuses: number[]; okSchema?: unknown; cost: number | null; source?: SulukSource;
|
|
30
|
+
/** the raw x-suluk-approval.required when the HITL facet is present (else null) — checked WELL-FORMED, statically. */
|
|
31
|
+
approvalRequired: unknown; hasApproval: boolean;
|
|
29
32
|
hasBody: boolean; paramFree: boolean;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -52,6 +55,7 @@ function walkOps(doc: OpenAPIv4Document): OpInfo[] {
|
|
|
52
55
|
requires: req["x-suluk-access"]?.requires ?? "anyone",
|
|
53
56
|
declaredStatuses: [...new Set(statuses)].sort((a, b) => a - b),
|
|
54
57
|
okSchema: ok?.contentSchema, cost: costOf(req), source: req["x-suluk-source"],
|
|
58
|
+
approvalRequired: req["x-suluk-approval"]?.required, hasApproval: req["x-suluk-approval"] != null,
|
|
55
59
|
hasBody: req.contentSchema != null || ps.body != null,
|
|
56
60
|
paramFree: method === "GET" && !hasPathParam && reqQuery.length === 0,
|
|
57
61
|
});
|
|
@@ -132,6 +136,13 @@ function emitOp(doc: OpenAPIv4Document, op: OpInfo): string {
|
|
|
132
136
|
expect(Number.isFinite(${op.cost}) && ${op.cost} >= 0, "x-suluk-cost is malformed").toBe(true);
|
|
133
137
|
});`);
|
|
134
138
|
|
|
139
|
+
// ── L2 (static): a declared HITL gate is well-formed. x-suluk-approval is an AGENT-RUNTIME facet (it projects to the
|
|
140
|
+
// Agents SDK `needsApproval`), NOT wire-enforced like x-suluk-access — so, like cost, the conformance claim is the
|
|
141
|
+
// static well-formedness (`required` is a boolean), which keeps the facet load-bearing (it can't silently malform).
|
|
142
|
+
if (op.hasApproval) tests.push(` test("approval — declares a well-formed x-suluk-approval HITL gate (static; enforced by the agent runtime, not the wire)", () => {
|
|
143
|
+
expect(typeof ${JSON.stringify(op.approvalRequired)} === "boolean", "x-suluk-approval.required must be a boolean").toBe(true);
|
|
144
|
+
});`);
|
|
145
|
+
|
|
135
146
|
return `describe("${lbl(head)}", () => {\n${tests.join("\n")}\n});`;
|
|
136
147
|
}
|
|
137
148
|
|
package/src/index.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* conform to their schemas, declared costs are well-formed. A pure function of the document. CANDIDATE tooling.
|
|
5
5
|
*/
|
|
6
6
|
export { generateTests, type TestgenOptions } from "./generate";
|
|
7
|
-
// money-correctness conformance (PARITY §2 checkout-resilience over the @suluk/
|
|
7
|
+
// money-correctness conformance (PARITY §2 checkout-resilience over the @suluk/payments pricing primitives).
|
|
8
8
|
export { generateMoneyTests, type MoneyTestsOptions } from "./money";
|
package/src/money.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* generateMoneyTests — PARITY §2 "Checkout & E-commerce Resilience" made an EXECUTABLE, in-process conformance
|
|
3
|
-
* suite over the @suluk/
|
|
3
|
+
* suite over the @suluk/payments pricing primitives (saastarter-parity Phase 0). Unlike the wire conformance suite,
|
|
4
4
|
* the money invariants are properties of the SHARED, app-independent primitives (there is nothing in a v4 document
|
|
5
5
|
* to walk for verifyAmount) — so this is a separate emitter that produces a self-contained `bun test` file an app
|
|
6
6
|
* commits + runs. No network, no app coupling, no document input.
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* Provenance note (honesty, adopt-by-receipt): the anti-tampering + integer-cents + never-over-discount invariants
|
|
9
9
|
* are faithful encodings of saastarter's checkout intent. The exact-sum proration + deterministic-idempotency
|
|
10
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/
|
|
11
|
+
* an ad-hoc retry path) — they assert what @suluk/payments was authored to GUARANTEE, an origination inspired by the
|
|
12
12
|
* parity goal, not a behavioral port. The generated header says so.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export interface MoneyTestsOptions {
|
|
16
16
|
/** which runner's imports to emit (both share test/expect/describe). Default "bun". */
|
|
17
17
|
framework?: "bun" | "vitest";
|
|
18
|
-
/** the import specifier for the pricing primitives. Default "@suluk/
|
|
18
|
+
/** the import specifier for the pricing primitives. Default "@suluk/payments". */
|
|
19
19
|
stripeModule?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -24,16 +24,16 @@ export function generateMoneyTests(opts: MoneyTestsOptions = {}): string {
|
|
|
24
24
|
const importLine = opts.framework === "vitest"
|
|
25
25
|
? `import { test, expect, describe } from "vitest";`
|
|
26
26
|
: `import { test, expect, describe } from "bun:test";`;
|
|
27
|
-
const mod = opts.stripeModule ?? "@suluk/
|
|
27
|
+
const mod = opts.stripeModule ?? "@suluk/payments";
|
|
28
28
|
|
|
29
29
|
return `/**
|
|
30
30
|
* Checkout money-correctness — CONFORMANCE suite. AUTO-GENERATED by @suluk/testgen. Do not edit.
|
|
31
31
|
*
|
|
32
|
-
* PARITY §2 "Checkout & E-commerce Resilience" made executable over the @suluk/
|
|
32
|
+
* PARITY §2 "Checkout & E-commerce Resilience" made executable over the @suluk/payments pricing primitives — the
|
|
33
33
|
* arithmetic that separates a toy cart from one you'd trust with money. In-process, no network: it asserts the
|
|
34
34
|
* SHARED primitives uphold the invariants, so the cart-drawer total and the order-summary total can never drift.
|
|
35
35
|
*
|
|
36
|
-
* Honesty: the exact-sum proration + deterministic-idempotency checks assert what @suluk/
|
|
36
|
+
* Honesty: the exact-sum proration + deterministic-idempotency checks assert what @suluk/payments GUARANTEES — they
|
|
37
37
|
* are stronger than saastarter's actual impl (which has a known proration-drift bug), an origination inspired by
|
|
38
38
|
* the parity goal rather than a behavioral port.
|
|
39
39
|
*
|
package/test/testgen.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
2
|
import { generateTests, generateMoneyTests } from "../src/index";
|
|
3
|
-
import { orderTotal, verifyAmount, prorateDiscount, idempotencyKey } from "@suluk/
|
|
3
|
+
import { orderTotal, verifyAmount, prorateDiscount, idempotencyKey } from "@suluk/payments";
|
|
4
4
|
import type { OpenAPIv4Document } from "@suluk/core";
|
|
5
5
|
|
|
6
6
|
const doc = {
|
|
@@ -10,7 +10,7 @@ const doc = {
|
|
|
10
10
|
product: {
|
|
11
11
|
requests: {
|
|
12
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" } },
|
|
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-approval": { required: true, reason: "creates a billable product" }, "x-suluk-source": { file: "src/schema.ts", symbol: "product", kind: "drizzle-table" } },
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
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" } } } },
|
|
@@ -59,6 +59,14 @@ describe("@suluk/testgen — generate a conformance suite from a v4 contract", (
|
|
|
59
59
|
expect(suite).not.toMatch(/toBe\(145\)/); // never asserts the literal amount
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("x-suluk-approval (HITL) is checked as a DECLARED well-formed gate — static, NOT wire-enforced (it's an agent-runtime facet)", () => {
|
|
63
|
+
expect(suite).toContain("declares a well-formed x-suluk-approval HITL gate");
|
|
64
|
+
expect(suite).toContain("enforced by the agent runtime, not the wire"); // honest scope — unlike x-suluk-access
|
|
65
|
+
expect(suite).toContain('typeof true === "boolean"'); // the static well-formedness assertion on `required`
|
|
66
|
+
// only the op that declares it gets the claim; a non-gated op (listProduct) does not
|
|
67
|
+
expect(suite.match(/well-formed x-suluk-approval/g)?.length).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
62
70
|
test("each op group is LABELLED with its provenance (x-suluk-source) — a failure points at the source", () => {
|
|
63
71
|
expect(suite).toContain("createProduct [POST product] · admin ← src/schema.ts#product");
|
|
64
72
|
expect(suite).toContain("getCart [GET cart/{id}] · authenticated ← src/ops.ts#getCart");
|
|
@@ -79,9 +87,9 @@ describe("@suluk/testgen — generate a conformance suite from a v4 contract", (
|
|
|
79
87
|
describe("@suluk/testgen — generateMoneyTests (PARITY §2 checkout-resilience, in-process)", () => {
|
|
80
88
|
const money = generateMoneyTests();
|
|
81
89
|
|
|
82
|
-
test("emits a self-contained bun:test suite importing the @suluk/
|
|
90
|
+
test("emits a self-contained bun:test suite importing the @suluk/payments primitives", () => {
|
|
83
91
|
expect(money).toContain('import { test, expect, describe } from "bun:test"');
|
|
84
|
-
expect(money).toContain('} from "@suluk/
|
|
92
|
+
expect(money).toContain('} from "@suluk/payments"');
|
|
85
93
|
expect(money).toContain("verifyAmount");
|
|
86
94
|
expect(money).toContain("prorateDiscount");
|
|
87
95
|
expect(money).toContain("idempotencyKey");
|
|
@@ -106,8 +114,8 @@ describe("@suluk/testgen — generateMoneyTests (PARITY §2 checkout-resilience,
|
|
|
106
114
|
});
|
|
107
115
|
});
|
|
108
116
|
|
|
109
|
-
// Smoke (closes the loop): the invariants the emitter ENCODES actually hold for the real @suluk/
|
|
110
|
-
describe("@suluk/testgen — money smoke against the real @suluk/
|
|
117
|
+
// Smoke (closes the loop): the invariants the emitter ENCODES actually hold for the real @suluk/payments build.
|
|
118
|
+
describe("@suluk/testgen — money smoke against the real @suluk/payments primitives", () => {
|
|
111
119
|
test("verifyAmount rejects tampering; proration sums exactly; idempotency is deterministic", () => {
|
|
112
120
|
const lines = [{ unitCents: 1999, qty: 2, id: "a" }, { unitCents: 500, qty: 1, id: "b" }, { unitCents: 333, qty: 3 }];
|
|
113
121
|
const exact = orderTotal(lines, null).totalCents;
|