Add cross-language wire fixtures for ECPay - #11
Conversation
Language-neutral JSON fixtures under fixtures/ecpay/ that pin the ECPay wire contract — given a unified SDK input, the payload the provider receives — plus the RtnMsg→normalized-error mapping. The Ruby port will run the same fixtures through its own adapter, so a wire-mapping drift on either side turns a shared fixture red. fixtures.test.ts is the TypeScript consumer: it drives the real ecpay adapter (issue/void/allowance/voidAllowance/query) through a captured request and asserts each fixture, and checks errors.json against mapEcpayError/ecpayErrorReason. 27 cases, all green. fixtures/README.md documents the case schema, the consumption steps, and how dynamic fields (timestamps, defaulted dates) are kept deterministic.
There was a problem hiding this comment.
Pull request overview
Adds language-neutral JSON fixtures under fixtures/ecpay/ to pin ECPay’s wire-level request payloads and error normalization contract, plus a Vitest consumer that drives the real TypeScript ECPay adapter against those fixtures.
Changes:
- Add ECPay wire-contract fixtures for
issue,void/allowance/voidAllowance, andqueryoperations. - Add ECPay error mapping fixtures (
RtnMsg/RtnCode→ normalizedcode+reason). - Add TypeScript test harness that captures outbound encrypted requests via MSW and asserts fixture expectations; document the fixture schema and consumption flow.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/einvoice-ecpay/src/tests/fixtures.test.ts | New MSW-backed test that consumes fixtures/ecpay/* and asserts captured wire payloads + error mapping outputs. |
| fixtures/README.md | Documentation for fixture layout, schema, and how SDKs should consume fixtures. |
| fixtures/ecpay/issue.json | Operation fixtures for issue wire payload mapping cases. |
| fixtures/ecpay/void-allowance.json | Operation fixtures for void, allowance, and voidAllowance wire payload mapping cases. |
| fixtures/ecpay/query.json | Operation fixtures for query wire payload mapping cases, including an exact-match case. |
| fixtures/ecpay/errors.json | Error mapping fixtures asserting normalized code and reason derived from ECPay messages. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| async function capture(c: WireCase) { | ||
| let data: Record<string, unknown> | undefined; | ||
| server.use( | ||
| http.post(`${BASE}${ENDPOINT_FOR[c.operation]}`, async ({ request }) => { | ||
| data = parseRequest(await request.text()).data; | ||
| return HttpResponse.json(ecSuccess(RESULT_STUB[c.operation])); | ||
| }), | ||
| ); | ||
| const provider = testProvider(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| await (provider as any)[c.operation](c.input); | ||
| return data as Record<string, unknown>; | ||
| } | ||
|
|
||
| for (const file of ["issue.json", "void-allowance.json", "query.json"]) { | ||
| describe(`fixtures/ecpay/${file}`, () => { | ||
| for (const c of load(file)) { | ||
| it(c.name, async () => { | ||
| const data = await capture(c); | ||
| expect(data).toMatchObject(c.expect.data); | ||
| if (c.expect.dataExact) { | ||
| expect(Object.keys(data).sort()).toEqual(Object.keys(c.expect.data).sort()); | ||
| } |
| "note": "ECPay classification is keyword-based on RtnMsg, not purely on RtnCode (several rows share RtnCode 0). `code` is the InvoiceErrorCode; `reason` is the optional action-oriented axis (null when the message implies no distinct consumer action). Both SDKs must agree on both axes.", | ||
| "cases": [ | ||
| { |
| `code` is the stable `InvoiceErrorCode` both SDKs normalize onto; `rawCode` is | ||
| the provider code preserved on the error. |
Address review feedback on the fixtures: - The consumer registered its MSW handler at a hard-coded endpoint map and never used the fixture's own `expect.endpoint`, so the endpoint half of the contract was unverified — a wrong path in a fixture would pass. Register the handler at `expect.endpoint` instead, so a wrong endpoint becomes an unhandled request and fails the case (verified by mutation). Drops the now redundant ENDPOINT_FOR map. - README's error-schema example and prose showed `rawCode`, but errors.json and the consumer assert the two-axis `code` + `reason` contract. Correct the docs so other SDKs implement the right shape.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
packages/einvoice-ecpay/src/tests/fixtures.test.ts:16
FIXTURE_DIRis converted to a path and then stitched back into afile://URL (file://${FIXTURE_DIR}), which produces a non-canonicalfile:////...base and is easy to get wrong across platforms. Keeping it as a URL and resolving fixture files vianew URL(file, FIXTURE_DIR)is simpler and avoids path→URL pitfalls.
const FIXTURE_DIR = fileURLToPath(new URL("../../../../fixtures/ecpay/", import.meta.url));
packages/einvoice-ecpay/src/tests/fixtures.test.ts:105
- Use the same
FIXTURE_DIRURL base when readingerrors.jsonto avoid duplicating the path→file URL construction logic.
const cases: ErrorCase[] = JSON.parse(
readFileSync(new URL("errors.json", `file://${FIXTURE_DIR}`), "utf8"),
).cases;
fixtures/README.md:43
- The documented case schema omits
itemsAbsent, but the fixtures and consumer test support it (used infixtures/ecpay/issue.json). This makes it easy for other SDKs to miss an important assertion type when implementing the fixture runner.
"dataAbsent": ["CustomerIdentifier"], // keys that must NOT be present (optional)
"dataExact": false // when true, `data` is a FULL match, not a subset
}
Keep FIXTURE_DIR as a URL and pass it straight to readFileSync via new URL(file, FIXTURE_DIR), instead of round-tripping URL → path → URL through fileURLToPath and file:// string concatenation. Drops the node:url import. No behavior change (27 fixture cases still green).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/einvoice-ecpay/src/tests/fixtures.test.ts:72
- This file introduces the only
// eslint-disable-next-line @typescript-eslint/no-explicit-anydirective in the repo; since the project lints with oxlint (not typescript-eslint), this directive is at best misleading and may not suppress anything. You can avoid both the directive andanyhere by casting the provider to a per-operation callable record before indexing byc.operation.
const provider = testProvider();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (provider as any)[c.operation](c.input);
return data as Record<string, unknown>;
What
Language-neutral JSON fixtures under
fixtures/ecpay/that pin ECPay's wire contract — given a unified SDK input, the payload the provider actually receives — plus theRtnMsg→ normalized-error mapping.issue.jsonvoid-allowance.jsonquery.jsonerrors.jsoncodeand keyword→reasontwo-axis error mappingWhy
This is the mechanism that keeps the TypeScript SDK and the planned Ruby port twins. Both run the same fixtures through their own adapter; a wire-mapping drift on either side turns a shared fixture red. It's the working proof behind the "cross-language wire fixtures" point in the einvoice 2.0 RFC (abookyun/einvoice#31), and it lives here in our repo, independent of that discussion.
Not just well-formed — behavior-verified
packages/einvoice-ecpay/src/__tests__/fixtures.test.tsis the TypeScript consumer: it drives the real ecpay adapter through a captured request and asserts each fixture (payload and endpoint), and checkserrors.jsonagainstmapEcpayError/ecpayErrorReason. 27 cases, all green. Running the fixtures against the live adapter caught three wrong hand-written guesses inerrors.json(e.g. 系統忙碌 isPROVIDERnotVALIDATION; 該折讓單已作廢過 carries reasonalready_voided) — all corrected to match verified behavior.fixtures/README.mddocuments the case schema, the consumption steps, and how dynamic fields (timestamps, defaulted dates) are kept deterministic.Scope
Purely additive — new files only. No change to the ecpay adapter or any other package.