@akinon/pz-theme 2.0.95-beta.0 → 2.0.95-beta.1

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 (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/account-button-block.tsx +25 -0
  30. package/src/blocks/add-to-cart-block.tsx +15 -0
  31. package/src/blocks/auth-field-block.tsx +13 -3
  32. package/src/blocks/auth-flow-block.tsx +45 -3
  33. package/src/blocks/auth-form-block.tsx +337 -54
  34. package/src/blocks/auth-success-block.tsx +85 -0
  35. package/src/blocks/block-renderer-registry.tsx +8 -0
  36. package/src/blocks/button-block.tsx +43 -13
  37. package/src/blocks/captcha-block.tsx +61 -0
  38. package/src/blocks/change-password-form-block.tsx +15 -0
  39. package/src/blocks/forgot-password-block.tsx +132 -9
  40. package/src/blocks/form-state-block.tsx +84 -0
  41. package/src/blocks/newsletter-form-block.tsx +16 -0
  42. package/src/blocks/otp-form-block.tsx +161 -67
  43. package/src/blocks/profile-form-block.tsx +13 -0
  44. package/src/blocks/register-form-block.tsx +362 -110
  45. package/src/blocks/reset-password-block.tsx +717 -0
  46. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  47. package/src/chrome/placeholder-slugs.ts +3 -0
  48. package/src/chrome/route-slug.ts +11 -0
  49. package/src/index.ts +10 -0
  50. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  51. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  52. package/src/overlays/modal-chrome-css.ts +8 -1
  53. package/src/overlays/modal-host-context.tsx +66 -0
  54. package/src/overlays/modal-preview-host.tsx +7 -2
  55. package/src/overlays/modal-registry.ts +38 -1
  56. package/src/overlays/overlay-loading.tsx +144 -0
  57. package/src/overlays/overlay-surface.tsx +23 -0
  58. package/src/overlays/overlay-tokens.ts +12 -0
  59. package/src/overlays/theme-modal-mount.tsx +85 -6
  60. package/src/utils/visibility-rules.ts +114 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.95-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.95-beta.1
9
+ - @akinon/theme-contract@2.0.95-beta.1
10
+
3
11
  ## 2.0.95-beta.0
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.95-beta.0",
4
+ "version": "2.0.95-beta.1",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.95-beta.0",
11
+ "@akinon/next": "2.0.95-beta.1",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.95-beta.0",
16
+ "@akinon/theme-contract": "2.0.95-beta.1",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.95-beta.0",
24
+ "@akinon/next": "2.0.95-beta.1",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,261 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFormBlock from '../blocks/auth-form-block';
5
+ import CaptchaBlock from '../blocks/captcha-block';
6
+ import { Block } from '../theme-block';
7
+
8
+ /**
9
+ * The security check commerce escalates to after too many attempts.
10
+ *
11
+ * Before this, the composable auth set could not answer it at all: the forms
12
+ * sent no `captchaValidated`, nothing rendered a widget, and the challenge
13
+ * reached the shopper as the literal `[{"type":"captcha"}]` in the error slot.
14
+ * A brand whose commerce is abuse-protected therefore had to keep its
15
+ * hand-written login.
16
+ *
17
+ * The widget itself stays in the core `useCaptcha` hook — the same one the
18
+ * brands' own views use — so what is pinned here is the SEAM: the flag on the
19
+ * body, where the widget is drawn, and the one retry a solved challenge earns.
20
+ */
21
+
22
+ const signIn = jest.fn();
23
+
24
+ jest.mock('next-auth/react', () => ({
25
+ signIn: (...args: unknown[]) => signIn(...args),
26
+ useSession: () => ({ data: null, status: 'unauthenticated' })
27
+ }));
28
+
29
+ jest.mock('next/navigation', () => ({
30
+ usePathname: () => '/',
31
+ useSearchParams: () => new URLSearchParams(),
32
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() })
33
+ }));
34
+
35
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
36
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
37
+ }));
38
+
39
+ const validate = jest.fn();
40
+ let captchaValidated = false;
41
+
42
+ jest.mock('@akinon/next/hooks', () => ({
43
+ useLocalization: () => ({ locale: 'en', currency: 'USD' }),
44
+ useCaptcha: () => ({
45
+ // The real hook renders reCAPTCHA; a marker is enough to prove the form
46
+ // put the widget where the composition asked for it.
47
+ CaptchaView: () => <div data-testid="captcha-widget" />,
48
+ validated: captchaValidated,
49
+ isVisible: true,
50
+ validate
51
+ })
52
+ }));
53
+
54
+ const block = (overrides: Partial<Block>): Block =>
55
+ ({
56
+ id: 'blk',
57
+ type: 'text',
58
+ label: 'Block',
59
+ value: '',
60
+ order: 0,
61
+ hidden: false,
62
+ properties: {},
63
+ styles: {},
64
+ ...overrides
65
+ }) as Block;
66
+
67
+ const field = (fieldType: string, order: number): Block =>
68
+ block({
69
+ id: `field-${fieldType}`,
70
+ type: 'auth-field',
71
+ label: fieldType,
72
+ properties: { fieldType },
73
+ order
74
+ });
75
+
76
+ const renderForm = (properties: Record<string, unknown>, children?: Block[]) =>
77
+ render(
78
+ <AuthFormBlock
79
+ block={
80
+ block({
81
+ id: 'login-1',
82
+ type: 'auth-form',
83
+ label: 'Auth Form',
84
+ properties,
85
+ blocks: children
86
+ }) as Block
87
+ }
88
+ placeholderId="placeholder-1"
89
+ sectionId="section-1"
90
+ currentBreakpoint="desktop"
91
+ />
92
+ );
93
+
94
+ const fill = (container: HTMLElement) => {
95
+ ['email', 'password'].forEach((name) => {
96
+ const input = container.querySelector(
97
+ `input[name="${name}"]`
98
+ ) as HTMLInputElement;
99
+ fireEvent.change(input, {
100
+ target: { value: name === 'email' ? 'ada@example.com' : 'hunter2' }
101
+ });
102
+ });
103
+ };
104
+
105
+ const captchaRefusal = {
106
+ error: 'CredentialsSignin',
107
+ code: JSON.stringify([{ type: 'captcha' }])
108
+ };
109
+
110
+ const bodyOf = (call: number) =>
111
+ signIn.mock.calls[call][1] as Record<string, unknown>;
112
+
113
+ beforeEach(() => {
114
+ signIn.mockReset();
115
+ validate.mockReset();
116
+ captchaValidated = false;
117
+ });
118
+
119
+ describe('a form that does not offer the check', () => {
120
+ it('sends no captcha flag and draws no widget', async () => {
121
+ signIn.mockResolvedValue({ error: null, url: '/account' });
122
+
123
+ const { container } = renderForm({});
124
+ fill(container);
125
+ fireEvent.submit(container.querySelector('form')!);
126
+
127
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
128
+
129
+ // Every published composition posts exactly the body it posts today.
130
+ expect(bodyOf(0)).not.toHaveProperty('captchaValidated');
131
+ expect(screen.queryByTestId('captcha-widget')).toBeNull();
132
+ });
133
+
134
+ it('still names the escalation instead of printing its JSON', async () => {
135
+ signIn.mockResolvedValue(captchaRefusal);
136
+
137
+ const { container } = renderForm({
138
+ captchaLabel: 'Security check needed.'
139
+ });
140
+ fill(container);
141
+ fireEvent.submit(container.querySelector('form')!);
142
+
143
+ await waitFor(() =>
144
+ expect(screen.getByText('Security check needed.')).toBeInTheDocument()
145
+ );
146
+ // Nothing to answer it with, so no retry is attempted.
147
+ expect(signIn).toHaveBeenCalledTimes(1);
148
+ expect(validate).not.toHaveBeenCalled();
149
+ });
150
+ });
151
+
152
+ describe('a form that offers the check', () => {
153
+ it('sends the flag and draws the widget above its submit', async () => {
154
+ signIn.mockResolvedValue({ error: null, url: '/account' });
155
+
156
+ const { container } = renderForm({ showCaptcha: true });
157
+ fill(container);
158
+ fireEvent.submit(container.querySelector('form')!);
159
+
160
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
161
+
162
+ expect(bodyOf(0).captchaValidated).toBe(false);
163
+ expect(screen.getByTestId('captcha-widget')).toBeInTheDocument();
164
+ });
165
+
166
+ it('retries once when the shopper solves the challenge', async () => {
167
+ signIn
168
+ .mockResolvedValueOnce(captchaRefusal)
169
+ .mockResolvedValueOnce({ error: null, url: '/account' });
170
+ // What `validate` resolves is the whole decision: true means commerce
171
+ // accepted the answer, and only then is there any point in retrying.
172
+ validate.mockImplementation(async () => {
173
+ captchaValidated = true;
174
+
175
+ return true;
176
+ });
177
+
178
+ const { container } = renderForm({ showCaptcha: true });
179
+ fill(container);
180
+ fireEvent.submit(container.querySelector('form')!);
181
+
182
+ await waitFor(() => expect(signIn).toHaveBeenCalledTimes(2));
183
+ expect(validate).toHaveBeenCalledTimes(1);
184
+ // The same body, now vouched for — the shopper does not retype anything.
185
+ expect(bodyOf(1).email).toBe('ada@example.com');
186
+ expect(bodyOf(1).captchaValidated).toBe(true);
187
+ });
188
+
189
+ it('does not retry while the challenge is unsolved', async () => {
190
+ signIn.mockResolvedValue(captchaRefusal);
191
+ // The first `validate` only REVEALS the widget, and answers false.
192
+ validate.mockResolvedValue(false);
193
+
194
+ const { container } = renderForm({
195
+ showCaptcha: true,
196
+ captchaLabel: 'Security check needed.'
197
+ });
198
+ fill(container);
199
+ fireEvent.submit(container.querySelector('form')!);
200
+
201
+ await waitFor(() => expect(validate).toHaveBeenCalled());
202
+
203
+ expect(signIn).toHaveBeenCalledTimes(1);
204
+ expect(screen.getByText('Security check needed.')).toBeInTheDocument();
205
+ // And the widget is on screen, so the next submit can succeed.
206
+ expect(screen.getByTestId('captcha-widget')).toBeInTheDocument();
207
+ });
208
+
209
+ it('draws the widget where the composition placed it, and only there', async () => {
210
+ signIn.mockResolvedValue({ error: null, url: '/account' });
211
+
212
+ const { container } = renderForm({ showCaptcha: true }, [
213
+ field('email', 0),
214
+ field('password', 1),
215
+ block({ id: 'captcha-1', type: 'captcha', label: 'Security Check', order: 2 })
216
+ ]);
217
+
218
+ // One widget, not two: the form's own fallback stands down as soon as the
219
+ // composition owns the placement.
220
+ expect(screen.getAllByTestId('captcha-widget')).toHaveLength(1);
221
+ expect(
222
+ container.querySelector('[data-auth-captcha]')?.parentElement
223
+ ).not.toBeNull();
224
+ });
225
+ });
226
+
227
+ describe('the block on its own', () => {
228
+ it('renders nothing outside a form that offers a challenge', () => {
229
+ const { container } = render(
230
+ <CaptchaBlock
231
+ block={block({ id: 'captcha-1', type: 'captcha', label: 'Security Check' })}
232
+ placeholderId="p"
233
+ sectionId="s"
234
+ currentBreakpoint="desktop"
235
+ />
236
+ );
237
+
238
+ expect(container.innerHTML).toBe('');
239
+ });
240
+
241
+ it('is a visible marker in the canvas, where the real widget cannot load', () => {
242
+ render(
243
+ <CaptchaBlock
244
+ block={
245
+ block({
246
+ id: 'captcha-1',
247
+ type: 'captcha',
248
+ label: 'Security Check',
249
+ properties: { label: 'Güvenlik doğrulaması' }
250
+ }) as Block
251
+ }
252
+ placeholderId="p"
253
+ sectionId="s"
254
+ currentBreakpoint="desktop"
255
+ isDesigner
256
+ />
257
+ );
258
+
259
+ expect(screen.getByText('Güvenlik doğrulaması')).toBeInTheDocument();
260
+ });
261
+ });
@@ -32,7 +32,11 @@ jest.mock('next/navigation', () => ({
32
32
  refresh: jest.fn(),
33
33
  replace: jest.fn()
34
34
  }),
35
- usePathname: () => '/account/orders'
35
+ usePathname: () => '/account/orders',
36
+ // The login form reads the query too, since where a sign-in leaves the
37
+ // shopper can come from `?callbackUrl`/`?next` — a double without this
38
+ // throws before a single event can be measured.
39
+ useSearchParams: () => new URLSearchParams()
36
40
  }));
37
41
 
38
42
  jest.mock('@akinon/next/data/client/user', () => ({
@@ -0,0 +1,348 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFormBlock from '../blocks/auth-form-block';
5
+ import AuthFlowBlock from '../blocks/auth-flow-block';
6
+ import {
7
+ isSafeInternalPath,
8
+ readReturnPath,
9
+ resolveAuthDestination,
10
+ resolveSignedInDestination,
11
+ withReturnPath
12
+ } from '../auth/auth-destination';
13
+ import { __resetAuthFlowBusForTests } from '../auth/auth-flow-bus';
14
+ import { Block } from '../theme-block';
15
+
16
+ /**
17
+ * Where a shopper goes after signing in, and what one who is ALREADY signed in
18
+ * sees on a login screen.
19
+ *
20
+ * Both used to have one compiled-in answer. The destination was `/`: a shopper
21
+ * sent to sign in from the basket, or one who opened the header's drawer two
22
+ * clicks into a product page, was taken to the homepage — and the storefront's
23
+ * own `?callbackUrl` / `?next` were ignored, which is what the brands' own
24
+ * views read. The signed-in case had no answer at all: a back button after
25
+ * login, a bookmarked /auth or a second tab showed a member a login form, and
26
+ * submitting it re-authenticated a live session.
27
+ */
28
+
29
+ const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/account' }));
30
+ let status = 'unauthenticated';
31
+
32
+ jest.mock('next-auth/react', () => ({
33
+ signIn: (...args: unknown[]) => signIn(...args),
34
+ useSession: () => ({ data: null, status })
35
+ }));
36
+
37
+ const push = jest.fn();
38
+ const replace = jest.fn();
39
+ const refresh = jest.fn();
40
+ let search = new URLSearchParams();
41
+
42
+ jest.mock('next/navigation', () => ({
43
+ useRouter: () => ({ push, replace, refresh }),
44
+ useSearchParams: () => search,
45
+ usePathname: () => '/orders/checkout'
46
+ }));
47
+
48
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
49
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
50
+ }));
51
+
52
+ const block = (overrides: Partial<Block>): Block =>
53
+ ({
54
+ id: 'blk',
55
+ type: 'text',
56
+ label: 'Block',
57
+ value: '',
58
+ order: 0,
59
+ hidden: false,
60
+ properties: {},
61
+ styles: {},
62
+ ...overrides
63
+ }) as Block;
64
+
65
+ const renderLogin = (properties: Record<string, unknown> = {}) =>
66
+ render(
67
+ <AuthFormBlock
68
+ block={
69
+ block({
70
+ id: 'login-1',
71
+ type: 'auth-form',
72
+ label: 'Auth Form',
73
+ properties
74
+ }) as Block
75
+ }
76
+ placeholderId="placeholder-1"
77
+ sectionId="section-1"
78
+ currentBreakpoint="desktop"
79
+ />
80
+ );
81
+
82
+ const submitLogin = (container: HTMLElement) => {
83
+ fireEvent.change(container.querySelector('input[name="email"]')!, {
84
+ target: { value: 'ada@example.com' }
85
+ });
86
+ fireEvent.change(container.querySelector('input[name="password"]')!, {
87
+ target: { value: 'hunter2' }
88
+ });
89
+ fireEvent.submit(container.querySelector('form')!);
90
+ };
91
+
92
+ beforeEach(() => {
93
+ signIn.mockClear();
94
+ push.mockClear();
95
+ replace.mockClear();
96
+ refresh.mockClear();
97
+ status = 'unauthenticated';
98
+ search = new URLSearchParams();
99
+ __resetAuthFlowBusForTests();
100
+ });
101
+
102
+ describe('a destination read out of the url', () => {
103
+ it('refuses anything that is not a path on this origin', () => {
104
+ expect(isSafeInternalPath('/basket')).toBe(true);
105
+ expect(isSafeInternalPath('/orders/checkout?step=2')).toBe(true);
106
+
107
+ // The whole point of reading a destination out of the url is that anyone
108
+ // can write one — a mail, an ad, a link on another site.
109
+ expect(isSafeInternalPath('https://evil.example/login')).toBe(false);
110
+ // Protocol-relative: starts with a slash, means a different host.
111
+ expect(isSafeInternalPath('//evil.example')).toBe(false);
112
+ expect(isSafeInternalPath('/\\evil.example')).toBe(false);
113
+ expect(isSafeInternalPath('javascript:alert(1)')).toBe(false);
114
+ expect(isSafeInternalPath('')).toBe(false);
115
+ expect(isSafeInternalPath(null)).toBe(false);
116
+ });
117
+
118
+ it('reads both keys a pz storefront uses, first one winning', () => {
119
+ expect(
120
+ readReturnPath(new URLSearchParams('callbackUrl=/basket&next=/account'))
121
+ ).toBe('/basket');
122
+ // `next` is what the OAuth middleware writes when it hands a shopper back.
123
+ expect(readReturnPath(new URLSearchParams('next=/account'))).toBe(
124
+ '/account'
125
+ );
126
+ expect(
127
+ readReturnPath(new URLSearchParams('callbackUrl=https://evil.example'))
128
+ ).toBeNull();
129
+ expect(readReturnPath(null)).toBeNull();
130
+ });
131
+
132
+ it('answers each mode the way its placement needs', () => {
133
+ const search = new URLSearchParams('callbackUrl=/basket');
134
+
135
+ expect(
136
+ resolveAuthDestination({ mode: 'return', authored: '/', search })
137
+ ).toBe('/basket');
138
+ // Nothing to navigate to: the shopper is already where they want to be.
139
+ expect(
140
+ resolveAuthDestination({ mode: 'stay', authored: '/x', search })
141
+ ).toBeNull();
142
+ // The historical answer, and still the default.
143
+ expect(
144
+ resolveAuthDestination({ mode: 'fixed', authored: '/account', search })
145
+ ).toBe('/account');
146
+ // A junk authored value cannot become a navigation either.
147
+ expect(
148
+ resolveAuthDestination({ mode: 'fixed', authored: 'https://evil', search })
149
+ ).toBe('/');
150
+ });
151
+
152
+ it('sends a signed-in shopper where they were going, else to the account', () => {
153
+ expect(
154
+ resolveSignedInDestination('/account', new URLSearchParams('next=/basket'))
155
+ ).toBe('/basket');
156
+ expect(resolveSignedInDestination('/account', null)).toBe('/account');
157
+ });
158
+
159
+ it('records the origin on a login link, once and never over an author', () => {
160
+ expect(withReturnPath('/auth', '/orders/checkout')).toBe(
161
+ '/auth?callbackUrl=%2Forders%2Fcheckout'
162
+ );
163
+ // Not twice, and not over an answer the author wrote themselves.
164
+ expect(withReturnPath('/auth?callbackUrl=/basket', '/x')).toBe(
165
+ '/auth?callbackUrl=/basket'
166
+ );
167
+ // Nothing to record, or nowhere safe to record it on.
168
+ expect(withReturnPath('/auth', undefined)).toBe('/auth');
169
+ expect(withReturnPath('https://brand.example/auth', '/x')).toBe(
170
+ 'https://brand.example/auth'
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('what a finished sign-in does', () => {
176
+ it('goes where the url asked, in return mode', async () => {
177
+ search = new URLSearchParams('callbackUrl=/basket');
178
+
179
+ const { container } = renderLogin({ afterSignIn: 'return' });
180
+ submitLogin(container);
181
+
182
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/basket'));
183
+ expect(refresh).toHaveBeenCalled();
184
+ });
185
+
186
+ it('stays put in a drawer, and still refreshes the page', async () => {
187
+ const { container } = renderLogin({ afterSignIn: 'stay' });
188
+ submitLogin(container);
189
+
190
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
191
+ // The refresh IS the navigation: the session has to reach the page the
192
+ // shopper is standing on, and nothing else should move.
193
+ expect(push).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('keeps the historical destination when nothing was authored', async () => {
197
+ const { container } = renderLogin({});
198
+ submitLogin(container);
199
+
200
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/'));
201
+ });
202
+
203
+ it('never navigates to a destination the url smuggled in', async () => {
204
+ search = new URLSearchParams('callbackUrl=https://evil.example');
205
+
206
+ const { container } = renderLogin({
207
+ afterSignIn: 'return',
208
+ callbackUrl: '/account'
209
+ });
210
+ submitLogin(container);
211
+
212
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/account'));
213
+ });
214
+ });
215
+
216
+ describe('a shopper who is already signed in', () => {
217
+ it('is told, instead of being shown a form they cannot use', () => {
218
+ status = 'authenticated';
219
+
220
+ renderLogin({ whenSignedIn: 'message', signedInLabel: 'Zaten girişliniz.' });
221
+
222
+ expect(screen.getByText('Zaten girişliniz.')).toBeInTheDocument();
223
+ expect(document.querySelector('form')).toBeNull();
224
+ });
225
+
226
+ it('is taken where they were going, with replace so Back does not loop', () => {
227
+ status = 'authenticated';
228
+ search = new URLSearchParams('callbackUrl=/basket');
229
+
230
+ renderLogin({ whenSignedIn: 'redirect' });
231
+
232
+ expect(replace).toHaveBeenCalledWith('/basket');
233
+ });
234
+
235
+ it('falls back to the authored account page', () => {
236
+ status = 'authenticated';
237
+
238
+ renderLogin({ whenSignedIn: 'redirect', signedInHref: '/account/orders' });
239
+
240
+ expect(replace).toHaveBeenCalledWith('/account/orders');
241
+ });
242
+
243
+ it('cannot re-authenticate a live session, whatever the policy says', async () => {
244
+ status = 'authenticated';
245
+
246
+ // Left on the historical `form` policy: the form is still on screen, so
247
+ // the guard is what stops the password going back on the wire.
248
+ const { container } = renderLogin({
249
+ signedInLabel: 'Zaten girişliniz.'
250
+ });
251
+ submitLogin(container);
252
+
253
+ await waitFor(() =>
254
+ expect(screen.getByText('Zaten girişliniz.')).toBeInTheDocument()
255
+ );
256
+ expect(signIn).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it('still sees the form in the canvas, so it can be styled', () => {
260
+ status = 'authenticated';
261
+
262
+ const { container } = render(
263
+ <AuthFormBlock
264
+ block={
265
+ block({
266
+ id: 'login-1',
267
+ type: 'auth-form',
268
+ label: 'Auth Form',
269
+ properties: { whenSignedIn: 'message' }
270
+ }) as Block
271
+ }
272
+ placeholderId="p"
273
+ sectionId="s"
274
+ currentBreakpoint="desktop"
275
+ isDesigner
276
+ />
277
+ );
278
+
279
+ expect(container.querySelector('form')).not.toBeNull();
280
+ expect(replace).not.toHaveBeenCalled();
281
+ });
282
+ });
283
+
284
+ describe('a journey that knows the shopper', () => {
285
+ const step = (stepKey: string, text: string): Block =>
286
+ block({
287
+ id: `step-${stepKey}`,
288
+ type: 'auth-step',
289
+ label: stepKey,
290
+ properties: { stepKey },
291
+ blocks: [
292
+ block({
293
+ id: `text-${stepKey}`,
294
+ type: 'text',
295
+ label: 'Copy',
296
+ value: text
297
+ })
298
+ ]
299
+ });
300
+
301
+ const renderFlow = (properties: Record<string, unknown>) =>
302
+ render(
303
+ <AuthFlowBlock
304
+ block={
305
+ block({
306
+ id: 'flow-1',
307
+ type: 'auth-flow',
308
+ label: 'Auth Flow',
309
+ properties: { flowId: 'auth', ...properties },
310
+ blocks: [step('login', 'Log in'), step('member', 'Welcome back')]
311
+ }) as Block
312
+ }
313
+ placeholderId="placeholder-1"
314
+ sectionId="section-1"
315
+ currentBreakpoint="desktop"
316
+ />
317
+ );
318
+
319
+ it('opens on the member step for a signed-in shopper', () => {
320
+ status = 'authenticated';
321
+
322
+ renderFlow({ initialStep: 'login', signedInStep: 'member' });
323
+
324
+ expect(screen.getByText('Welcome back')).toBeInTheDocument();
325
+ expect(screen.queryByText('Log in')).toBeNull();
326
+ });
327
+
328
+ it('opens on the initial step for a guest, and while the session loads', () => {
329
+ renderFlow({ initialStep: 'login', signedInStep: 'member' });
330
+
331
+ expect(screen.getByText('Log in')).toBeInTheDocument();
332
+
333
+ // `loading` is not treated as signed in: the flow would open on the member
334
+ // step for a guest and then swap under them.
335
+ status = 'loading';
336
+ renderFlow({ initialStep: 'login', signedInStep: 'member' });
337
+
338
+ expect(screen.getAllByText('Log in').length).toBeGreaterThan(0);
339
+ });
340
+
341
+ it('leaves a flow that named no member step exactly as it was', () => {
342
+ status = 'authenticated';
343
+
344
+ renderFlow({ initialStep: 'login' });
345
+
346
+ expect(screen.getByText('Log in')).toBeInTheDocument();
347
+ });
348
+ });