autotel-pact 0.2.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.
Files changed (86) hide show
  1. package/README.md +385 -0
  2. package/dist/audit.cjs +412 -0
  3. package/dist/audit.cjs.map +1 -0
  4. package/dist/audit.d.cts +25 -0
  5. package/dist/audit.d.ts +25 -0
  6. package/dist/audit.js +403 -0
  7. package/dist/audit.js.map +1 -0
  8. package/dist/auto-wrap.cjs +255 -0
  9. package/dist/auto-wrap.cjs.map +1 -0
  10. package/dist/auto-wrap.d.cts +57 -0
  11. package/dist/auto-wrap.d.ts +57 -0
  12. package/dist/auto-wrap.js +248 -0
  13. package/dist/auto-wrap.js.map +1 -0
  14. package/dist/broker.cjs +84 -0
  15. package/dist/broker.cjs.map +1 -0
  16. package/dist/broker.d.cts +23 -0
  17. package/dist/broker.d.ts +23 -0
  18. package/dist/broker.js +80 -0
  19. package/dist/broker.js.map +1 -0
  20. package/dist/cli.cjs +662 -0
  21. package/dist/cli.cjs.map +1 -0
  22. package/dist/cli.d.cts +4 -0
  23. package/dist/cli.d.ts +4 -0
  24. package/dist/cli.js +656 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/index.cjs +967 -0
  27. package/dist/index.cjs.map +1 -0
  28. package/dist/index.d.cts +200 -0
  29. package/dist/index.d.ts +200 -0
  30. package/dist/index.js +932 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/ledger-BuBmfWNc.d.ts +22 -0
  33. package/dist/ledger-D88TzN1c.d.cts +22 -0
  34. package/dist/processor.cjs +200 -0
  35. package/dist/processor.cjs.map +1 -0
  36. package/dist/processor.d.cts +58 -0
  37. package/dist/processor.d.ts +58 -0
  38. package/dist/processor.js +193 -0
  39. package/dist/processor.js.map +1 -0
  40. package/dist/provider.cjs +219 -0
  41. package/dist/provider.cjs.map +1 -0
  42. package/dist/provider.d.cts +41 -0
  43. package/dist/provider.d.ts +41 -0
  44. package/dist/provider.js +213 -0
  45. package/dist/provider.js.map +1 -0
  46. package/dist/tag.cjs +50 -0
  47. package/dist/tag.cjs.map +1 -0
  48. package/dist/tag.d.cts +9 -0
  49. package/dist/tag.d.ts +9 -0
  50. package/dist/tag.js +48 -0
  51. package/dist/tag.js.map +1 -0
  52. package/dist/types-BHGiwqcp.d.cts +157 -0
  53. package/dist/types-BHGiwqcp.d.ts +157 -0
  54. package/package.json +108 -0
  55. package/schemas/README.md +24 -0
  56. package/schemas/audit-matrix-v0.2.0.json +78 -0
  57. package/schemas/ledger-entry-v0.2.0.json +77 -0
  58. package/src/attrs.test.ts +35 -0
  59. package/src/attrs.ts +53 -0
  60. package/src/audit.test.ts +189 -0
  61. package/src/audit.ts +251 -0
  62. package/src/auto-wrap.test.ts +149 -0
  63. package/src/auto-wrap.ts +283 -0
  64. package/src/broker.test.ts +175 -0
  65. package/src/broker.ts +118 -0
  66. package/src/cli.test.ts +148 -0
  67. package/src/cli.ts +287 -0
  68. package/src/index.ts +94 -0
  69. package/src/labels.ts +25 -0
  70. package/src/ledger-normalize.test.ts +141 -0
  71. package/src/ledger-normalize.ts +82 -0
  72. package/src/ledger.test.ts +92 -0
  73. package/src/ledger.ts +156 -0
  74. package/src/pact-file.test.ts +124 -0
  75. package/src/pact-file.ts +65 -0
  76. package/src/processor.test.ts +90 -0
  77. package/src/processor.ts +191 -0
  78. package/src/tag.test.ts +72 -0
  79. package/src/tag.ts +21 -0
  80. package/src/types.ts +169 -0
  81. package/src/wrapper-http.test.ts +133 -0
  82. package/src/wrapper-http.ts +194 -0
  83. package/src/wrapper-provider.test.ts +132 -0
  84. package/src/wrapper-provider.ts +163 -0
  85. package/src/wrapper.test.ts +176 -0
  86. package/src/wrapper.ts +221 -0
@@ -0,0 +1,194 @@
1
+ // HTTP Pact wrapper. Mirrors the Pact-Message wrapper shape but targets
2
+ // PactV3 / PactV4 (HTTP) instances, where the lifecycle is
3
+ // `addInteraction(...)` + `executeTest(fn)` instead of `.verify(handler)`.
4
+
5
+ import { span as autotelSpan, getActiveSpan } from 'autotel';
6
+ import { buildPactAttributes, outcomeAttribute } from './attrs.js';
7
+ import { appendLedgerEntry, type LedgerOptions } from './ledger.js';
8
+ import {
9
+ LEDGER_ENTRY_SPEC,
10
+ type InteractionLedgerEntry,
11
+ type PactInteractionMeta,
12
+ } from './types.js';
13
+
14
+ /**
15
+ * Minimal structural type for a PactV3 / PactV4 HTTP pact instance — we
16
+ * only call `addInteraction` and `executeTest`. The `opts` property is
17
+ * private in Pact-JS but readable at runtime, exactly like the message
18
+ * variant's `config`.
19
+ */
20
+ export interface HttpPactLike {
21
+ addInteraction: (interaction: HttpInteraction) => unknown;
22
+ executeTest: <T>(testFn: (mockServer: HttpMockServer) => Promise<T>) => Promise<T | undefined>;
23
+ }
24
+
25
+ interface HttpPactWithOpts extends HttpPactLike {
26
+ opts?: { consumer?: string; provider?: string };
27
+ }
28
+
29
+ /**
30
+ * Structural shape of a single HTTP interaction as passed to
31
+ * `PactV3.addInteraction`. We only read the description and states
32
+ * directly; the rest is forwarded to Pact untouched.
33
+ */
34
+ export interface HttpInteraction {
35
+ uponReceiving: string;
36
+ states?: Array<{ description: string; parameters?: unknown }>;
37
+ withRequest?: unknown;
38
+ willRespondWith?: unknown;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ /** Subset of Pact's `V3MockServer` the test function typically uses. */
43
+ export interface HttpMockServer {
44
+ url: string;
45
+ port: number;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export type HttpPactTestFn<T> = (mockServer: HttpMockServer) => Promise<T>;
50
+
51
+ export interface WithHttpPactInteractionOptions extends LedgerOptions {
52
+ contractFile?: string;
53
+ spanName?: string;
54
+ consumer?: string;
55
+ provider?: string;
56
+ /** See {@link WithPactInteractionOptions.interactionId}. */
57
+ interactionId?: string;
58
+ }
59
+
60
+ function resolveHttpParticipants(
61
+ pact: HttpPactLike,
62
+ opts: WithHttpPactInteractionOptions,
63
+ ): { consumer: string; provider: string } {
64
+ const fromOpts = (pact as HttpPactWithOpts).opts;
65
+ const consumer = opts.consumer ?? fromOpts?.consumer;
66
+ const provider = opts.provider ?? fromOpts?.provider;
67
+ if (!consumer || !provider) {
68
+ throw new Error(
69
+ 'autotel-pact: could not resolve consumer/provider from the PactV3 instance. ' +
70
+ 'Pass `{ consumer, provider }` in the options object.',
71
+ );
72
+ }
73
+ return { consumer, provider };
74
+ }
75
+
76
+ /**
77
+ * Wrap a Pact-JS HTTP test (PactV3 / PactV4) so that:
78
+ * 1. The interaction is added to the pact via `addInteraction`.
79
+ * 2. An autotel span opens around the test body, with `pact.*` attributes.
80
+ * 3. A ledger entry records that this interaction was exercised.
81
+ *
82
+ * Mirrors `withPactInteraction` (the message variant) so the DX is the
83
+ * same regardless of contract kind.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const provider = new PactV3({ consumer: 'Web', provider: 'Catalog', dir: './pacts' });
88
+ *
89
+ * it('gets all products', async () => {
90
+ * await withHttpPactInteraction(
91
+ * provider,
92
+ * {
93
+ * states: [{ description: 'products exist' }],
94
+ * uponReceiving: 'get all products',
95
+ * withRequest: { method: 'GET', path: '/products' },
96
+ * willRespondWith: { status: 200, body: [...] },
97
+ * },
98
+ * async (mockServer) => {
99
+ * const api = new API(mockServer.url);
100
+ * expect(await api.getAllProducts()).toEqual([...]);
101
+ * },
102
+ * );
103
+ * });
104
+ * ```
105
+ */
106
+ export async function withHttpPactInteraction<T>(
107
+ pact: HttpPactLike,
108
+ interaction: HttpInteraction,
109
+ testFn: HttpPactTestFn<T>,
110
+ opts: WithHttpPactInteractionOptions = {},
111
+ ): Promise<T | undefined> {
112
+ // PactV3 interactions have no metadata channel we can write an id into
113
+ // (V4's `comments` field will let us do this in a future release). Without
114
+ // a way to land the id on the pact-file side, observed-side and contracted-
115
+ // side would key on different identities and produce spurious STALE+SHADOW
116
+ // pairs. Refuse the option until v0.2 wires it through PactV4.
117
+ if (opts.interactionId !== undefined) {
118
+ throw new Error(
119
+ 'autotel-pact: `interactionId` is not yet supported for HTTP Pact. ' +
120
+ 'PactV3 interactions have no metadata channel to persist the id; ' +
121
+ 'support arrives in v0.2 via PactV4 comments. Use the description as ' +
122
+ 'the stable identity for HTTP interactions in v0.1.',
123
+ );
124
+ }
125
+
126
+ const start = process.hrtime.bigint();
127
+ const spanName = opts.spanName ?? 'pact.interaction';
128
+ const kind = 'http' as const;
129
+ const { consumer, provider } = resolveHttpParticipants(pact, opts);
130
+
131
+ const meta: PactInteractionMeta = {
132
+ consumer,
133
+ provider,
134
+ description: interaction.uponReceiving,
135
+ states: (interaction.states ?? []).map((s) => s.description),
136
+ kind,
137
+ interactionId: opts.interactionId,
138
+ };
139
+
140
+ pact.addInteraction(interaction);
141
+
142
+ return autotelSpan(spanName, async (span) => {
143
+ span.setAttributes(buildPactAttributes(meta, { contractFile: opts.contractFile }));
144
+
145
+ try {
146
+ const result = await pact.executeTest(testFn);
147
+ span.setAttributes(outcomeAttribute('passed'));
148
+ writeHttpLedgerEntry({ meta, outcome: 'passed', start, opts });
149
+ return result;
150
+ } catch (error) {
151
+ span.setAttributes(outcomeAttribute('failed'));
152
+ writeHttpLedgerEntry({
153
+ meta,
154
+ outcome: 'failed',
155
+ start,
156
+ opts,
157
+ error: error instanceof Error ? error.message : String(error),
158
+ });
159
+ throw error;
160
+ }
161
+ });
162
+ }
163
+
164
+ function writeHttpLedgerEntry(args: {
165
+ meta: PactInteractionMeta;
166
+ outcome: 'passed' | 'failed';
167
+ start: bigint;
168
+ opts: WithHttpPactInteractionOptions;
169
+ error?: string;
170
+ }): void {
171
+ const { meta, outcome, start, opts, error } = args;
172
+ const ctx = getActiveSpan()?.spanContext();
173
+ const entry: InteractionLedgerEntry = {
174
+ type: 'interaction',
175
+ spec: LEDGER_ENTRY_SPEC,
176
+ consumer: meta.consumer,
177
+ provider: meta.provider,
178
+ interaction: meta.description,
179
+ interaction_id: meta.interactionId,
180
+ states: meta.states,
181
+ kind: 'http',
182
+ source: 'test',
183
+ role: 'consumer',
184
+ outcome,
185
+ duration_ms: Number(process.hrtime.bigint() - start) / 1e6,
186
+ observed_at: new Date().toISOString(),
187
+ trace_id: ctx?.traceId,
188
+ span_id: ctx?.spanId,
189
+ run_id: process.env.AUTOTEL_PACT_RUN_ID,
190
+ git_sha: process.env.GIT_SHA ?? process.env.GITHUB_SHA,
191
+ error,
192
+ };
193
+ appendLedgerEntry(entry, opts);
194
+ }
@@ -0,0 +1,132 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { readLedger } from './ledger.js';
6
+ import { isInteractionLedgerEntry, isProviderVerificationRun } from './types.js';
7
+ import {
8
+ withProviderVerification,
9
+ type VerifierConstructor,
10
+ } from './wrapper-provider.js';
11
+
12
+ let workDir: string;
13
+ let originalCwd: string;
14
+
15
+ beforeEach(() => {
16
+ originalCwd = process.cwd();
17
+ workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-prov-'));
18
+ process.chdir(workDir);
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.chdir(originalCwd);
23
+ rmSync(workDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function mockVerifier(outcome: 'pass' | 'fail'): VerifierConstructor {
27
+ return class {
28
+ constructor(_opts: unknown) {}
29
+ verifyProvider() {
30
+ if (outcome === 'fail') return Promise.reject(new Error('verifier failed'));
31
+ return Promise.resolve();
32
+ }
33
+ };
34
+ }
35
+
36
+ describe('withProviderVerification', () => {
37
+ it('appends per-interaction rows on success only', async () => {
38
+ const pactDir = path.join(workDir, 'pacts');
39
+ const { mkdirSync, writeFileSync } = await import('node:fs');
40
+ mkdirSync(pactDir, { recursive: true });
41
+ writeFileSync(
42
+ path.join(pactDir, 'A-B.json'),
43
+ JSON.stringify({
44
+ consumer: { name: 'A' },
45
+ provider: { name: 'B' },
46
+ messages: [{ description: 'evt1' }, { description: 'evt2' }],
47
+ }),
48
+ );
49
+
50
+ await withProviderVerification(
51
+ {
52
+ provider: 'B',
53
+ providerBaseUrl: 'http://localhost:0',
54
+ pactUrls: [path.join(pactDir, 'A-B.json')],
55
+ },
56
+ { runId: 'prov-ok', Verifier: mockVerifier('pass') },
57
+ );
58
+
59
+ const entries = readLedger({ runId: 'prov-ok' });
60
+ expect(entries).toHaveLength(2);
61
+ expect(entries.every(isInteractionLedgerEntry)).toBe(true);
62
+ expect(entries.every((e) => e.role === 'provider' && e.outcome === 'passed')).toBe(true);
63
+ });
64
+
65
+ it('skipVerifier emits per-interaction rows without loading or calling a Verifier', async () => {
66
+ const pactDir = path.join(workDir, 'pacts');
67
+ const { mkdirSync, writeFileSync } = await import('node:fs');
68
+ mkdirSync(pactDir, { recursive: true });
69
+ writeFileSync(
70
+ path.join(pactDir, 'A-B.json'),
71
+ JSON.stringify({
72
+ consumer: { name: 'A' },
73
+ provider: { name: 'B' },
74
+ messages: [{ description: 'evt1' }, { description: 'evt2' }],
75
+ }),
76
+ );
77
+
78
+ // No Verifier supplied. Without skipVerifier this would dynamically import
79
+ // @pact-foundation/pact. With skipVerifier: true, no import happens.
80
+ const VerifierThatWouldThrow = class {
81
+ constructor(_opts: unknown) {
82
+ throw new Error('Verifier should not be instantiated when skipVerifier is true');
83
+ }
84
+ verifyProvider() {
85
+ throw new Error('unreachable');
86
+ }
87
+ };
88
+
89
+ await withProviderVerification(
90
+ {
91
+ provider: 'B',
92
+ providerBaseUrl: 'http://localhost:0',
93
+ pactUrls: [path.join(pactDir, 'A-B.json')],
94
+ },
95
+ { runId: 'prov-skip', skipVerifier: true, Verifier: VerifierThatWouldThrow },
96
+ );
97
+
98
+ const entries = readLedger({ runId: 'prov-skip' });
99
+ expect(entries).toHaveLength(2);
100
+ expect(entries.every(isInteractionLedgerEntry)).toBe(true);
101
+ expect(entries.every((e) => e.role === 'provider' && e.outcome === 'passed')).toBe(true);
102
+ });
103
+
104
+ it('appends run-level failure without interaction rows', async () => {
105
+ const pactDir = path.join(workDir, 'pacts');
106
+ const { mkdirSync, writeFileSync } = await import('node:fs');
107
+ mkdirSync(pactDir, { recursive: true });
108
+ writeFileSync(
109
+ path.join(pactDir, 'A-B.json'),
110
+ JSON.stringify({
111
+ consumer: { name: 'A' },
112
+ provider: { name: 'B' },
113
+ messages: [{ description: 'evt' }],
114
+ }),
115
+ );
116
+
117
+ await expect(
118
+ withProviderVerification(
119
+ {
120
+ provider: 'B',
121
+ providerBaseUrl: 'http://localhost:0',
122
+ pactUrls: [path.join(pactDir, 'A-B.json')],
123
+ },
124
+ { runId: 'prov-fail', Verifier: mockVerifier('fail') },
125
+ ),
126
+ ).rejects.toThrow(/verifier failed/);
127
+
128
+ const entries = readLedger({ runId: 'prov-fail' });
129
+ expect(entries).toHaveLength(1);
130
+ expect(isProviderVerificationRun(entries[0]!)).toBe(true);
131
+ });
132
+ });
@@ -0,0 +1,163 @@
1
+ import path from 'node:path';
2
+ import { span as autotelSpan, getActiveSpan } from 'autotel';
3
+ import { appendLedgerEntry, appendProviderVerificationFailure, type LedgerOptions } from './ledger.js';
4
+ import { interactionsFromPactFile, parsePactFile } from './pact-file.js';
5
+ import { LEDGER_ENTRY_SPEC, type InteractionLedgerEntry, type PactKind } from './types.js';
6
+ import { PACT_ATTRS } from './attrs.js';
7
+
8
+ /**
9
+ * Minimal Verifier options — structural match for @pact-foundation/pact Verifier.
10
+ */
11
+ export interface VerifierOptionsLike {
12
+ provider: string;
13
+ providerBaseUrl: string;
14
+ pactUrls?: string[];
15
+ logLevel?: string;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ export interface VerifierLike {
20
+ verifyProvider: () => Promise<unknown>;
21
+ }
22
+
23
+ export interface VerifierConstructor {
24
+ new (options: VerifierOptionsLike): VerifierLike;
25
+ }
26
+
27
+ export interface WithProviderVerificationOptions extends LedgerOptions {
28
+ /** Consumer name when not inferrable from pact files. */
29
+ consumer?: string;
30
+ spanName?: string;
31
+ /** Custom Verifier class (defaults to dynamic import from @pact-foundation/pact). */
32
+ Verifier?: VerifierConstructor;
33
+ /**
34
+ * Skip loading and calling the Verifier entirely. Emits the same
35
+ * per-interaction ledger rows as a successful verification, parsed from the
36
+ * supplied pact files. Use for demos, smoke tests, and audit-pipeline
37
+ * exercises where running a real provider is impractical. Do not use in
38
+ * production CI: it will mark every interaction in the pact file as
39
+ * provider-verified without any actual verification.
40
+ */
41
+ skipVerifier?: boolean;
42
+ }
43
+
44
+ function resolvePactPaths(opts: VerifierOptionsLike): string[] {
45
+ const urls = opts.pactUrls ?? [];
46
+ return urls.map((u) => path.resolve(process.cwd(), u));
47
+ }
48
+
49
+ function inferConsumerFromPacts(pactPaths: string[], fallback?: string): string {
50
+ for (const filePath of pactPaths) {
51
+ const pact = parsePactFile(filePath);
52
+ if (pact?.consumer?.name) return pact.consumer.name;
53
+ }
54
+ if (fallback) return fallback;
55
+ throw new Error(
56
+ 'autotel-pact: could not infer consumer from pact files. Pass `consumer` in options.',
57
+ );
58
+ }
59
+
60
+ function kindForPactFile(filePath: string): PactKind {
61
+ const pact = parsePactFile(filePath);
62
+ if (!pact) return 'message';
63
+ if ((pact.interactions?.length ?? 0) > 0) return 'http';
64
+ return 'message';
65
+ }
66
+
67
+ async function loadVerifier(
68
+ VerifierClass?: VerifierConstructor,
69
+ ): Promise<VerifierConstructor> {
70
+ if (VerifierClass) return VerifierClass;
71
+ const mod = await import('@pact-foundation/pact');
72
+ const Verifier = (mod as { Verifier?: VerifierConstructor }).Verifier;
73
+ if (!Verifier) {
74
+ throw new Error(
75
+ 'autotel-pact: @pact-foundation/pact Verifier not found. Install the peer dependency.',
76
+ );
77
+ }
78
+ return Verifier;
79
+ }
80
+
81
+ /**
82
+ * Wrap provider verification — records per-interaction evidence only on success.
83
+ */
84
+ export async function withProviderVerification(
85
+ verifierOpts: VerifierOptionsLike,
86
+ wrapOpts: WithProviderVerificationOptions = {},
87
+ ): Promise<void> {
88
+ const pactPaths = resolvePactPaths(verifierOpts);
89
+ const provider = verifierOpts.provider;
90
+ const consumer = inferConsumerFromPacts(pactPaths, wrapOpts.consumer);
91
+ const spanName = wrapOpts.spanName ?? 'pact.verification';
92
+ const start = process.hrtime.bigint();
93
+ const Verifier = wrapOpts.skipVerifier ? undefined : await loadVerifier(wrapOpts.Verifier);
94
+
95
+ return autotelSpan(spanName, async () => {
96
+ const span = getActiveSpan();
97
+ span?.setAttributes({
98
+ [PACT_ATTRS.CONSUMER]: consumer,
99
+ [PACT_ATTRS.PROVIDER]: provider,
100
+ [PACT_ATTRS.KIND]: pactPaths.length === 1 ? kindForPactFile(pactPaths[0]!) : 'message',
101
+ 'pact.role': 'provider',
102
+ });
103
+
104
+ try {
105
+ if (Verifier) {
106
+ await new Verifier(verifierOpts).verifyProvider();
107
+ }
108
+ span?.setAttributes({ [PACT_ATTRS.OUTCOME]: 'passed' });
109
+
110
+ const ctx = span?.spanContext();
111
+ const base = {
112
+ source: 'test' as const,
113
+ role: 'provider' as const,
114
+ outcome: 'passed' as const,
115
+ observed_at: new Date().toISOString(),
116
+ trace_id: ctx?.traceId,
117
+ span_id: ctx?.spanId,
118
+ run_id: process.env.AUTOTEL_PACT_RUN_ID,
119
+ git_sha: process.env.GIT_SHA ?? process.env.GITHUB_SHA,
120
+ };
121
+
122
+ for (const filePath of pactPaths) {
123
+ const pact = parsePactFile(filePath);
124
+ if (!pact) continue;
125
+ const interactions = interactionsFromPactFile(pact);
126
+ for (const i of interactions) {
127
+ const entry: InteractionLedgerEntry = {
128
+ type: 'interaction',
129
+ spec: LEDGER_ENTRY_SPEC,
130
+ consumer: i.consumer,
131
+ provider: i.provider,
132
+ interaction: i.interaction,
133
+ interaction_id: i.interactionId,
134
+ states: [],
135
+ kind: i.kind,
136
+ duration_ms: Number(process.hrtime.bigint() - start) / 1e6,
137
+ ...base,
138
+ };
139
+ appendLedgerEntry(entry, wrapOpts);
140
+ }
141
+ }
142
+ } catch (error) {
143
+ span?.setAttributes({ [PACT_ATTRS.OUTCOME]: 'failed' });
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ const ctx = span?.spanContext();
146
+ appendProviderVerificationFailure(
147
+ {
148
+ consumer,
149
+ provider,
150
+ source: 'test',
151
+ observed_at: new Date().toISOString(),
152
+ error: message,
153
+ trace_id: ctx?.traceId,
154
+ span_id: ctx?.spanId,
155
+ run_id: process.env.AUTOTEL_PACT_RUN_ID,
156
+ git_sha: process.env.GIT_SHA ?? process.env.GITHUB_SHA,
157
+ },
158
+ wrapOpts,
159
+ );
160
+ throw error;
161
+ }
162
+ });
163
+ }
@@ -0,0 +1,176 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { readLedger } from './ledger.js';
6
+ import { withPactInteraction, type MessageConsumerPactLike, type ReifiedMessage } from './wrapper.js';
7
+
8
+ class FakeMessagePact implements MessageConsumerPactLike {
9
+ config = { consumer: 'OrderShipper', provider: 'OrderService' };
10
+ recordedMetadata: Record<string, unknown> = {};
11
+
12
+ constructor(
13
+ private message: ReifiedMessage,
14
+ private shouldFailHandler = false,
15
+ ) {}
16
+
17
+ withMetadata(metadata: Record<string, unknown>): this {
18
+ Object.assign(this.recordedMetadata, metadata);
19
+ return this;
20
+ }
21
+
22
+ async verify(handler: (msg: ReifiedMessage) => unknown): Promise<unknown> {
23
+ if (this.shouldFailHandler) {
24
+ throw new Error('handler rejected message');
25
+ }
26
+ return handler(this.message);
27
+ }
28
+ }
29
+
30
+ let workDir: string;
31
+ let originalCwd: string;
32
+
33
+ beforeEach(() => {
34
+ originalCwd = process.cwd();
35
+ workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-wrap-'));
36
+ process.chdir(workDir);
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.chdir(originalCwd);
41
+ rmSync(workDir, { recursive: true, force: true });
42
+ delete process.env.AUTOTEL_PACT_RUN_ID;
43
+ });
44
+
45
+ const sampleMessage: ReifiedMessage = {
46
+ contents: { orderId: 'ord-123' },
47
+ description: 'an OrderCreated event',
48
+ providerStates: [{ name: 'an order exists' }],
49
+ metadata: { 'content-type': 'application/json' },
50
+ };
51
+
52
+ describe('withPactInteraction', () => {
53
+ it('runs the handler and writes a passed ledger entry', async () => {
54
+ const pact = new FakeMessagePact(sampleMessage);
55
+ const result = await withPactInteraction(
56
+ pact,
57
+ (msg) => ({ processed: (msg.contents as { orderId: string }).orderId }),
58
+ { runId: 'r-pass' },
59
+ );
60
+
61
+ expect(result).toEqual({ processed: 'ord-123' });
62
+
63
+ const entries = readLedger({ runId: 'r-pass' });
64
+ expect(entries).toHaveLength(1);
65
+ expect(entries[0]).toMatchObject({
66
+ consumer: 'OrderShipper',
67
+ provider: 'OrderService',
68
+ interaction: 'an OrderCreated event',
69
+ states: ['an order exists'],
70
+ kind: 'message',
71
+ outcome: 'passed',
72
+ });
73
+ expect(entries[0]!.duration_ms).toBeGreaterThanOrEqual(0);
74
+ expect(entries[0]!.observed_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
75
+ });
76
+
77
+ it('writes a failed ledger entry when the handler throws', async () => {
78
+ const pact = new FakeMessagePact(sampleMessage);
79
+ await expect(
80
+ withPactInteraction(
81
+ pact,
82
+ () => {
83
+ throw new Error('boom');
84
+ },
85
+ { runId: 'r-fail' },
86
+ ),
87
+ ).rejects.toThrow('boom');
88
+
89
+ const entries = readLedger({ runId: 'r-fail' });
90
+ expect(entries).toHaveLength(1);
91
+ expect(entries[0]).toMatchObject({ outcome: 'failed', error: 'boom' });
92
+ });
93
+
94
+ it('writes a failed ledger entry when pact.verify itself rejects', async () => {
95
+ const pact = new FakeMessagePact(sampleMessage, true);
96
+ await expect(
97
+ withPactInteraction(pact, () => {}, { runId: 'r-verify-fail' }),
98
+ ).rejects.toThrow('handler rejected message');
99
+
100
+ const entries = readLedger({ runId: 'r-verify-fail' });
101
+ expect(entries).toHaveLength(1);
102
+ expect(entries[0]!.outcome).toBe('failed');
103
+ });
104
+
105
+ it('persists interactionId on the ledger entry when supplied', async () => {
106
+ const pact = new FakeMessagePact(sampleMessage);
107
+ await withPactInteraction(pact, () => 'ok', {
108
+ runId: 'r-iid',
109
+ interactionId: 'order.created.v1',
110
+ });
111
+
112
+ const entries = readLedger({ runId: 'r-iid' });
113
+ expect(entries[0]!.interaction_id).toBe('order.created.v1');
114
+ });
115
+
116
+ it('writes interactionId into the pact message metadata so the audit can match both sides', async () => {
117
+ const pact = new FakeMessagePact(sampleMessage);
118
+ await withPactInteraction(pact, () => 'ok', {
119
+ runId: 'r-iid-meta',
120
+ interactionId: 'order.created.v1',
121
+ });
122
+
123
+ // The metadata makes its way into the pact file via Pact-JS's
124
+ // `withMetadata` API. The audit's `extractInteractionId` then reads it
125
+ // back on the contracted side, so the row collapses with the ledger
126
+ // (interaction_id-keyed) row instead of fragmenting.
127
+ expect(pact.recordedMetadata.interactionId).toBe('order.created.v1');
128
+ });
129
+
130
+ it('does not touch withMetadata when interactionId is not supplied', async () => {
131
+ const pact = new FakeMessagePact(sampleMessage);
132
+ await withPactInteraction(pact, () => 'ok', { runId: 'r-no-iid' });
133
+ expect(pact.recordedMetadata).toEqual({});
134
+ });
135
+
136
+ it('throws a helpful error when consumer/provider cannot be resolved', async () => {
137
+ // Pact-like with no config and no override — wrapper should refuse cleanly.
138
+ const bare: MessageConsumerPactLike = {
139
+ async verify(handler) {
140
+ return handler(sampleMessage);
141
+ },
142
+ };
143
+ await expect(
144
+ withPactInteraction(bare, () => {}, { runId: 'r-no-cfg' }),
145
+ ).rejects.toThrow(/could not resolve consumer\/provider/);
146
+ });
147
+
148
+ it('uses opts.consumer/provider fallbacks when the pact lacks config', async () => {
149
+ const bare: MessageConsumerPactLike = {
150
+ async verify(handler) {
151
+ return handler(sampleMessage);
152
+ },
153
+ };
154
+ await withPactInteraction(bare, () => {}, {
155
+ runId: 'r-fallback',
156
+ consumer: 'OverrideConsumer',
157
+ provider: 'OverrideProvider',
158
+ });
159
+
160
+ const entries = readLedger({ runId: 'r-fallback' });
161
+ expect(entries[0]).toMatchObject({
162
+ consumer: 'OverrideConsumer',
163
+ provider: 'OverrideProvider',
164
+ });
165
+ });
166
+
167
+ it('records one ledger entry per interaction when the same pact instance is reused', async () => {
168
+ const pact = new FakeMessagePact(sampleMessage);
169
+ await withPactInteraction(pact, () => 'a', { runId: 'r-reuse' });
170
+ await withPactInteraction(pact, () => 'b', { runId: 'r-reuse' });
171
+
172
+ const entries = readLedger({ runId: 'r-reuse' });
173
+ expect(entries).toHaveLength(2);
174
+ expect(entries.every((e) => e.outcome === 'passed')).toBe(true);
175
+ });
176
+ });