Skip to content

Commit 553e43c

Browse files
test(mosaic): replace module-mocked tests with feature tests (#10444)
1 parent 3748a2d commit 553e43c

12 files changed

Lines changed: 526 additions & 912 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/__tests__/feature/fake-fapi.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,18 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
602602
const user = updateActiveUser(state, user => patchUser(user, body));
603603
return user ? envelope(user, state.client) : missing();
604604
}),
605+
http.post(fapiUrl('/v1/me'), ({ request }) => {
606+
if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
607+
return undefined;
608+
}
609+
const user = activeUser(state);
610+
if (!user) {
611+
return missing();
612+
}
613+
const sessions = state.client.sessions.filter(session => session.user.id !== user.id);
614+
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
615+
return envelope({ object: 'user', id: user.id, deleted: true }, state.client);
616+
}),
605617
http.post(fapiUrl('/v1/me/profile_image'), async ({ request }) => {
606618
if (new URL(request.url).searchParams.get('_method') === 'DELETE') {
607619
const user = updateActiveUser(state, user => ({ ...user, image_url: '', has_image: false }));

‎packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx‎

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { http, HttpResponse } from 'msw';
1212
import { useRef, useState } from 'react';
1313
import { describe, expect, it, vi } from 'vitest';
1414

15+
import { deferred } from '../../../__tests__/async';
1516
import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
1617
import type { FakeVerificationSeed } from '../../../__tests__/feature/fake-fapi/verification';
1718
import { fapiClient, fapiEmailAddress, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
@@ -29,6 +30,14 @@ const alice = fapiUser({
2930
});
3031

3132
const aliceSession = fapiSession({ id: 'sess_1', user: alice });
33+
const bobSession = fapiSession({
34+
id: 'sess_2',
35+
user: fapiUser({
36+
id: 'user_2',
37+
first_name: 'Bob',
38+
email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })],
39+
}),
40+
});
3241

3342
const VERIFY = '/v1/client/sessions/sess_1/verify';
3443
const PREPARE_FIRST = `${VERIFY}/prepare_first_factor`;
@@ -109,11 +118,15 @@ function errorCode(error: unknown) {
109118
return 'unknown';
110119
}
111120

112-
function Host({ action }: { action: () => Promise<unknown> }) {
121+
function Host({ action, onOutcome }: { action: () => Promise<unknown>; onOutcome?: (outcome: string) => void }) {
113122
const [run, reverification] = useReverificationFlow(action);
114123
const [outcome, setOutcome] = useState('');
115124
const [running, setRunning] = useState(false);
116125
const inFlight = useRef(false);
126+
const report = (value: string) => {
127+
setOutcome(value);
128+
onOutcome?.(value);
129+
};
117130

118131
return (
119132
<>
@@ -127,8 +140,8 @@ function Host({ action }: { action: () => Promise<unknown> }) {
127140
}
128141
void run()
129142
.then(
130-
() => setOutcome('resolved'),
131-
(error: unknown) => setOutcome(`rejected: ${errorCode(error)}`),
143+
() => report('resolved'),
144+
(error: unknown) => report(`rejected: ${errorCode(error)}`),
132145
)
133146
.finally(() => {
134147
if (owner) {
@@ -196,11 +209,13 @@ function renderReverification(
196209
action = guardedAction(),
197210
seed: FakeFapiSeed = signedIn(),
198211
localization?: MosaicLocalization,
212+
options?: Parameters<typeof renderWithClerk>[1],
199213
) {
200214
const fapi = serveFapi(seed);
201215
const host = <Host action={action} />;
202216
return renderWithClerk(
203217
localization ? <MosaicProvider localization={localization}>{host}</MosaicProvider> : host,
218+
options,
204219
).then(view => ({ ...view, fapi, action }));
205220
}
206221

@@ -1060,7 +1075,32 @@ describe('Reverification', () => {
10601075
// Email support opens a mail to support@ the Frontend API host
10611076
await user.click(await screen.findByRole('button', { name: 'Email support' }));
10621077
await waitFor(() => expect(navigations.urls).toHaveLength(1));
1063-
expect(navigations.urls[0]).toMatch(/^mailto:support@/);
1078+
expect(navigations.urls[0]).toMatch(/^mailto:support@run\d+\.12345\.prod\.lclclerk\.com$/);
1079+
} finally {
1080+
navigations.stop();
1081+
}
1082+
});
1083+
1084+
it('emails the support address from the supportEmail option over the environment', async () => {
1085+
await renderReverification(
1086+
guardedAction(),
1087+
verificationSeed(
1088+
{ firstFactors: [passwordFactor, emailFactor] },
1089+
{ environment: fapiEnvironment({ display_config: { support_email: 'help@acme.test' } }) },
1090+
),
1091+
undefined,
1092+
{ supportEmail: 'option@acme.test' },
1093+
);
1094+
const navigations = captureNavigations();
1095+
1096+
try {
1097+
const user = await startVerification();
1098+
expect(await screen.findByText(STEP.password)).toBeVisible();
1099+
await useAnotherMethod(user);
1100+
await user.click(await screen.findByRole('button', { name: 'Get help' }));
1101+
1102+
await user.click(await screen.findByRole('button', { name: 'Email support' }));
1103+
await waitFor(() => expect(navigations.urls).toEqual(['mailto:option@acme.test']));
10641104
} finally {
10651105
navigations.stop();
10661106
}
@@ -1127,6 +1167,7 @@ describe('Reverification', () => {
11271167
// A second call rejects with request_already_in_progress
11281168
await user.click(screen.getByRole('button', { name: 'Run action' }));
11291169
await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress'));
1170+
expect(action).toHaveBeenCalledTimes(2);
11301171
expect(passwordField()).toHaveAttribute('aria-disabled', 'true');
11311172
expect(passwordField()).toHaveAttribute('readonly');
11321173

@@ -1369,8 +1410,26 @@ describe('Reverification', () => {
13691410
});
13701411

13711412
describe('action lifecycle', () => {
1413+
it('rejects a second call while the first request is in flight', async () => {
1414+
const response = deferred<{ done: true }>();
1415+
const action = vi.fn(() => response.promise);
1416+
await renderReverification(action);
1417+
const user = await startVerification();
1418+
1419+
await user.click(screen.getByRole('button', { name: 'Run action' }));
1420+
1421+
await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress'));
1422+
expect(action).toHaveBeenCalledOnce();
1423+
1424+
response.resolve({ done: true });
1425+
await untilResolved();
1426+
});
1427+
13721428
it('rejects a second call while a challenge is open', async () => {
1373-
await renderReverification(guardedAction(), verificationSeed({ firstFactors: [passwordFactor] }));
1429+
const { action } = await renderReverification(
1430+
guardedAction(),
1431+
verificationSeed({ firstFactors: [passwordFactor] }),
1432+
);
13741433
const user = await startVerification();
13751434
await user.type(await screen.findByLabelText('Password'), 'typed');
13761435

@@ -1380,6 +1439,7 @@ describe('Reverification', () => {
13801439
await waitFor(() => expect(outcome()).toBe('rejected: request_already_in_progress'));
13811440

13821441
// The open challenge is unaffected
1442+
expect(action).toHaveBeenCalledOnce();
13831443
expect(screen.getByText(STEP.password)).toBeVisible();
13841444
expect(passwordField()).toHaveValue('typed');
13851445
});
@@ -1401,6 +1461,134 @@ describe('Reverification', () => {
14011461
await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled'));
14021462
});
14031463

1464+
it('cancels the challenge when the active session changes', async () => {
1465+
const { clerk } = await renderReverification(
1466+
guardedAction(),
1467+
verificationSeed({ firstFactors: [passwordFactor] }, { client: fapiClient([aliceSession, bobSession]) }),
1468+
);
1469+
await startVerification();
1470+
expect(await screen.findByText(STEP.password)).toBeVisible();
1471+
1472+
await act(() => clerk.setActive({ session: 'sess_2' }));
1473+
1474+
await untilClosed();
1475+
await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled'));
1476+
});
1477+
1478+
it('keeps the challenge open while the session is briefly unset during a navigation', async () => {
1479+
const { clerk, navigate } = await renderReverification(
1480+
guardedAction(),
1481+
verificationSeed({ firstFactors: [passwordFactor] }),
1482+
);
1483+
const navigation = deferred<undefined>();
1484+
navigate.mockImplementationOnce(() => navigation.promise);
1485+
const user = await startVerification();
1486+
expect(await screen.findByText(STEP.password)).toBeVisible();
1487+
1488+
let switching: Promise<void> = Promise.resolve();
1489+
act(() => {
1490+
switching = clerk.setActive({ session: 'sess_1', redirectUrl: '/elsewhere' });
1491+
});
1492+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/elsewhere'));
1493+
expect(clerk.session).toBeUndefined();
1494+
navigation.resolve(undefined);
1495+
await act(() => switching);
1496+
1497+
expect(clerk.session?.id).toBe('sess_1');
1498+
expect(screen.getByText(STEP.password)).toBeVisible();
1499+
expect(outcome()).toBe('');
1500+
1501+
await dismiss(user);
1502+
await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled'));
1503+
});
1504+
1505+
it('lets a retry finish when the session changes or signs out', async () => {
1506+
const retry = deferred<{ done: true }>();
1507+
const action = vi
1508+
.fn()
1509+
.mockResolvedValueOnce(reverificationError())
1510+
.mockImplementationOnce(() => retry.promise);
1511+
const { clerk } = await renderReverification(
1512+
action,
1513+
verificationSeed({ firstFactors: [passwordFactor] }, { client: fapiClient([aliceSession, bobSession]) }),
1514+
);
1515+
const user = await startVerification();
1516+
await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`);
1517+
await waitFor(() => expect(action).toHaveBeenCalledTimes(2));
1518+
1519+
await act(() => clerk.setActive({ session: 'sess_2' }));
1520+
await act(() => clerk.signOut());
1521+
await settle();
1522+
expect(outcome()).toBe('');
1523+
1524+
retry.resolve({ done: true });
1525+
await untilResolved();
1526+
});
1527+
1528+
it('cancels the challenge when its owner unmounts', async () => {
1529+
serveFapi(verificationSeed({ firstFactors: [passwordFactor] }));
1530+
const onOutcome = vi.fn();
1531+
const { rerender } = await renderWithClerk(
1532+
<Host
1533+
action={guardedAction()}
1534+
onOutcome={onOutcome}
1535+
/>,
1536+
);
1537+
await startVerification();
1538+
expect(await screen.findByText(STEP.password)).toBeVisible();
1539+
1540+
rerender(<p>Gone</p>);
1541+
1542+
await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('rejected: reverification_cancelled'));
1543+
});
1544+
1545+
it('cancels the challenge when its owner unmounts before the action asks for reverification', async () => {
1546+
serveFapi(signedIn());
1547+
const response = deferred<unknown>();
1548+
const action = vi.fn(() => response.promise);
1549+
const onOutcome = vi.fn();
1550+
const { rerender } = await renderWithClerk(
1551+
<Host
1552+
action={action}
1553+
onOutcome={onOutcome}
1554+
/>,
1555+
);
1556+
await startVerification();
1557+
await waitFor(() => expect(action).toHaveBeenCalledOnce());
1558+
1559+
rerender(<p>Gone</p>);
1560+
response.resolve(reverificationError());
1561+
1562+
await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('rejected: reverification_cancelled'));
1563+
expect(action).toHaveBeenCalledOnce();
1564+
});
1565+
1566+
it('lets a retry finish when its owner unmounts', async () => {
1567+
serveFapi(verificationSeed({ firstFactors: [passwordFactor] }));
1568+
const retry = deferred<{ done: true }>();
1569+
const action = vi
1570+
.fn()
1571+
.mockResolvedValueOnce(reverificationError())
1572+
.mockImplementationOnce(() => retry.promise);
1573+
const onOutcome = vi.fn();
1574+
const { rerender } = await renderWithClerk(
1575+
<Host
1576+
action={action}
1577+
onOutcome={onOutcome}
1578+
/>,
1579+
);
1580+
const user = await startVerification();
1581+
await user.type(await screen.findByLabelText('Password'), `${PASSWORD}{Enter}`);
1582+
await waitFor(() => expect(action).toHaveBeenCalledTimes(2));
1583+
1584+
rerender(<p>Gone</p>);
1585+
await settle();
1586+
expect(onOutcome).not.toHaveBeenCalled();
1587+
1588+
retry.resolve({ done: true });
1589+
await waitFor(() => expect(onOutcome).toHaveBeenCalledWith('resolved'));
1590+
});
1591+
14041592
it('passes through an error that is unrelated to reverification', async () => {
14051593
const action = vi.fn().mockRejectedValue(new Error('unrelated failure'));
14061594
await renderReverification(action);

0 commit comments

Comments
 (0)