mailery 0.7.0 → 0.8.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/dist/admin/spa/{index-CjQTOX9H.js → index-B3ndbezm.js} +3 -3
- package/dist/admin/spa/index-B3ndbezm.js.map +1 -0
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js → template-editor-DwMKy5sw.js} +3 -3
- package/dist/admin/spa/{template-editor-yH0Oz4Ix.js.map → template-editor-DwMKy5sw.js.map} +1 -1
- package/dist/index.cjs +25 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +25 -11
- package/dist/index.js.map +1 -1
- package/dist/{null-B0rPgE5_.d.cts → null-BCxlHRJ0.d.cts} +9 -1
- package/dist/{null-B0rPgE5_.d.ts → null-BCxlHRJ0.d.ts} +9 -1
- package/dist/testing.cjs +331 -67
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +233 -13
- package/dist/testing.d.ts +233 -13
- package/dist/testing.js +325 -68
- package/dist/testing.js.map +1 -1
- package/package.json +4 -1
- package/dist/admin/spa/index-CjQTOX9H.js.map +0 -1
package/dist/testing.d.cts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Db } from 'mongodb';
|
|
2
|
-
import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer,
|
|
2
|
+
import { C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, N as NormalizedEvent, F as FlowStep, a2 as FlowTrigger, m as FlowGoal, U as TemplateKind, T as TemplateDoc, l as FlowDoc, D as DeliveryWindow, R as RunnerContext, d as Mailer, a3 as QueueDriverConfig } from './null-BCxlHRJ0.cjs';
|
|
3
|
+
export { s as NullProvider } from './null-BCxlHRJ0.cjs';
|
|
3
4
|
import 'zod';
|
|
4
5
|
import 'handlebars';
|
|
5
6
|
import 'ioredis';
|
|
@@ -29,36 +30,255 @@ declare class MemoryContactAdapter implements ContactAdapter {
|
|
|
29
30
|
removeTags(externalId: string, tags: string[]): Promise<void>;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* RecordingProvider — a `MailProvider` decorator that records every send and
|
|
35
|
+
* then delegates to whatever it wraps.
|
|
36
|
+
*
|
|
37
|
+
* This is what lets one test suite run against two very different backends.
|
|
38
|
+
* Wrapping `NullProvider` gives fast, offline, deterministic assertions;
|
|
39
|
+
* wrapping `SendGridProvider` makes the *same* assertions run against the real
|
|
40
|
+
* API, with real auth, real payload validation and (outside sandbox mode) real
|
|
41
|
+
* delivery. Tests assert on the recording either way, so nothing is duplicated
|
|
42
|
+
* between the two tiers.
|
|
43
|
+
*
|
|
44
|
+
* const provider = new RecordingProvider(new NullProvider())
|
|
45
|
+
* ...
|
|
46
|
+
* expect(provider.sent[0]?.subject).toContain('Alice') // SendArgs
|
|
47
|
+
* expect(provider.records[0]?.result?.status).toBe('accepted')
|
|
48
|
+
*
|
|
49
|
+
* `sent` is deliberately a bare `SendArgs[]`, matching `NullProvider.sent`, so
|
|
50
|
+
* this drops in wherever the old harness provider was used. `records` carries
|
|
51
|
+
* the richer per-call detail (result, error, duration) that the live tier needs.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
interface SendRecord {
|
|
55
|
+
args: SendArgs;
|
|
56
|
+
/** Provider result, or null when the call threw. */
|
|
57
|
+
result: SendResult | null;
|
|
58
|
+
/** Error thrown by the wrapped provider, or null on success. */
|
|
59
|
+
error: Error | null;
|
|
60
|
+
/** Wall-clock ms the wrapped `send` took. Meaningful for the live tier. */
|
|
61
|
+
durationMs: number;
|
|
62
|
+
at: Date;
|
|
63
|
+
}
|
|
64
|
+
declare class RecordingProvider implements MailProvider {
|
|
65
|
+
readonly inner: MailProvider;
|
|
66
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
67
|
+
readonly sent: SendArgs[];
|
|
68
|
+
/** Same calls, with outcome attached. */
|
|
69
|
+
readonly records: SendRecord[];
|
|
70
|
+
constructor(inner: MailProvider);
|
|
71
|
+
get name(): string;
|
|
72
|
+
get sendRatePerSecond(): number | undefined;
|
|
73
|
+
send(args: SendArgs): Promise<SendResult>;
|
|
74
|
+
verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
|
|
75
|
+
parseWebhookEvents(payload: unknown, headers: Record<string, string>): NormalizedEvent[];
|
|
76
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
77
|
+
get last(): SendArgs | undefined;
|
|
78
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
79
|
+
toRecipient(email: string): SendArgs[];
|
|
80
|
+
reset(): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Document builders for tests. Hand-rolling a full `TemplateDoc` / `FlowDoc`
|
|
85
|
+
* literal is ~40 lines of noise per fixture; these fill every required field
|
|
86
|
+
* with a sane default so a test only states what it is actually asserting on.
|
|
87
|
+
*
|
|
88
|
+
* await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
|
|
89
|
+
* await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [...] })
|
|
90
|
+
*
|
|
91
|
+
* `createdAt` defaults to a minute in the past: the event-trigger scan uses
|
|
92
|
+
* `lastTriggerScanAt ?? createdAt` as its watermark and only picks up events
|
|
93
|
+
* with `occurredAt > watermark`, so a flow created at the same instant as the
|
|
94
|
+
* event it should catch would never fire.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
interface TemplateSpec {
|
|
98
|
+
slug: string;
|
|
99
|
+
name?: string;
|
|
100
|
+
description?: string;
|
|
101
|
+
kind?: TemplateKind;
|
|
102
|
+
fromName?: string;
|
|
103
|
+
fromEmail?: string;
|
|
104
|
+
replyTo?: string | null;
|
|
105
|
+
providerOverride?: string | null;
|
|
106
|
+
subject?: string;
|
|
107
|
+
preheader?: string;
|
|
108
|
+
/** MJML source — compiled to html + derived plain text. */
|
|
109
|
+
mjml?: string;
|
|
110
|
+
/** Pre-compiled HTML. Wins over `mjml`. */
|
|
111
|
+
html?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Body copy wrapped in a minimal MJML document. The lazy path — use when the
|
|
114
|
+
* test cares about the rendered *content*, not the markup around it.
|
|
115
|
+
*/
|
|
116
|
+
text?: string;
|
|
117
|
+
/** Explicit plain-text part. Omit to auto-derive from the HTML. */
|
|
118
|
+
plainText?: string;
|
|
119
|
+
variablesSchema?: TemplateDoc['variablesSchema'];
|
|
120
|
+
tags?: string[];
|
|
121
|
+
trackOpens?: boolean;
|
|
122
|
+
trackClicks?: boolean;
|
|
123
|
+
published?: boolean;
|
|
124
|
+
createdAt?: Date;
|
|
125
|
+
}
|
|
126
|
+
declare function buildTemplate(spec: TemplateSpec): Promise<TemplateDoc>;
|
|
127
|
+
/** Minimal valid MJML document around a body string. */
|
|
128
|
+
declare function wrapMjml(body: string): string;
|
|
129
|
+
interface FlowSpec {
|
|
130
|
+
slug: string;
|
|
131
|
+
name?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
steps: FlowStep[];
|
|
134
|
+
/** Shorthand for an `event` trigger. Ignored when `trigger` is given. */
|
|
135
|
+
eventName?: string;
|
|
136
|
+
/** Re-entry policy for the shorthand trigger. Defaults to true (enter once). */
|
|
137
|
+
once?: boolean;
|
|
138
|
+
trigger?: FlowTrigger;
|
|
139
|
+
enabled?: boolean;
|
|
140
|
+
goal?: FlowGoal;
|
|
141
|
+
audience?: string;
|
|
142
|
+
version?: number;
|
|
143
|
+
createdAt?: Date;
|
|
144
|
+
}
|
|
145
|
+
declare function buildFlow(spec: FlowSpec): FlowDoc;
|
|
146
|
+
declare const step: {
|
|
147
|
+
readonly send: (templateSlug: string, opts?: Omit<Extract<FlowStep, {
|
|
148
|
+
type: "send";
|
|
149
|
+
}>, "type" | "templateSlug">) => FlowStep;
|
|
150
|
+
readonly sendAt: (templateSlug: string, delivery: DeliveryWindow) => FlowStep;
|
|
151
|
+
readonly wait: (value: number, unit?: "minutes" | "hours" | "days" | "weeks") => FlowStep;
|
|
152
|
+
readonly tag: (addTags?: string[], removeTags?: string[]) => FlowStep;
|
|
153
|
+
readonly exit: (reason?: string) => FlowStep;
|
|
154
|
+
readonly fireEvent: (eventName: string, properties?: Record<string, unknown>) => FlowStep;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Deterministic runner driver.
|
|
159
|
+
*
|
|
160
|
+
* The test harness uses the `noop` queue driver: `queue.add()` throws nothing
|
|
161
|
+
* away quietly but nothing ever fires it either, so the runner only moves when
|
|
162
|
+
* a test tells it to. `drain` is that push — it alternates the trigger scan,
|
|
163
|
+
* the due-run sweep and send dispatch until the system is quiescent, which is
|
|
164
|
+
* what a real deployment converges to between ticks.
|
|
165
|
+
*
|
|
166
|
+
* await H.mailer.fire('Created', 'u1')
|
|
167
|
+
* await drain(H.mailer.getRunnerContext())
|
|
168
|
+
* expect(H.provider.sent).toHaveLength(1)
|
|
169
|
+
*
|
|
170
|
+
* "Quiescent" means: no active flow_run whose `nextActionAt` has passed, and
|
|
171
|
+
* no queued send left to dispatch. Runs parked in a `wait` step or deferred by
|
|
172
|
+
* a delivery window are *expected* to remain — move the clock forward and
|
|
173
|
+
* drain again.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
interface DrainOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Safety valve. Each round advances every due run by exactly one step, so a
|
|
179
|
+
* flow needs one round per step. Hitting the cap means the system did not
|
|
180
|
+
* converge — `settled` comes back false rather than throwing, so a test can
|
|
181
|
+
* assert on non-convergence deliberately.
|
|
182
|
+
*/
|
|
183
|
+
maxRounds?: number;
|
|
184
|
+
/** Skip send dispatch to inspect `queued` send rows before they go out. */
|
|
185
|
+
dispatch?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Let provider/render errors propagate. Off by default: `dispatchSend`
|
|
188
|
+
* rethrows so the real queue can retry, and a drain that unwound on the
|
|
189
|
+
* first failure could not assert on the failed send row it just wrote.
|
|
190
|
+
*/
|
|
191
|
+
throwOnSendError?: boolean;
|
|
192
|
+
}
|
|
193
|
+
interface DrainResult {
|
|
194
|
+
rounds: number;
|
|
195
|
+
/** Sends passed to `dispatchSend`. Not all of them reached the provider. */
|
|
196
|
+
dispatched: number;
|
|
197
|
+
/** Errors thrown by `dispatchSend`, swallowed unless `throwOnSendError`. */
|
|
198
|
+
errors: Error[];
|
|
199
|
+
settled: boolean;
|
|
200
|
+
}
|
|
201
|
+
declare function drain(ctx: RunnerContext, opts?: DrainOptions): Promise<DrainResult>;
|
|
202
|
+
/**
|
|
203
|
+
* Dispatch every currently-queued send, once. Use when a test drove the flow
|
|
204
|
+
* with `drain({ dispatch: false })` and wants to inspect the queued rows first.
|
|
205
|
+
*/
|
|
206
|
+
declare function dispatchQueued(ctx: RunnerContext): Promise<number>;
|
|
207
|
+
|
|
32
208
|
/**
|
|
33
209
|
* Test helpers — `import { ... } from 'mailery/testing'`.
|
|
34
210
|
*
|
|
35
|
-
* const
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* //
|
|
39
|
-
*
|
|
40
|
-
* await
|
|
41
|
-
*
|
|
42
|
-
*
|
|
211
|
+
* const H = await createTestMailer({
|
|
212
|
+
* seedContacts: [{ externalId: 'u1', email: 'alice@example.com', tags: [], fields: {} }],
|
|
213
|
+
* })
|
|
214
|
+
* await H.seedContact({ externalId: 'u1', ... }) // adapter + subscription
|
|
215
|
+
* await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
|
|
216
|
+
* await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [step.send('welcome')] })
|
|
217
|
+
*
|
|
218
|
+
* H.mailer.registerEvent({ name: 'Created', dedupePolicy: 'once-per-contact' })
|
|
219
|
+
* await H.mailer.fire('Created', 'u1')
|
|
220
|
+
* await H.drain() // run to quiescence
|
|
43
221
|
*
|
|
44
|
-
*
|
|
222
|
+
* expect(H.provider.sent).toHaveLength(1)
|
|
223
|
+
* await H.stop()
|
|
224
|
+
*
|
|
225
|
+
* Backed by mongodb-memory-server + the `noop` queue driver, so nothing runs in
|
|
226
|
+
* the background and every transition is something the test asked for. The
|
|
227
|
+
* provider is always wrapped in a `RecordingProvider`, which is what allows the
|
|
228
|
+
* same suite to run against `NullProvider` offline or against real SendGrid
|
|
229
|
+
* (`provider: 'sendgrid'`) without changing a single assertion.
|
|
45
230
|
*/
|
|
46
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Which provider the harness sends through.
|
|
234
|
+
* - `'null'` — in-memory, offline, the default.
|
|
235
|
+
* - `'sendgrid'` — the real API. Needs `SENDGRID_API_KEY`. Sandbox mode
|
|
236
|
+
* unless `MAILERY_LIVE_E2E=deliver`, so the default costs
|
|
237
|
+
* nothing and delivers nothing while still proving that
|
|
238
|
+
* SendGrid accepts the payload.
|
|
239
|
+
* - a `MailProvider` instance — anything else you want to record around.
|
|
240
|
+
*/
|
|
241
|
+
type ProviderSpec = 'null' | 'sendgrid' | MailProvider;
|
|
47
242
|
interface TestMailerOptions {
|
|
48
243
|
adapter?: ContactAdapter;
|
|
49
244
|
seedContacts?: Contact[];
|
|
50
|
-
provider?:
|
|
245
|
+
provider?: ProviderSpec;
|
|
246
|
+
/**
|
|
247
|
+
* Queue driver. Defaults to `noop`, which is what makes the fast matrix
|
|
248
|
+
* deterministic — nothing runs until a test calls `drain()`. The live tier
|
|
249
|
+
* passes a real `bull` config to exercise job delays, retries and the send
|
|
250
|
+
* rate limiter, and must then also set `startWorkers`.
|
|
251
|
+
*/
|
|
252
|
+
queue?: QueueDriverConfig;
|
|
253
|
+
/** Call `mailer.startWorkers()` after init. Requires a non-noop `queue`. */
|
|
254
|
+
startWorkers?: boolean;
|
|
51
255
|
/** Override Mailer config (excluding required fields the harness fills in). */
|
|
52
256
|
config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
|
|
53
257
|
}
|
|
258
|
+
interface SeedContactOptions {
|
|
259
|
+
/** Also create a `subscribed` subscription row. Default true. */
|
|
260
|
+
subscribe?: boolean;
|
|
261
|
+
source?: string;
|
|
262
|
+
}
|
|
54
263
|
interface TestMailerHarness {
|
|
55
264
|
mailer: Mailer;
|
|
56
265
|
db: Db;
|
|
57
|
-
provider
|
|
266
|
+
/** Recording wrapper around the configured provider. */
|
|
267
|
+
provider: RecordingProvider;
|
|
58
268
|
adapter: ContactAdapter;
|
|
59
269
|
memoryAdapter: MemoryContactAdapter | null;
|
|
270
|
+
/** The runner context, for passing to `runTick` / `processOneRunStep` / `drain`. */
|
|
271
|
+
ctx: RunnerContext;
|
|
272
|
+
/** Insert a contact into the adapter and (by default) subscribe it. */
|
|
273
|
+
seedContact: (contact: Contact, opts?: SeedContactOptions) => Promise<Contact>;
|
|
274
|
+
/** Build + insert a published template. Returns the doc, `_id` included. */
|
|
275
|
+
seedTemplate: (spec: TemplateSpec) => Promise<TemplateDoc>;
|
|
276
|
+
/** Build + insert a published flow. Returns the doc, `_id` included. */
|
|
277
|
+
seedFlow: (spec: FlowSpec) => Promise<FlowDoc>;
|
|
278
|
+
/** Run the runner to quiescence. See `drive.ts`. */
|
|
279
|
+
drain: (opts?: DrainOptions) => Promise<DrainResult>;
|
|
60
280
|
stop: () => Promise<void>;
|
|
61
281
|
}
|
|
62
282
|
declare function createTestMailer(opts?: TestMailerOptions): Promise<TestMailerHarness>;
|
|
63
283
|
|
|
64
|
-
export { MemoryContactAdapter,
|
|
284
|
+
export { type DrainOptions, type DrainResult, type FlowSpec, MemoryContactAdapter, type ProviderSpec, RecordingProvider, type SeedContactOptions, type SendRecord, type TemplateSpec, type TestMailerHarness, type TestMailerOptions, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Db } from 'mongodb';
|
|
2
|
-
import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer,
|
|
2
|
+
import { C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, N as NormalizedEvent, F as FlowStep, a2 as FlowTrigger, m as FlowGoal, U as TemplateKind, T as TemplateDoc, l as FlowDoc, D as DeliveryWindow, R as RunnerContext, d as Mailer, a3 as QueueDriverConfig } from './null-BCxlHRJ0.js';
|
|
3
|
+
export { s as NullProvider } from './null-BCxlHRJ0.js';
|
|
3
4
|
import 'zod';
|
|
4
5
|
import 'handlebars';
|
|
5
6
|
import 'ioredis';
|
|
@@ -29,36 +30,255 @@ declare class MemoryContactAdapter implements ContactAdapter {
|
|
|
29
30
|
removeTags(externalId: string, tags: string[]): Promise<void>;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* RecordingProvider — a `MailProvider` decorator that records every send and
|
|
35
|
+
* then delegates to whatever it wraps.
|
|
36
|
+
*
|
|
37
|
+
* This is what lets one test suite run against two very different backends.
|
|
38
|
+
* Wrapping `NullProvider` gives fast, offline, deterministic assertions;
|
|
39
|
+
* wrapping `SendGridProvider` makes the *same* assertions run against the real
|
|
40
|
+
* API, with real auth, real payload validation and (outside sandbox mode) real
|
|
41
|
+
* delivery. Tests assert on the recording either way, so nothing is duplicated
|
|
42
|
+
* between the two tiers.
|
|
43
|
+
*
|
|
44
|
+
* const provider = new RecordingProvider(new NullProvider())
|
|
45
|
+
* ...
|
|
46
|
+
* expect(provider.sent[0]?.subject).toContain('Alice') // SendArgs
|
|
47
|
+
* expect(provider.records[0]?.result?.status).toBe('accepted')
|
|
48
|
+
*
|
|
49
|
+
* `sent` is deliberately a bare `SendArgs[]`, matching `NullProvider.sent`, so
|
|
50
|
+
* this drops in wherever the old harness provider was used. `records` carries
|
|
51
|
+
* the richer per-call detail (result, error, duration) that the live tier needs.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
interface SendRecord {
|
|
55
|
+
args: SendArgs;
|
|
56
|
+
/** Provider result, or null when the call threw. */
|
|
57
|
+
result: SendResult | null;
|
|
58
|
+
/** Error thrown by the wrapped provider, or null on success. */
|
|
59
|
+
error: Error | null;
|
|
60
|
+
/** Wall-clock ms the wrapped `send` took. Meaningful for the live tier. */
|
|
61
|
+
durationMs: number;
|
|
62
|
+
at: Date;
|
|
63
|
+
}
|
|
64
|
+
declare class RecordingProvider implements MailProvider {
|
|
65
|
+
readonly inner: MailProvider;
|
|
66
|
+
/** Every `SendArgs` handed to the provider, in call order. */
|
|
67
|
+
readonly sent: SendArgs[];
|
|
68
|
+
/** Same calls, with outcome attached. */
|
|
69
|
+
readonly records: SendRecord[];
|
|
70
|
+
constructor(inner: MailProvider);
|
|
71
|
+
get name(): string;
|
|
72
|
+
get sendRatePerSecond(): number | undefined;
|
|
73
|
+
send(args: SendArgs): Promise<SendResult>;
|
|
74
|
+
verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
|
|
75
|
+
parseWebhookEvents(payload: unknown, headers: Record<string, string>): NormalizedEvent[];
|
|
76
|
+
/** Last recorded send, or undefined. Sugar for the common single-send assertion. */
|
|
77
|
+
get last(): SendArgs | undefined;
|
|
78
|
+
/** All sends addressed to `email` (case-insensitive). */
|
|
79
|
+
toRecipient(email: string): SendArgs[];
|
|
80
|
+
reset(): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Document builders for tests. Hand-rolling a full `TemplateDoc` / `FlowDoc`
|
|
85
|
+
* literal is ~40 lines of noise per fixture; these fill every required field
|
|
86
|
+
* with a sane default so a test only states what it is actually asserting on.
|
|
87
|
+
*
|
|
88
|
+
* await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
|
|
89
|
+
* await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [...] })
|
|
90
|
+
*
|
|
91
|
+
* `createdAt` defaults to a minute in the past: the event-trigger scan uses
|
|
92
|
+
* `lastTriggerScanAt ?? createdAt` as its watermark and only picks up events
|
|
93
|
+
* with `occurredAt > watermark`, so a flow created at the same instant as the
|
|
94
|
+
* event it should catch would never fire.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
interface TemplateSpec {
|
|
98
|
+
slug: string;
|
|
99
|
+
name?: string;
|
|
100
|
+
description?: string;
|
|
101
|
+
kind?: TemplateKind;
|
|
102
|
+
fromName?: string;
|
|
103
|
+
fromEmail?: string;
|
|
104
|
+
replyTo?: string | null;
|
|
105
|
+
providerOverride?: string | null;
|
|
106
|
+
subject?: string;
|
|
107
|
+
preheader?: string;
|
|
108
|
+
/** MJML source — compiled to html + derived plain text. */
|
|
109
|
+
mjml?: string;
|
|
110
|
+
/** Pre-compiled HTML. Wins over `mjml`. */
|
|
111
|
+
html?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Body copy wrapped in a minimal MJML document. The lazy path — use when the
|
|
114
|
+
* test cares about the rendered *content*, not the markup around it.
|
|
115
|
+
*/
|
|
116
|
+
text?: string;
|
|
117
|
+
/** Explicit plain-text part. Omit to auto-derive from the HTML. */
|
|
118
|
+
plainText?: string;
|
|
119
|
+
variablesSchema?: TemplateDoc['variablesSchema'];
|
|
120
|
+
tags?: string[];
|
|
121
|
+
trackOpens?: boolean;
|
|
122
|
+
trackClicks?: boolean;
|
|
123
|
+
published?: boolean;
|
|
124
|
+
createdAt?: Date;
|
|
125
|
+
}
|
|
126
|
+
declare function buildTemplate(spec: TemplateSpec): Promise<TemplateDoc>;
|
|
127
|
+
/** Minimal valid MJML document around a body string. */
|
|
128
|
+
declare function wrapMjml(body: string): string;
|
|
129
|
+
interface FlowSpec {
|
|
130
|
+
slug: string;
|
|
131
|
+
name?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
steps: FlowStep[];
|
|
134
|
+
/** Shorthand for an `event` trigger. Ignored when `trigger` is given. */
|
|
135
|
+
eventName?: string;
|
|
136
|
+
/** Re-entry policy for the shorthand trigger. Defaults to true (enter once). */
|
|
137
|
+
once?: boolean;
|
|
138
|
+
trigger?: FlowTrigger;
|
|
139
|
+
enabled?: boolean;
|
|
140
|
+
goal?: FlowGoal;
|
|
141
|
+
audience?: string;
|
|
142
|
+
version?: number;
|
|
143
|
+
createdAt?: Date;
|
|
144
|
+
}
|
|
145
|
+
declare function buildFlow(spec: FlowSpec): FlowDoc;
|
|
146
|
+
declare const step: {
|
|
147
|
+
readonly send: (templateSlug: string, opts?: Omit<Extract<FlowStep, {
|
|
148
|
+
type: "send";
|
|
149
|
+
}>, "type" | "templateSlug">) => FlowStep;
|
|
150
|
+
readonly sendAt: (templateSlug: string, delivery: DeliveryWindow) => FlowStep;
|
|
151
|
+
readonly wait: (value: number, unit?: "minutes" | "hours" | "days" | "weeks") => FlowStep;
|
|
152
|
+
readonly tag: (addTags?: string[], removeTags?: string[]) => FlowStep;
|
|
153
|
+
readonly exit: (reason?: string) => FlowStep;
|
|
154
|
+
readonly fireEvent: (eventName: string, properties?: Record<string, unknown>) => FlowStep;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Deterministic runner driver.
|
|
159
|
+
*
|
|
160
|
+
* The test harness uses the `noop` queue driver: `queue.add()` throws nothing
|
|
161
|
+
* away quietly but nothing ever fires it either, so the runner only moves when
|
|
162
|
+
* a test tells it to. `drain` is that push — it alternates the trigger scan,
|
|
163
|
+
* the due-run sweep and send dispatch until the system is quiescent, which is
|
|
164
|
+
* what a real deployment converges to between ticks.
|
|
165
|
+
*
|
|
166
|
+
* await H.mailer.fire('Created', 'u1')
|
|
167
|
+
* await drain(H.mailer.getRunnerContext())
|
|
168
|
+
* expect(H.provider.sent).toHaveLength(1)
|
|
169
|
+
*
|
|
170
|
+
* "Quiescent" means: no active flow_run whose `nextActionAt` has passed, and
|
|
171
|
+
* no queued send left to dispatch. Runs parked in a `wait` step or deferred by
|
|
172
|
+
* a delivery window are *expected* to remain — move the clock forward and
|
|
173
|
+
* drain again.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
interface DrainOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Safety valve. Each round advances every due run by exactly one step, so a
|
|
179
|
+
* flow needs one round per step. Hitting the cap means the system did not
|
|
180
|
+
* converge — `settled` comes back false rather than throwing, so a test can
|
|
181
|
+
* assert on non-convergence deliberately.
|
|
182
|
+
*/
|
|
183
|
+
maxRounds?: number;
|
|
184
|
+
/** Skip send dispatch to inspect `queued` send rows before they go out. */
|
|
185
|
+
dispatch?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Let provider/render errors propagate. Off by default: `dispatchSend`
|
|
188
|
+
* rethrows so the real queue can retry, and a drain that unwound on the
|
|
189
|
+
* first failure could not assert on the failed send row it just wrote.
|
|
190
|
+
*/
|
|
191
|
+
throwOnSendError?: boolean;
|
|
192
|
+
}
|
|
193
|
+
interface DrainResult {
|
|
194
|
+
rounds: number;
|
|
195
|
+
/** Sends passed to `dispatchSend`. Not all of them reached the provider. */
|
|
196
|
+
dispatched: number;
|
|
197
|
+
/** Errors thrown by `dispatchSend`, swallowed unless `throwOnSendError`. */
|
|
198
|
+
errors: Error[];
|
|
199
|
+
settled: boolean;
|
|
200
|
+
}
|
|
201
|
+
declare function drain(ctx: RunnerContext, opts?: DrainOptions): Promise<DrainResult>;
|
|
202
|
+
/**
|
|
203
|
+
* Dispatch every currently-queued send, once. Use when a test drove the flow
|
|
204
|
+
* with `drain({ dispatch: false })` and wants to inspect the queued rows first.
|
|
205
|
+
*/
|
|
206
|
+
declare function dispatchQueued(ctx: RunnerContext): Promise<number>;
|
|
207
|
+
|
|
32
208
|
/**
|
|
33
209
|
* Test helpers — `import { ... } from 'mailery/testing'`.
|
|
34
210
|
*
|
|
35
|
-
* const
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* //
|
|
39
|
-
*
|
|
40
|
-
* await
|
|
41
|
-
*
|
|
42
|
-
*
|
|
211
|
+
* const H = await createTestMailer({
|
|
212
|
+
* seedContacts: [{ externalId: 'u1', email: 'alice@example.com', tags: [], fields: {} }],
|
|
213
|
+
* })
|
|
214
|
+
* await H.seedContact({ externalId: 'u1', ... }) // adapter + subscription
|
|
215
|
+
* await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
|
|
216
|
+
* await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [step.send('welcome')] })
|
|
217
|
+
*
|
|
218
|
+
* H.mailer.registerEvent({ name: 'Created', dedupePolicy: 'once-per-contact' })
|
|
219
|
+
* await H.mailer.fire('Created', 'u1')
|
|
220
|
+
* await H.drain() // run to quiescence
|
|
43
221
|
*
|
|
44
|
-
*
|
|
222
|
+
* expect(H.provider.sent).toHaveLength(1)
|
|
223
|
+
* await H.stop()
|
|
224
|
+
*
|
|
225
|
+
* Backed by mongodb-memory-server + the `noop` queue driver, so nothing runs in
|
|
226
|
+
* the background and every transition is something the test asked for. The
|
|
227
|
+
* provider is always wrapped in a `RecordingProvider`, which is what allows the
|
|
228
|
+
* same suite to run against `NullProvider` offline or against real SendGrid
|
|
229
|
+
* (`provider: 'sendgrid'`) without changing a single assertion.
|
|
45
230
|
*/
|
|
46
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Which provider the harness sends through.
|
|
234
|
+
* - `'null'` — in-memory, offline, the default.
|
|
235
|
+
* - `'sendgrid'` — the real API. Needs `SENDGRID_API_KEY`. Sandbox mode
|
|
236
|
+
* unless `MAILERY_LIVE_E2E=deliver`, so the default costs
|
|
237
|
+
* nothing and delivers nothing while still proving that
|
|
238
|
+
* SendGrid accepts the payload.
|
|
239
|
+
* - a `MailProvider` instance — anything else you want to record around.
|
|
240
|
+
*/
|
|
241
|
+
type ProviderSpec = 'null' | 'sendgrid' | MailProvider;
|
|
47
242
|
interface TestMailerOptions {
|
|
48
243
|
adapter?: ContactAdapter;
|
|
49
244
|
seedContacts?: Contact[];
|
|
50
|
-
provider?:
|
|
245
|
+
provider?: ProviderSpec;
|
|
246
|
+
/**
|
|
247
|
+
* Queue driver. Defaults to `noop`, which is what makes the fast matrix
|
|
248
|
+
* deterministic — nothing runs until a test calls `drain()`. The live tier
|
|
249
|
+
* passes a real `bull` config to exercise job delays, retries and the send
|
|
250
|
+
* rate limiter, and must then also set `startWorkers`.
|
|
251
|
+
*/
|
|
252
|
+
queue?: QueueDriverConfig;
|
|
253
|
+
/** Call `mailer.startWorkers()` after init. Requires a non-noop `queue`. */
|
|
254
|
+
startWorkers?: boolean;
|
|
51
255
|
/** Override Mailer config (excluding required fields the harness fills in). */
|
|
52
256
|
config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
|
|
53
257
|
}
|
|
258
|
+
interface SeedContactOptions {
|
|
259
|
+
/** Also create a `subscribed` subscription row. Default true. */
|
|
260
|
+
subscribe?: boolean;
|
|
261
|
+
source?: string;
|
|
262
|
+
}
|
|
54
263
|
interface TestMailerHarness {
|
|
55
264
|
mailer: Mailer;
|
|
56
265
|
db: Db;
|
|
57
|
-
provider
|
|
266
|
+
/** Recording wrapper around the configured provider. */
|
|
267
|
+
provider: RecordingProvider;
|
|
58
268
|
adapter: ContactAdapter;
|
|
59
269
|
memoryAdapter: MemoryContactAdapter | null;
|
|
270
|
+
/** The runner context, for passing to `runTick` / `processOneRunStep` / `drain`. */
|
|
271
|
+
ctx: RunnerContext;
|
|
272
|
+
/** Insert a contact into the adapter and (by default) subscribe it. */
|
|
273
|
+
seedContact: (contact: Contact, opts?: SeedContactOptions) => Promise<Contact>;
|
|
274
|
+
/** Build + insert a published template. Returns the doc, `_id` included. */
|
|
275
|
+
seedTemplate: (spec: TemplateSpec) => Promise<TemplateDoc>;
|
|
276
|
+
/** Build + insert a published flow. Returns the doc, `_id` included. */
|
|
277
|
+
seedFlow: (spec: FlowSpec) => Promise<FlowDoc>;
|
|
278
|
+
/** Run the runner to quiescence. See `drive.ts`. */
|
|
279
|
+
drain: (opts?: DrainOptions) => Promise<DrainResult>;
|
|
60
280
|
stop: () => Promise<void>;
|
|
61
281
|
}
|
|
62
282
|
declare function createTestMailer(opts?: TestMailerOptions): Promise<TestMailerHarness>;
|
|
63
283
|
|
|
64
|
-
export { MemoryContactAdapter,
|
|
284
|
+
export { type DrainOptions, type DrainResult, type FlowSpec, MemoryContactAdapter, type ProviderSpec, RecordingProvider, type SeedContactOptions, type SendRecord, type TemplateSpec, type TestMailerHarness, type TestMailerOptions, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };
|