@startsimpli/integrations 0.2.35 → 0.2.37

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.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * The Customer emails composer (lane startsim-r7hws), end to end against a
3
+ * mocked Django (msw) through the REAL createCommsApi client, so each test is a
4
+ * full round-trip: click, request on the wire, response rendered.
5
+ *
6
+ * Screens: persona A, A1-A8 (startsim-lzfsd). Contract: ADR startsim-bzcrj §2.
7
+ *
8
+ * THE ACCESSIBLE CONTRACT GREEN MUST MEET (also in the DESIGN bead):
9
+ * - one heading "Customer emails"; two role="switch" controls directly under it
10
+ * - every other section is a fold: a button (aria-expanded) whose content is a
11
+ * role="region" with the same name, collapsed by default
12
+ * - native <select>s for cadence / audience / send-log filters
13
+ * - previews in a sandboxed <iframe title="Email preview" srcdoc=...>
14
+ *
15
+ * Every "it never does X" test first proves the page rendered, so an empty
16
+ * component cannot pass it by doing nothing.
17
+ *
18
+ * GREEN (startsim-lno6v) aligned the mock with the SHIPPED backend:
19
+ * - PATCH replaces `config` / `voice` whole (it does not merge them).
20
+ * - steps/{i}/generate/ is 202 + a status, polled with GET until done/failed.
21
+ * - send-now is 202 with a placeholder draft (generating:true), polled with
22
+ * GET drafts/{id}/; or 200 nothing_customer_facing / draft_pending.
23
+ * - enabled:false answers with `cancelled`; drafts/{id}/preview/ exists (G2).
24
+ */
25
+ import * as React from 'react';
26
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
27
+ import { render, screen, waitFor, within } from '@testing-library/react';
28
+ import userEvent from '@testing-library/user-event';
29
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
30
+ import { http, HttpResponse } from 'msw';
31
+ import { setupServer } from 'msw/node';
32
+
33
+ import { CustomerEmailsPage } from '../components/pages/CustomerEmailsPage';
34
+ import { createCommsApi, setApiBase } from '../api/index';
35
+ import type { CommsProgram, CommsSettings, Draft, SendRow } from '../types/index';
36
+ import { BASE, P, SLUG, comeback, draft, metrics, productUpdate, sendRows, settings } from './fixtures';
37
+
38
+ // ---- an in-memory comms backend --------------------------------------------
39
+
40
+ interface Hit {
41
+ method: string;
42
+ path: string;
43
+ search: URLSearchParams;
44
+ body: any;
45
+ }
46
+
47
+ let hits: Hit[] = [];
48
+ let db: {
49
+ settings: CommsSettings;
50
+ programs: CommsProgram[];
51
+ drafts: Draft[];
52
+ sends: SendRow[];
53
+ settingsStatus: number;
54
+ programPatchStatus: number;
55
+ testSend: { sent: boolean; reason?: string };
56
+ sendNow: { draft: Draft | null; reason?: string | null };
57
+ sendNowStatus: number;
58
+ /** What a send-now draft becomes once the worker finishes writing it. */
59
+ sendNowResult: Partial<Draft>;
60
+ generation: 'done' | 'failed';
61
+ cancelled: number;
62
+ };
63
+
64
+ const rel = (p: string) => p.replace(new URL(P).pathname, '');
65
+
66
+ const server = setupServer(
67
+ http.all(`${P}/*`, async ({ request }) => {
68
+ const url = new URL(request.url);
69
+ const text = request.method === 'GET' ? '' : await request.text();
70
+ const hit: Hit = { method: request.method, path: rel(url.pathname), search: url.searchParams, body: text ? JSON.parse(text) : undefined };
71
+ hits.push(hit);
72
+ const { method, path, body } = hit;
73
+ let m: RegExpMatchArray | null;
74
+
75
+ if (path === '/settings/') {
76
+ if (db.settingsStatus !== 200) {
77
+ return HttpResponse.json({ detail: 'You do not have permission to perform this action.' }, { status: db.settingsStatus });
78
+ }
79
+ // JSON fields are replaced whole, as DRF does.
80
+ if (method === 'PATCH') db.settings = { ...db.settings, ...body };
81
+ return HttpResponse.json(db.settings);
82
+ }
83
+ if (path === '/settings/domain/' && method === 'POST') {
84
+ db.settings = {
85
+ ...db.settings,
86
+ sending_domain: body.domain,
87
+ domain_status: 'pending',
88
+ domain_records: [
89
+ { type: 'TXT', host: body.domain, value: 'v=spf1 include:spf.mailjet.com ~all', ok: false },
90
+ { type: 'TXT', host: `mailjet._domainkey.${body.domain}`, value: 'k=rsa; p=MIGf', ok: false },
91
+ { type: 'TXT', host: `_dmarc.${body.domain}`, value: 'v=DMARC1; p=none', ok: false },
92
+ ],
93
+ };
94
+ return HttpResponse.json(db.settings);
95
+ }
96
+ if (path === '/settings/domain/check/' && method === 'POST') {
97
+ db.settings = { ...db.settings, domain_status: 'verified', domain_records: db.settings.domain_records.map((r) => ({ ...r, ok: true })) };
98
+ return HttpResponse.json(db.settings);
99
+ }
100
+ if (path === '/programs/' && method === 'GET') {
101
+ if (db.settingsStatus !== 200) return HttpResponse.json({ detail: 'forbidden' }, { status: db.settingsStatus });
102
+ return HttpResponse.json(db.programs);
103
+ }
104
+ if ((m = path.match(/^\/programs\/([\w-]+)\/$/)) && method === 'PATCH') {
105
+ if (db.programPatchStatus !== 200) return HttpResponse.json({ detail: 'Engine unavailable' }, { status: db.programPatchStatus });
106
+ const i = db.programs.findIndex((p) => p.key === m![1]);
107
+ const cur = db.programs[i];
108
+ // config is replaced whole, as the shipped view does.
109
+ db.programs[i] = { ...cur, ...body, config: body.config ?? cur.config, version: cur.version + 1 } as CommsProgram;
110
+ const turnedOff = cur.enabled && body.enabled === false;
111
+ return HttpResponse.json(turnedOff ? { ...db.programs[i], cancelled: db.cancelled } : db.programs[i]);
112
+ }
113
+ if ((m = path.match(/^\/programs\/([\w-]+)\/steps\/(\d+)\/preview\/$/))) {
114
+ return HttpResponse.json({ subject: `Preview subject ${m[2]}`, preheader: 'Preview preheader', html: `<p>Hi Quinn, step ${m[2]}</p>`, text: 'Hi Quinn' });
115
+ }
116
+ if ((m = path.match(/^\/programs\/([\w-]+)\/steps\/(\d+)\/generate\/$/))) {
117
+ const p = db.programs.find((x) => x.key === m![1]) as ReturnType<typeof comeback>;
118
+ const index = Number(m[2]);
119
+ const step = p.config.steps[index];
120
+ if (method === 'POST') {
121
+ return HttpResponse.json({ step_index: index, status: 'pending', error: '', requested_at: 'now', finished_at: null, step }, { status: 202 });
122
+ }
123
+ // The poll: the worker has finished.
124
+ if (db.generation === 'failed') {
125
+ return HttpResponse.json({ step_index: index, status: 'failed', error: 'The model did not answer in time', requested_at: 'now', finished_at: 'now', step });
126
+ }
127
+ step.content = { subject: 'Last call: your patterns', preheader: '', html: '<p>Last call</p>', text: 'Last call' };
128
+ step.content_status = 'draft';
129
+ return HttpResponse.json({ step_index: index, status: 'done', error: '', requested_at: 'now', finished_at: 'now', step });
130
+ }
131
+ if ((m = path.match(/^\/programs\/([\w-]+)\/steps\/(\d+)\/approve\/$/)) && method === 'POST') {
132
+ const p = db.programs.find((x) => x.key === m![1]) as ReturnType<typeof comeback>;
133
+ p.config.steps[Number(m[2])].content_status = 'approved';
134
+ return HttpResponse.json(p);
135
+ }
136
+ if (path === '/test-send/' && method === 'POST') return HttpResponse.json(db.testSend);
137
+ if (path === '/programs/product_update/send-now/' && method === 'POST') {
138
+ if (db.sendNow.draft && db.sendNowStatus === 202) db.drafts.push({ ...db.sendNow.draft });
139
+ return HttpResponse.json(db.sendNow, { status: db.sendNowStatus });
140
+ }
141
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/preview\/$/)) && method === 'GET') {
142
+ const d = db.drafts.find((x) => x.id === m![1])!;
143
+ return HttpResponse.json({ subject: `Rendered: ${d.subject}`, preheader: d.preheader, html: `<p>Hi Quinn, ${d.subject}</p>`, text: d.text });
144
+ }
145
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/$/)) && method === 'GET') {
146
+ const d = db.drafts.find((x) => x.id === m![1])!;
147
+ // The poll: the worker has finished writing it.
148
+ if (d.generating) Object.assign(d, { generating: false }, db.sendNowResult);
149
+ return HttpResponse.json(d);
150
+ }
151
+ if (path === '/drafts/' && method === 'GET') {
152
+ const status = hit.search.get('status');
153
+ const rows = db.drafts.filter((d) => !status || d.approval === status);
154
+ return HttpResponse.json({ count: rows.length, next: null, previous: null, results: rows });
155
+ }
156
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/(approve|discard)\/$/)) && method === 'POST') {
157
+ const d = db.drafts.find((x) => x.id === m![1])!;
158
+ d.approval = m[2] === 'approve' ? 'approved' : 'discarded';
159
+ return HttpResponse.json(d);
160
+ }
161
+ if ((m = path.match(/^\/drafts\/([\w-]+)\/$/)) && method === 'PATCH') {
162
+ const d = db.drafts.find((x) => x.id === m![1])!;
163
+ Object.assign(d, body);
164
+ return HttpResponse.json(d);
165
+ }
166
+ if (path === '/sends/' && method === 'GET') {
167
+ const page = Number(hit.search.get('page') ?? '1');
168
+ const size = Number(hit.search.get('page_size') ?? '25');
169
+ const program = hit.search.get('program');
170
+ const rows = db.sends.filter((r) => !program || r.program_key === program);
171
+ return HttpResponse.json({
172
+ count: rows.length,
173
+ next: page * size < rows.length ? `${P}/sends/?page=${page + 1}` : null,
174
+ previous: page > 1 ? `${P}/sends/?page=${page - 1}` : null,
175
+ results: rows.slice((page - 1) * size, page * size),
176
+ });
177
+ }
178
+ if (path === '/metrics/' && method === 'GET') return HttpResponse.json(metrics());
179
+ return HttpResponse.json({ detail: `unhandled ${method} ${path}` }, { status: 599 });
180
+ }),
181
+ );
182
+
183
+ beforeAll(() => {
184
+ server.listen({ onUnhandledRequest: 'error' });
185
+ setApiBase(`${BASE}/api/v1`);
186
+ });
187
+ afterEach(() => server.resetHandlers());
188
+ afterAll(() => server.close());
189
+ beforeEach(() => {
190
+ hits = [];
191
+ db = {
192
+ settings: settings(),
193
+ programs: [comeback(), productUpdate()],
194
+ drafts: [draft()],
195
+ // 120 rows on the server: more than one page, so slicing locally cannot pass.
196
+ sends: sendRows(120),
197
+ settingsStatus: 200,
198
+ programPatchStatus: 200,
199
+ testSend: { sent: true },
200
+ sendNow: { draft: draft({ id: 'd-2', subject: '', html: '', text: '', preheader: '', generating: true }), reason: null },
201
+ sendNowStatus: 202,
202
+ sendNowResult: { subject: 'What is new this week', html: '<p>New</p>', text: 'New' },
203
+ generation: 'done',
204
+ cancelled: 0,
205
+ };
206
+ });
207
+
208
+ function renderPage() {
209
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
210
+ return render(
211
+ <QueryClientProvider client={qc}>
212
+ <CustomerEmailsPage api={createCommsApi(SLUG)} foundrySlug={SLUG} pollIntervalMs={10} />
213
+ </QueryClientProvider>,
214
+ );
215
+ }
216
+
217
+ const writes = () => hits.filter((h) => h.method !== 'GET');
218
+ const comebackSwitch = () => screen.findByRole('switch', { name: /^comeback sequence$/i });
219
+ const updatesSwitch = () => screen.findByRole('switch', { name: /^product update emails$/i });
220
+
221
+ async function openFold(name: RegExp) {
222
+ const trigger = await screen.findByRole('button', { name });
223
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
224
+ await userEvent.click(trigger);
225
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
226
+ return screen.findByRole('region', { name });
227
+ }
228
+
229
+ // ---- A1: one page, one title, two switches ----------------------------------
230
+
231
+ describe('overview (A1)', () => {
232
+ it('renders ONE title and the two switches with their server state', async () => {
233
+ renderPage();
234
+ expect(await comebackSwitch()).toHaveAttribute('aria-checked', 'true');
235
+ expect(await updatesSwitch()).toHaveAttribute('aria-checked', 'false');
236
+ expect(screen.getAllByRole('heading', { name: /customer emails/i })).toHaveLength(1);
237
+ });
238
+
239
+ it('every other section is folded, collapsed by default', async () => {
240
+ renderPage();
241
+ await comebackSwitch();
242
+ for (const name of [
243
+ /^comeback sequence settings$/i, /^product updates$/i, /^approval$/i,
244
+ /^voice$/i, /^sending domain$/i, /^results$/i, /^send log$/i,
245
+ ]) {
246
+ expect(screen.getByRole('button', { name })).toHaveAttribute('aria-expanded', 'false');
247
+ }
248
+ });
249
+
250
+ it('makes no write and fetches no preview until the admin clicks something', async () => {
251
+ renderPage();
252
+ await comebackSwitch();
253
+ await new Promise((r) => setTimeout(r, 50));
254
+ expect(writes()).toEqual([]);
255
+ expect(hits.some((h) => h.path.includes('/preview/'))).toBe(false);
256
+ });
257
+ });
258
+
259
+ // ---- A8: switching off -------------------------------------------------------
260
+
261
+ describe('switches (A1, A8)', () => {
262
+ it('turning Comeback off PATCHes enabled:false and says nothing further goes out', async () => {
263
+ renderPage();
264
+ await userEvent.click(await comebackSwitch());
265
+ await waitFor(() => expect(writes()).toHaveLength(1));
266
+ expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/programs/comeback/', body: { enabled: false } });
267
+ expect(await comebackSwitch()).toHaveAttribute('aria-checked', 'false');
268
+ expect(await screen.findByText(/nothing further will be sent/i)).toBeInTheDocument();
269
+ });
270
+
271
+ it('says how many pending emails the server cancelled', async () => {
272
+ db.cancelled = 12;
273
+ renderPage();
274
+ await userEvent.click(await comebackSwitch());
275
+ expect(await screen.findByText(/12 pending emails were cancelled/i)).toBeInTheDocument();
276
+ });
277
+
278
+ it('turning Product update emails on PATCHes enabled:true', async () => {
279
+ renderPage();
280
+ await userEvent.click(await updatesSwitch());
281
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/programs/product_update/', body: { enabled: true } }));
282
+ await waitFor(async () => expect(await updatesSwitch()).toHaveAttribute('aria-checked', 'true'));
283
+ });
284
+
285
+ it('a refused toggle shows the server reason and the switch stays as it was', async () => {
286
+ db.programPatchStatus = 503;
287
+ renderPage();
288
+ await userEvent.click(await comebackSwitch());
289
+ expect((await screen.findByRole('alert')).textContent).toMatch(/engine unavailable/i);
290
+ expect(await comebackSwitch()).toHaveAttribute('aria-checked', 'true');
291
+ });
292
+ });
293
+
294
+ // ---- A2: comeback sequence ---------------------------------------------------
295
+
296
+ describe('comeback sequence settings (A2)', () => {
297
+ it('shows the threshold and saves a new one, keeping the steps', async () => {
298
+ renderPage();
299
+ const fold = await openFold(/^comeback sequence settings$/i);
300
+ const days = within(fold).getByRole('spinbutton', { name: /days without logging in/i });
301
+ expect(days).toHaveValue(14);
302
+ await userEvent.clear(days);
303
+ await userEvent.type(days, '21');
304
+ await userEvent.click(within(fold).getByRole('button', { name: /^save$/i }));
305
+ await waitFor(() => expect(writes()).toHaveLength(1));
306
+ const w = writes()[0];
307
+ expect(w).toMatchObject({ method: 'PATCH', path: '/programs/comeback/' });
308
+ expect(w.body.config.trigger).toEqual({ type: 'inactive', days: 21 });
309
+ expect(w.body.config.steps).toHaveLength(3);
310
+ });
311
+
312
+ it('lists each step with its day and content status', async () => {
313
+ renderPage();
314
+ const fold = await openFold(/^comeback sequence settings$/i);
315
+ const steps = within(fold).getAllByRole('listitem');
316
+ expect(steps).toHaveLength(3);
317
+ expect(steps[0].textContent).toMatch(/day 0/i);
318
+ expect(steps[0].textContent).toMatch(/approved/i);
319
+ expect(steps[1].textContent).toMatch(/day 5/i);
320
+ expect(steps[1].textContent).toMatch(/draft/i);
321
+ expect(steps[2].textContent).toMatch(/day 10/i);
322
+ expect(steps[2].textContent).toMatch(/not written/i);
323
+ });
324
+
325
+ it("edits one step's day and subject, PATCHing the whole steps array with the others untouched", async () => {
326
+ renderPage();
327
+ const fold = await openFold(/^comeback sequence settings$/i);
328
+ const step2 = () => within(fold).getAllByRole('listitem')[1];
329
+ await userEvent.click(within(step2()).getByRole('button', { name: /^edit$/i }));
330
+ const day = within(step2()).getByRole('spinbutton', { name: /^day$/i });
331
+ expect(day).toHaveValue(5);
332
+ await userEvent.clear(day);
333
+ await userEvent.type(day, '7');
334
+ const subject = within(step2()).getByRole('textbox', { name: /^subject$/i });
335
+ expect(subject).toHaveValue('Three patterns to try');
336
+ await userEvent.clear(subject);
337
+ await userEvent.type(subject, 'Three new patterns');
338
+ await userEvent.click(within(step2()).getByRole('button', { name: /save step/i }));
339
+ await waitFor(() => expect(writes()).toHaveLength(1));
340
+ const w = writes()[0];
341
+ expect(w).toMatchObject({ method: 'PATCH', path: '/programs/comeback/' });
342
+ const [s0, s1, s2] = w.body.config.steps;
343
+ expect(w.body.config.steps).toHaveLength(3);
344
+ expect(s1.offset_days).toBe(7);
345
+ expect(s1.content.subject).toBe('Three new patterns');
346
+ expect(s0).toEqual(comeback().config.steps[0]);
347
+ expect(s2).toEqual(comeback().config.steps[2]);
348
+ await waitFor(() => expect(step2().textContent).toMatch(/day 7/i));
349
+ });
350
+
351
+ it('previews a step as the SERVER rendered it, in a sandboxed iframe', async () => {
352
+ renderPage();
353
+ const fold = await openFold(/^comeback sequence settings$/i);
354
+ const step2 = within(fold).getAllByRole('listitem')[1];
355
+ await userEvent.click(within(step2).getByRole('button', { name: /^preview$/i }));
356
+ await waitFor(() => expect(hits.some((h) => h.method === 'GET' && h.path === '/programs/comeback/steps/1/preview/')).toBe(true));
357
+ const frame = await within(fold).findByTitle(/email preview/i);
358
+ expect(frame.tagName).toBe('IFRAME');
359
+ expect(frame.getAttribute('srcdoc')).toContain('<p>Hi Quinn, step 1</p>');
360
+ expect(frame.hasAttribute('sandbox')).toBe(true);
361
+ expect(within(fold).getByText('Preview subject 1')).toBeInTheDocument();
362
+ });
363
+
364
+ it('sends a test of a step to the admin only, on click', async () => {
365
+ renderPage();
366
+ const fold = await openFold(/^comeback sequence settings$/i);
367
+ const step1 = within(fold).getAllByRole('listitem')[0];
368
+ await userEvent.click(within(step1).getByRole('button', { name: /send (a )?test to me/i }));
369
+ await waitFor(() => expect(writes()).toEqual([
370
+ expect.objectContaining({ method: 'POST', path: '/test-send/', body: { program_key: 'comeback', step_index: 0 } }),
371
+ ]));
372
+ expect(await within(fold).findByText(/test sent to you/i)).toBeInTheDocument();
373
+ });
374
+
375
+ it('a test the guard refused shows why, not "sent"', async () => {
376
+ db.testSend = { sent: false, reason: 'not_allowlisted' };
377
+ renderPage();
378
+ const fold = await openFold(/^comeback sequence settings$/i);
379
+ await userEvent.click(within(within(fold).getAllByRole('listitem')[0]).getByRole('button', { name: /send (a )?test to me/i }));
380
+ expect((await within(fold).findByRole('alert')).textContent).toMatch(/not_allowlisted|not allowlisted/i);
381
+ expect(within(fold).queryByText(/test sent to you/i)).toBeNull();
382
+ });
383
+
384
+ it('writes an empty step with AI, then approves it', async () => {
385
+ renderPage();
386
+ const fold = await openFold(/^comeback sequence settings$/i);
387
+ const last = () => within(fold).getAllByRole('listitem')[2];
388
+ await userEvent.click(within(last()).getByRole('button', { name: /write with ai/i }));
389
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/programs/comeback/steps/2/generate/' }));
390
+ await waitFor(() => expect(last().textContent).toMatch(/draft/i));
391
+ await userEvent.click(within(last()).getByRole('button', { name: /^approve$/i }));
392
+ await waitFor(() => expect(writes()[1]).toMatchObject({ method: 'POST', path: '/programs/comeback/steps/2/approve/' }));
393
+ await waitFor(() => expect(last().textContent).toMatch(/approved/i));
394
+ });
395
+ });
396
+
397
+ describe('comeback sequence: writing fails', () => {
398
+ it('a failed generation shows the reason and leaves the step unwritten', async () => {
399
+ db.generation = 'failed';
400
+ renderPage();
401
+ const fold = await openFold(/^comeback sequence settings$/i);
402
+ const last = () => within(fold).getAllByRole('listitem')[2];
403
+ await userEvent.click(within(last()).getByRole('button', { name: /write with ai/i }));
404
+ expect((await within(fold).findByRole('alert')).textContent).toMatch(/did not answer in time/i);
405
+ expect(last().textContent).toMatch(/not written/i);
406
+ });
407
+ });
408
+
409
+ // ---- A4: approval mode --------------------------------------------------------
410
+
411
+ describe('approval (A4)', () => {
412
+ it('shows the current mode and switches to automatic sending', async () => {
413
+ renderPage();
414
+ const fold = await openFold(/^approval$/i);
415
+ expect(within(fold).getByRole('radio', { name: /i approve each email/i })).toBeChecked();
416
+ await userEvent.click(within(fold).getByRole('radio', { name: /send automatically/i }));
417
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/settings/', body: { approval_mode: 'auto' } }));
418
+ });
419
+ });
420
+
421
+ // ---- A5: voice -------------------------------------------------------------------
422
+
423
+ describe('voice (A5)', () => {
424
+ it('edits the voice and sends avoid_words as a list', async () => {
425
+ renderPage();
426
+ const fold = await openFold(/^voice$/i);
427
+ expect(within(fold).getByRole('textbox', { name: /^product$/i })).toHaveValue('AI crochet patterns from a photo');
428
+ const tone = within(fold).getByRole('textbox', { name: /^tone$/i });
429
+ await userEvent.clear(tone);
430
+ await userEvent.type(tone, 'cheerful');
431
+ const avoid = within(fold).getByRole('textbox', { name: /words to avoid/i });
432
+ await userEvent.clear(avoid);
433
+ await userEvent.type(avoid, 'synergy, leverage');
434
+ await userEvent.click(within(fold).getByRole('button', { name: /save voice/i }));
435
+ await waitFor(() => expect(writes()).toHaveLength(1));
436
+ expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/settings/' });
437
+ expect(writes()[0].body.voice).toMatchObject({ tone: 'cheerful', avoid_words: ['synergy', 'leverage'] });
438
+ for (const label of [/^audience$/i, /call-to-action link/i]) {
439
+ expect(within(fold).getByRole('textbox', { name: label })).toBeInTheDocument();
440
+ }
441
+ });
442
+ });
443
+
444
+ // ---- A6: sending domain ----------------------------------------------------------
445
+
446
+ describe('sending domain (A6)', () => {
447
+ it('until a domain is verified, says mail goes out via the StartSimpli marketing stream', async () => {
448
+ renderPage();
449
+ const fold = await openFold(/^sending domain$/i);
450
+ expect(fold.textContent).toMatch(/startsimpli marketing stream/i);
451
+ });
452
+
453
+ it('connects a domain, shows SPF/DKIM/DMARC records, then re-checks to verified', async () => {
454
+ renderPage();
455
+ const fold = await openFold(/^sending domain$/i);
456
+ await userEvent.type(within(fold).getByRole('textbox', { name: /^domain$/i }), 'crochets.site');
457
+ await userEvent.click(within(fold).getByRole('button', { name: /connect domain/i }));
458
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/settings/domain/', body: { domain: 'crochets.site' } }));
459
+ const table = await within(fold).findByRole('table');
460
+ const text = table.textContent ?? '';
461
+ expect(text).toMatch(/SPF/);
462
+ expect(text).toMatch(/DKIM/);
463
+ expect(text).toMatch(/DMARC/);
464
+ expect(text).toMatch(/_dmarc\.crochets\.site/);
465
+ expect(fold.textContent).toMatch(/pending/i);
466
+ await userEvent.click(within(fold).getByRole('button', { name: /check now/i }));
467
+ await waitFor(() => expect(writes()[1]).toMatchObject({ method: 'POST', path: '/settings/domain/check/' }));
468
+ await waitFor(() => expect(fold.textContent).toMatch(/verified/i));
469
+ expect(fold.textContent).not.toMatch(/startsimpli marketing stream/i);
470
+ });
471
+ });
472
+
473
+ // ---- A3 + A4: product updates and drafts -------------------------------------------
474
+
475
+ describe('product updates (A3)', () => {
476
+ it('saves cadence and audience into the program config', async () => {
477
+ renderPage();
478
+ const fold = await openFold(/^product updates$/i);
479
+ const cadence = within(fold).getByRole('combobox', { name: /cadence/i });
480
+ expect(cadence).toHaveValue('on_deploy');
481
+ await userEvent.selectOptions(cadence, 'weekly');
482
+ await waitFor(() => expect(writes().at(-1)).toMatchObject({ method: 'PATCH', path: '/programs/product_update/', body: { config: { cadence: 'weekly' } } }));
483
+ await userEvent.selectOptions(within(fold).getByRole('combobox', { name: /audience/i }), 'inactive');
484
+ await waitFor(() => expect(writes().at(-1)).toMatchObject({ method: 'PATCH', path: '/programs/product_update/', body: { config: { audience: 'inactive' } } }));
485
+ });
486
+
487
+ it('"Send an update now" posts the notes and shows the new draft for review', async () => {
488
+ renderPage();
489
+ const fold = await openFold(/^product updates$/i);
490
+ await userEvent.type(within(fold).getByRole('textbox', { name: /notes/i }), 'Stitch counter is live');
491
+ await userEvent.click(within(fold).getByRole('button', { name: /send an update now/i }));
492
+ await waitFor(() => expect(writes()[0]).toMatchObject({
493
+ method: 'POST', path: '/programs/product_update/send-now/', body: { notes: 'Stitch counter is live' },
494
+ }));
495
+ expect(await within(fold).findByText('What is new this week')).toBeInTheDocument();
496
+ });
497
+
498
+ it('while the update is being written it says so, then shows the written draft', async () => {
499
+ renderPage();
500
+ const fold = await openFold(/^product updates$/i);
501
+ await userEvent.click(within(fold).getByRole('button', { name: /send an update now/i }));
502
+ expect(await within(fold).findByText('What is new this week')).toBeInTheDocument();
503
+ expect(hits.some((h) => h.method === 'GET' && h.path === '/drafts/d-2/')).toBe(true);
504
+ });
505
+
506
+ it('when the worker finds nothing customer-facing, says so', async () => {
507
+ db.sendNowResult = { approval: 'discarded', error: 'nothing_customer_facing' };
508
+ renderPage();
509
+ const fold = await openFold(/^product updates$/i);
510
+ await userEvent.click(within(fold).getByRole('button', { name: /send an update now/i }));
511
+ expect(await within(fold).findByText(/nothing customer-facing/i)).toBeInTheDocument();
512
+ });
513
+
514
+ it('with an update already waiting, points at it instead of drafting another', async () => {
515
+ db.sendNow = { draft: draft(), reason: 'draft_pending' };
516
+ db.sendNowStatus = 200;
517
+ renderPage();
518
+ const fold = await openFold(/^product updates$/i);
519
+ await userEvent.click(within(fold).getByRole('button', { name: /send an update now/i }));
520
+ expect(await within(fold).findByText(/already waiting for your approval/i)).toBeInTheDocument();
521
+ });
522
+
523
+ it('with nothing customer-facing, says so instead of drafting', async () => {
524
+ db.sendNow = { draft: null, reason: 'nothing_customer_facing' };
525
+ db.sendNowStatus = 200;
526
+ renderPage();
527
+ const fold = await openFold(/^product updates$/i);
528
+ await userEvent.click(within(fold).getByRole('button', { name: /send an update now/i }));
529
+ expect(await within(fold).findByText(/nothing customer-facing/i)).toBeInTheDocument();
530
+ });
531
+ });
532
+
533
+ describe('draft review (A4)', () => {
534
+ it('lists only PENDING drafts, asked for with server pagination params', async () => {
535
+ db.drafts.push(draft({ id: 'd-9', subject: 'Old approved one', approval: 'approved' }));
536
+ renderPage();
537
+ const fold = await openFold(/^product updates$/i);
538
+ expect(await within(fold).findByText('New: stitch counter')).toBeInTheDocument();
539
+ expect(within(fold).queryByText('Old approved one')).toBeNull();
540
+ const req = hits.find((h) => h.method === 'GET' && h.path === '/drafts/')!;
541
+ expect(req.search.get('status')).toBe('pending');
542
+ expect(req.search.get('page')).toBe('1');
543
+ expect(req.search.get('page_size')).not.toBeNull();
544
+ });
545
+
546
+ it('previews a draft as the SERVER rendered it (G2), in a sandboxed iframe', async () => {
547
+ renderPage();
548
+ const fold = await openFold(/^product updates$/i);
549
+ await userEvent.click(await within(fold).findByRole('button', { name: /^preview$/i }));
550
+ await waitFor(() => expect(hits.some((h) => h.method === 'GET' && h.path === '/drafts/d-1/preview/')).toBe(true));
551
+ const frame = await within(fold).findByTitle(/email preview/i);
552
+ expect(frame.getAttribute('srcdoc')).toContain('<p>Hi Quinn, New: stitch counter</p>');
553
+ expect(frame.hasAttribute('sandbox')).toBe(true);
554
+ expect(writes()).toEqual([]);
555
+ });
556
+
557
+ it('a draft whose writing failed offers only Discard', async () => {
558
+ db.drafts = [draft({ subject: '', error: 'generation_failed' })];
559
+ renderPage();
560
+ const fold = await openFold(/^product updates$/i);
561
+ expect((await within(fold).findByRole('alert')).textContent).toMatch(/generation failed/i);
562
+ expect(within(fold).getByRole('button', { name: /^discard$/i })).toBeInTheDocument();
563
+ expect(within(fold).queryByRole('button', { name: /approve and send/i })).toBeNull();
564
+ expect(within(fold).queryByRole('button', { name: /^edit$/i })).toBeNull();
565
+ });
566
+
567
+ it('edits a draft subject and saves it', async () => {
568
+ renderPage();
569
+ const fold = await openFold(/^product updates$/i);
570
+ await userEvent.click(await within(fold).findByRole('button', { name: /^edit$/i }));
571
+ const subject = within(fold).getByRole('textbox', { name: /^subject$/i });
572
+ await userEvent.clear(subject);
573
+ await userEvent.type(subject, 'Meet the stitch counter');
574
+ await userEvent.click(within(fold).getByRole('button', { name: /save draft/i }));
575
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/drafts/d-1/', body: { subject: 'Meet the stitch counter' } }));
576
+ });
577
+
578
+ it('approves a draft (which queues the send)', async () => {
579
+ renderPage();
580
+ const fold = await openFold(/^product updates$/i);
581
+ await userEvent.click(await within(fold).findByRole('button', { name: /approve and send/i }));
582
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/approve/' }));
583
+ await waitFor(() => expect(within(fold).queryByText('New: stitch counter')).toBeNull());
584
+ });
585
+
586
+ it('discards a draft', async () => {
587
+ renderPage();
588
+ const fold = await openFold(/^product updates$/i);
589
+ await userEvent.click(await within(fold).findByRole('button', { name: /^discard$/i }));
590
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/drafts/d-1/discard/' }));
591
+ });
592
+
593
+ it('sends a test of a draft to the admin', async () => {
594
+ renderPage();
595
+ const fold = await openFold(/^product updates$/i);
596
+ await within(fold).findByText('New: stitch counter');
597
+ await userEvent.click(within(fold).getByRole('button', { name: /send (a )?test to me/i }));
598
+ await waitFor(() => expect(writes()[0]).toMatchObject({ method: 'POST', path: '/test-send/', body: { draft_id: 'd-1' } }));
599
+ });
600
+ });
601
+
602
+ // ---- A7: results + send log ---------------------------------------------------
603
+
604
+ describe('results (A7)', () => {
605
+ it('shows every number the server reports', async () => {
606
+ renderPage();
607
+ const fold = await openFold(/^results$/i);
608
+ await waitFor(() => expect(fold.textContent).toMatch(/sent\D*120/i));
609
+ const t = fold.textContent ?? '';
610
+ expect(t).toMatch(/delivered\D*117/i);
611
+ expect(t).toMatch(/opened\D*61/i);
612
+ expect(t).toMatch(/clicked\D*19/i);
613
+ expect(t).toMatch(/came back\D*11/i);
614
+ expect(t).toMatch(/unsubscribed\D*2/i);
615
+ expect(t).toMatch(/bounced\D*3/i);
616
+ });
617
+ });
618
+
619
+ describe('send log (A7, server-side pagination)', () => {
620
+ it('asks the server for page 1 and shows the SERVER total', async () => {
621
+ renderPage();
622
+ const fold = await openFold(/^send log$/i);
623
+ await waitFor(() => expect(within(fold).getAllByRole('row').length).toBeGreaterThan(1));
624
+ const req = hits.find((h) => h.method === 'GET' && h.path === '/sends/')!;
625
+ expect(req.search.get('page')).toBe('1');
626
+ expect(req.search.get('page_size')).toBe('25');
627
+ // header row + 25 data rows
628
+ expect(within(fold).getAllByRole('row')).toHaveLength(26);
629
+ expect(fold.textContent).toMatch(/1\s*[–-]\s*25\s+of\s+120/);
630
+ });
631
+
632
+ it('"Next page" asks the server for page 2', async () => {
633
+ renderPage();
634
+ const fold = await openFold(/^send log$/i);
635
+ await within(fold).findByText('customer1@example.com');
636
+ await userEvent.click(within(fold).getByRole('button', { name: /next page/i }));
637
+ await waitFor(() => expect(hits.filter((h) => h.path === '/sends/').at(-1)!.search.get('page')).toBe('2'));
638
+ expect(await within(fold).findByText('customer26@example.com')).toBeInTheDocument();
639
+ expect(fold.textContent).toMatch(/26\s*[–-]\s*50\s+of\s+120/);
640
+ });
641
+
642
+ it('filtering by program goes to the server and resets to page 1', async () => {
643
+ db.sends = [...sendRows(30), ...sendRows(5, 30).map((r) => ({ ...r, program_key: 'product_update' }))];
644
+ renderPage();
645
+ const fold = await openFold(/^send log$/i);
646
+ await within(fold).findByText('customer1@example.com');
647
+ await userEvent.click(within(fold).getByRole('button', { name: /next page/i }));
648
+ await waitFor(() => expect(hits.filter((h) => h.path === '/sends/').at(-1)!.search.get('page')).toBe('2'));
649
+ await userEvent.selectOptions(within(fold).getByRole('combobox', { name: /program/i }), 'product_update');
650
+ await waitFor(() => {
651
+ const q = hits.filter((h) => h.path === '/sends/').at(-1)!.search;
652
+ expect(q.get('program')).toBe('product_update');
653
+ expect(q.get('page')).toBe('1');
654
+ });
655
+ expect(await within(fold).findByText(/1\s*[–-]\s*5\s+of\s+5/)).toBeInTheDocument();
656
+ });
657
+ });
658
+
659
+ // ---- admin only --------------------------------------------------------------------
660
+
661
+ describe('admin only', () => {
662
+ it('a non-admin (403) sees a plain notice and no controls', async () => {
663
+ db.settingsStatus = 403;
664
+ renderPage();
665
+ expect((await screen.findByRole('alert')).textContent).toMatch(/only foundry admins/i);
666
+ expect(screen.queryAllByRole('switch')).toHaveLength(0);
667
+ expect(screen.queryByRole('button', { name: /send an update now/i })).toBeNull();
668
+ });
669
+
670
+ it('a 401 (no valid bearer) is not reported as "not an admin"', async () => {
671
+ db.settingsStatus = 401;
672
+ renderPage();
673
+ const alert = await screen.findByRole('alert');
674
+ expect(alert.textContent).toMatch(/sign-in was not accepted/i);
675
+ expect(alert.textContent).not.toMatch(/only foundry admins/i);
676
+ expect(screen.queryAllByRole('switch')).toHaveLength(0);
677
+ });
678
+ });