@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.88

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 (49) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +2 -2
  3. package/AGENTS.md +3 -1
  4. package/README.MD +11 -5
  5. package/client/components/body-portal.tsx +25 -0
  6. package/client/components/breadcrumbs.tsx +13 -0
  7. package/client/components/flow-diagram.tsx +2 -2
  8. package/client/layouts/app-layout.tsx +8 -4
  9. package/client/layouts/components/sidebar-footer.tsx +18 -25
  10. package/client/lib/navigation-transform.ts +156 -22
  11. package/client/locales/system/en-US.ts +7 -5
  12. package/client/locales/system/settings-en-US.ts +17 -0
  13. package/client/locales/system/settings-zh-CN.ts +15 -0
  14. package/client/locales/system/zh-CN.ts +7 -5
  15. package/client/pages/auth/register.tsx +39 -22
  16. package/client/pages/auth/shared.tsx +1 -3
  17. package/client/pages/settings/hooks.tsx +3 -68
  18. package/client/pages/settings/notification-routes.ts +109 -0
  19. package/client/pages/settings/notification-templates.tsx +26 -0
  20. package/client/pages/settings/permission-management-page.tsx +15 -90
  21. package/client/pages/settings/routes.ts +96 -98
  22. package/client/pages/settings/settings-home.tsx +109 -0
  23. package/client/pages/settings/settings-module-page.tsx +158 -0
  24. package/client/pages/settings/shell-routes.ts +53 -0
  25. package/client/routes.ts +17 -1
  26. package/client/routing/app-router.tsx +1 -14
  27. package/client/routing/client-route.tsx +16 -9
  28. package/client/routing/route-context.tsx +30 -3
  29. package/client/routing/route-leave-boundary.tsx +249 -0
  30. package/client/routing/route-navigation.ts +72 -8
  31. package/client/routing/route-tree.tsx +7 -12
  32. package/client/runtime.ts +2 -2
  33. package/config.example.yml +1 -1
  34. package/package.json +12 -12
  35. package/server/config/auth.ts +1 -1
  36. package/server/providers/index.ts +2 -0
  37. package/server/providers/notification-management.ts +27 -0
  38. package/tests/client/account-permissions.test.tsx +1 -1
  39. package/tests/client/client-auth.test.tsx +10 -1
  40. package/tests/client/client-routes.test.ts +137 -4
  41. package/tests/client/client-shell.test.tsx +23 -8
  42. package/tests/client/foundation-integration.test.ts +38 -17
  43. package/tests/client/layout-i18n.test.tsx +9 -4
  44. package/tests/client/permission-management-tabs.test.tsx +421 -16
  45. package/tests/client/registration-feedback.test.tsx +143 -0
  46. package/tests/client/settings-home.test.tsx +110 -0
  47. package/tests/client/settings-navigation.test.ts +230 -0
  48. package/tests/server/notification-templates.test.ts +144 -0
  49. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -1,12 +1,55 @@
1
+ import packageMetadata from '../../package.json' with { type: 'json' };
1
2
  import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
2
- import { screen, waitFor } from '@testing-library/react';
3
+ import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
4
+ import { act, screen, waitFor } from '@testing-library/react';
3
5
  import userEvent from '@testing-library/user-event';
4
- import { Route, Routes, useLocation } from 'react-router';
6
+ import { useState, type ReactNode } from 'react';
7
+ import { RouteLeaveBoundary } from '../../client/routing/route-leave-boundary.js';
8
+ import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
9
+ import { useSettingsData } from '../../client/pages/settings/hooks.js';
10
+ import { authorizationToken } from '@nocobase/app-plugin-authorization/server';
11
+ import { useSettingsLeave } from '../../client/pages/settings/hooks.js';
12
+ import { BodyPortal } from '../../client/components/body-portal.js';
13
+ import { ClientRoute } from '../../client/routing/client-route.js';
14
+ import { authorizationClientToken } from '@nocobase/app-plugin-authorization/client';
15
+ import {
16
+ Link,
17
+ Outlet,
18
+ Route,
19
+ Routes,
20
+ useLocation,
21
+ useNavigate,
22
+ } from 'react-router';
5
23
  import { expect, it } from 'vitest';
6
24
  import { PermissionManagementPage } from '../../client/pages/settings/permission-management-page.js';
25
+ import settingsRoutes from '../../client/pages/settings/routes.js';
26
+ import shellRoutes from '../../client/pages/settings/shell-routes.js';
27
+ import { RouteTreeProvider } from '../../client/routing/route-context.js';
28
+ import { useRouteNavigation } from '../../client/routing/route-navigation.js';
29
+
30
+ const { routes } = resolveAppClientContributions([
31
+ {
32
+ packageName: packageMetadata.name,
33
+ source: 'application',
34
+ routes: [shellRoutes, settingsRoutes],
35
+ },
36
+ ]);
37
+
38
+ /** The shell's route tree and permission check, as `AppLayout` provides them. */
39
+ function Shell({ children }: { children: ReactNode }) {
40
+ const { denied, loading } = useRouteNavigation(routes);
41
+ return (
42
+ <RouteTreeProvider routes={routes} access={{ denied, loading }}>
43
+ <output aria-label='Permission check'>
44
+ {loading ? 'Checking' : 'Ready'}
45
+ </output>
46
+ <RouteLeaveBoundary hidden={loading}>{children}</RouteLeaveBoundary>
47
+ </RouteTreeProvider>
48
+ );
49
+ }
7
50
 
8
51
  function mount(prefix: string, tab = 'roles', roles = true, locale = 'en-US') {
9
- return renderAuthenticatedClient(tree(prefix), {
52
+ return renderAuthenticatedClient(<Shell>{tree(prefix)}</Shell>, {
10
53
  route: `${prefix}/${tab}`,
11
54
  locale,
12
55
  member: true,
@@ -41,8 +84,8 @@ function tree(prefix: string) {
41
84
  </Routes>
42
85
  );
43
86
  }
44
- it.each(['', '/settings'])(
45
- 'uses two URL-backed tabs under one heading, preserves the existing %s route prefix and keyboard focus',
87
+ it.each(['/settings'])(
88
+ 'uses two URL-backed tabs under one heading at the existing %s URLs and keeps keyboard focus',
46
89
  async (prefix) => {
47
90
  const user = userEvent.setup();
48
91
  await mount(prefix);
@@ -85,23 +128,385 @@ it.each(['', '/settings'])(
85
128
  );
86
129
  },
87
130
  );
88
- it('hides an unreadable tab while keeping direct navigation on the readable default scope page', async () => {
89
- await mount('', 'default-access', false);
90
- await screen.findByRole('tab', { name: 'Default data scopes' });
91
- expect(
92
- screen.queryByRole('tab', { name: 'Roles and permissions' }),
93
- ).not.toBeInTheDocument();
94
- expect(
95
- screen.getByRole('tab', { name: 'Default data scopes' }),
96
- ).toHaveAttribute('aria-selected', 'true');
131
+ it('shows a readable view without a single-tab bar and keeps direct navigation on it', async () => {
132
+ await mount('/settings', 'default-access', false);
133
+ await screen.findByRole('heading', { name: 'Permission management' });
134
+ // The module has one view this viewer may open, so it shows that view without a tab bar.
135
+ await waitFor(() =>
136
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument(),
137
+ );
138
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
97
139
  expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
98
- '/default-access',
140
+ '/settings/default-access',
99
141
  );
100
142
  });
101
143
  it('uses the agreed Chinese menu and tab names', async () => {
102
- await mount('', 'roles', true, 'zh-CN');
144
+ await mount('/settings', 'roles', true, 'zh-CN');
103
145
  await screen.findByRole('tab', { name: '角色与权限' });
104
146
  expect(screen.getByRole('heading', { name: '权限管理' })).toBeVisible();
105
147
  expect(screen.getByRole('tab', { name: '角色与权限' })).toBeVisible();
106
148
  expect(screen.getByRole('tab', { name: '默认数据范围' })).toBeVisible();
107
149
  });
150
+
151
+ function Draft() {
152
+ const [value, setValue] = useState('');
153
+ const leave = useSettingsLeave(value !== '', false);
154
+ return (
155
+ <>
156
+ <input
157
+ aria-label='Draft'
158
+ value={value}
159
+ onChange={(event) => setValue(event.target.value)}
160
+ />
161
+ <BodyPortal>
162
+ <div aria-label='Draft overlay' role='dialog'>
163
+ {value}
164
+ </div>
165
+ </BodyPortal>
166
+ {leave.dialog}
167
+ </>
168
+ );
169
+ }
170
+
171
+ function DraftPage() {
172
+ return (
173
+ <PermissionManagementPage tab='roles'>
174
+ <Draft />
175
+ </PermissionManagementPage>
176
+ );
177
+ }
178
+
179
+ it.each([false, true])(
180
+ 'preserves the mounted draft during permission rechecks (page guard: %s)',
181
+ async (guarded) => {
182
+ let hold = false;
183
+ let release!: () => void;
184
+ const pending = new Promise<void>((resolve) => {
185
+ release = resolve;
186
+ });
187
+ const view = await renderAuthenticatedClient(
188
+ <Shell>
189
+ {guarded ? (
190
+ <ClientRoute
191
+ route={{
192
+ id: 'draft',
193
+ name: 'draft',
194
+ path: '/draft',
195
+ auth: 'required',
196
+ authz: 'unrestricted',
197
+ packageName: packageMetadata.name,
198
+ source: 'application',
199
+ componentLoader: async () => ({ default: DraftPage }),
200
+ }}
201
+ />
202
+ ) : (
203
+ <DraftPage />
204
+ )}
205
+ </Shell>,
206
+ {
207
+ route: '/settings/roles',
208
+ beforeRequest: async (request) => {
209
+ if (
210
+ hold &&
211
+ new URL(request.url).pathname.endsWith('/authorization/permissions')
212
+ )
213
+ await pending;
214
+ },
215
+ },
216
+ );
217
+ await screen.findByRole('tab', { name: 'Roles and permissions' });
218
+ const draft = screen.getByRole('textbox', { name: 'Draft' });
219
+ await userEvent.type(draft, 'Keep this unsaved value');
220
+ try {
221
+ hold = true;
222
+ act(() =>
223
+ view.app.container.resolve(authorizationClientToken).invalidate(),
224
+ );
225
+ await waitFor(() =>
226
+ expect(
227
+ screen.getByRole('status', { name: 'Permission check' }),
228
+ ).toHaveTextContent('Checking'),
229
+ );
230
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
231
+ expect(draft).toBeInTheDocument();
232
+ if (guarded) {
233
+ expect(draft).not.toBeVisible();
234
+ expect(
235
+ screen.queryByRole('dialog', { name: 'Draft overlay' }),
236
+ ).not.toBeInTheDocument();
237
+ }
238
+ expect(draft).toHaveValue('Keep this unsaved value');
239
+ } finally {
240
+ hold = false;
241
+ release();
242
+ }
243
+ await screen.findByRole('tab', { name: 'Roles and permissions' });
244
+ expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
245
+ expect(draft).toHaveValue('Keep this unsaved value');
246
+ },
247
+ );
248
+
249
+ it.each(['tab', 'breadcrumb'])(
250
+ 'protects an inline module draft when leaving by %s',
251
+ async (destination) => {
252
+ const user = userEvent.setup();
253
+ await renderAuthenticatedClient(
254
+ <Shell>
255
+ <Breadcrumbs />
256
+ <Routes>
257
+ <Route path='/settings/roles' element={<DraftPage />} />
258
+ <Route
259
+ path='/settings/default-access'
260
+ element={<Content tab='default-access' />}
261
+ />
262
+ <Route path='/settings' element={<h1>Settings destination</h1>} />
263
+ </Routes>
264
+ </Shell>,
265
+ { route: '/settings/roles' },
266
+ );
267
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
268
+ await user.type(draft, 'Unsaved inline draft');
269
+ const target =
270
+ destination === 'tab'
271
+ ? await screen.findByRole('tab', { name: 'Default data scopes' })
272
+ : screen.getByRole('link', { name: 'System settings' });
273
+ await user.click(target);
274
+ expect(await screen.findByRole('alertdialog')).toBeVisible();
275
+ await user.click(
276
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
277
+ );
278
+ expect(draft).toHaveValue('Unsaved inline draft');
279
+ await user.click(target);
280
+ await user.click(
281
+ await screen.findByRole('button', { name: 'Confirm', exact: true }),
282
+ );
283
+ if (destination === 'tab')
284
+ await waitFor(() =>
285
+ expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
286
+ '/settings/default-access',
287
+ ),
288
+ );
289
+ else
290
+ expect(
291
+ await screen.findByRole('heading', { name: 'Settings destination' }),
292
+ ).toBeVisible();
293
+ expect(
294
+ screen.queryByRole('textbox', { name: 'Draft' }),
295
+ ).not.toBeInTheDocument();
296
+ },
297
+ );
298
+
299
+ function HistoryControls() {
300
+ const navigate = useNavigate();
301
+ const location = useLocation();
302
+ return (
303
+ <>
304
+ <button onClick={() => navigate(-1)}>Browser back</button>
305
+ <output aria-label='Current URL'>{location.pathname}</output>
306
+ </>
307
+ );
308
+ }
309
+ function NestedDraft() {
310
+ const request = useSettingsData<{ data: unknown[] }>('users');
311
+ return (
312
+ <>
313
+ <DraftPage />
314
+ <output aria-label='Loaded users'>
315
+ {request.data ? 'Ready' : 'Loading'}
316
+ </output>
317
+ </>
318
+ );
319
+ }
320
+ const nestedRoute = {
321
+ id: 'nested-draft',
322
+ name: 'Nested draft',
323
+ path: '/settings/roles/test/edit',
324
+ auth: 'required',
325
+ authz: {
326
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
327
+ action: 'read',
328
+ },
329
+ packageName: packageMetadata.name,
330
+ source: 'application',
331
+ componentLoader: async () => ({ default: NestedDraft }),
332
+ } as const;
333
+ const parentRoute = {
334
+ ...nestedRoute,
335
+ id: 'parent-draft',
336
+ path: '/settings/roles',
337
+ componentLoader: async () => ({ default: Outlet }),
338
+ };
339
+ function nestedTree() {
340
+ return (
341
+ <Shell>
342
+ <Breadcrumbs />
343
+ <HistoryControls />
344
+ <Routes>
345
+ <Route
346
+ path='/settings'
347
+ element={
348
+ <>
349
+ <h1>Settings destination</h1>
350
+ <Link to='/settings/roles/test/edit'>Edit role</Link>
351
+ </>
352
+ }
353
+ />
354
+ <Route
355
+ path='/settings/roles'
356
+ element={<ClientRoute route={parentRoute} />}
357
+ >
358
+ <Route
359
+ path='test/edit'
360
+ element={<ClientRoute route={nestedRoute} />}
361
+ />
362
+ </Route>
363
+ </Routes>
364
+ </Shell>
365
+ );
366
+ }
367
+ function beforeUnload() {
368
+ const event = new Event('beforeunload', { cancelable: true });
369
+ window.dispatchEvent(event);
370
+ return event.defaultPrevented;
371
+ }
372
+
373
+ it.each(['breadcrumb', 'back'])(
374
+ 'protects a nested hidden draft during a pending permission request: %s',
375
+ async (destination) => {
376
+ const pending = Promise.withResolvers<void>();
377
+ let hold = false;
378
+ const view = await renderAuthenticatedClient(nestedTree(), {
379
+ route: '/settings',
380
+ beforeRequest: (request) =>
381
+ hold && request.url.endsWith('/authorization/permissions')
382
+ ? pending.promise
383
+ : undefined,
384
+ });
385
+ const user = userEvent.setup();
386
+ await user.click(screen.getByRole('link', { name: 'Edit role' }));
387
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
388
+ await user.type(draft, 'Nested unsaved draft');
389
+ hold = true;
390
+ act(() =>
391
+ view.app.container.resolve(authorizationClientToken).invalidate(),
392
+ );
393
+ try {
394
+ await waitFor(() => expect(draft).not.toBeVisible());
395
+ expect(beforeUnload()).toBe(true);
396
+ const leave = async () => {
397
+ await user.click(
398
+ destination === 'breadcrumb'
399
+ ? screen.getByRole('link', { name: 'System settings' })
400
+ : screen.getByRole('button', { name: 'Browser back' }),
401
+ );
402
+ expect(await screen.findByRole('alertdialog')).toBeVisible();
403
+ };
404
+ await leave();
405
+ await user.click(
406
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
407
+ );
408
+ expect(
409
+ screen.getByRole('status', { name: 'Current URL' }),
410
+ ).toHaveTextContent('/settings/roles/test/edit');
411
+ expect(draft).toHaveValue('Nested unsaved draft');
412
+ expect(beforeUnload()).toBe(true);
413
+ await leave();
414
+ await user.click(
415
+ screen.getByRole('button', { name: 'Confirm', exact: true }),
416
+ );
417
+ expect(
418
+ await screen.findByRole('heading', { name: 'Settings destination' }),
419
+ ).toBeVisible();
420
+ expect(
421
+ screen.getByRole('status', { name: 'Current URL' }),
422
+ ).toHaveTextContent(/^\/settings$/);
423
+ await waitFor(() => expect(beforeUnload()).toBe(false));
424
+ } finally {
425
+ hold = false;
426
+ pending.resolve();
427
+ }
428
+ },
429
+ );
430
+
431
+ it('restores the draft and restarts only the mounted data effect after permission revalidation', async () => {
432
+ const requests: string[] = [];
433
+ const pending = Promise.withResolvers<void>();
434
+ let hold = false;
435
+ const view = await renderAuthenticatedClient(nestedTree(), {
436
+ route: '/settings/roles/test/edit',
437
+ beforeRequest(request) {
438
+ requests.push(new URL(request.url).pathname);
439
+ if (hold && request.url.endsWith('/authorization/permissions'))
440
+ return pending.promise;
441
+ },
442
+ });
443
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
444
+ await screen.findByText('Ready', { selector: '[aria-label="Loaded users"]' });
445
+ await userEvent.type(draft, 'Retained after cancel');
446
+ const count = (suffix: string) =>
447
+ requests.filter((path) => path.endsWith(suffix)).length;
448
+ const permissions = count('/authorization/permissions');
449
+ const users = count('/users');
450
+ hold = true;
451
+ act(() => view.app.container.resolve(authorizationClientToken).invalidate());
452
+ try {
453
+ await waitFor(() => expect(draft).not.toBeVisible());
454
+ expect(count('/users')).toBe(users);
455
+ await userEvent.click(
456
+ screen.getByRole('link', { name: 'System settings' }),
457
+ );
458
+ await userEvent.click(
459
+ await screen.findByRole('button', { name: 'Cancel', exact: true }),
460
+ );
461
+ } finally {
462
+ hold = false;
463
+ pending.resolve();
464
+ }
465
+ await waitFor(() => expect(draft).toBeVisible());
466
+ expect(screen.getByRole('textbox', { name: 'Draft' })).toBe(draft);
467
+ expect(draft).toHaveValue('Retained after cancel');
468
+ await waitFor(() => expect(count('/users')).toBe(users + 1));
469
+ expect(count('/authorization/permissions')).toBe(permissions + 1);
470
+ expect(beforeUnload()).toBe(true);
471
+ });
472
+
473
+ it('drops a hidden draft and its shell guard when the final permission result denies access', async () => {
474
+ const pending = Promise.withResolvers<void>();
475
+ let hold = false;
476
+ const view = await renderAuthenticatedClient(nestedTree(), {
477
+ route: '/settings/roles/test/edit',
478
+ member: true,
479
+ grants: [
480
+ {
481
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
482
+ actions: [{ action: 'read' }],
483
+ },
484
+ ],
485
+ beforeRequest: (request) =>
486
+ hold && request.url.endsWith('/authorization/permissions')
487
+ ? pending.promise
488
+ : undefined,
489
+ });
490
+ const draft = await screen.findByRole('textbox', { name: 'Draft' });
491
+ await userEvent.type(draft, 'Must be discarded on denial');
492
+ hold = true;
493
+ act(() => view.app.container.resolve(authorizationClientToken).invalidate());
494
+ try {
495
+ await waitFor(() => expect(draft).not.toBeVisible());
496
+ expect(beforeUnload()).toBe(true);
497
+ await view.server.application.container
498
+ .resolve(authorizationToken)
499
+ .permissionSets.update('test-access', { key: 'test-access', grants: [] });
500
+ } finally {
501
+ hold = false;
502
+ pending.resolve();
503
+ }
504
+ expect(await screen.findByText('Access denied')).toBeVisible();
505
+ expect(draft).not.toBeInTheDocument();
506
+ await waitFor(() => expect(beforeUnload()).toBe(false));
507
+ await userEvent.click(screen.getByRole('link', { name: 'System settings' }));
508
+ expect(
509
+ await screen.findByRole('heading', { name: 'Settings destination' }),
510
+ ).toBeVisible();
511
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
512
+ });
@@ -0,0 +1,143 @@
1
+ import {
2
+ AuthenticationProvider,
3
+ GuestAuthentication,
4
+ authenticationClientToken,
5
+ } from '@nocobase/app-plugin-authentication/client';
6
+ import { authorizationClientToken } from '@nocobase/app-plugin-authorization/client';
7
+ import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
8
+ import { renderWithApp } from '@nocobase/app-testing/client';
9
+ import { act, fireEvent, screen } from '@testing-library/react';
10
+ import { Route, Routes } from 'react-router';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import enUS from '../../client/locales/en-US.js';
13
+ import zhCN from '../../client/locales/zh-CN.js';
14
+ import RegisterPage from '../../client/pages/auth/register.js';
15
+
16
+ const AuthorizationProvider = reactProviders[0]!.component;
17
+
18
+ async function renderRegistration(signedIn = false, locale = 'en-US') {
19
+ let registered = false;
20
+ const refresh = Promise.withResolvers<void>();
21
+ const email = vi.fn(async () => {
22
+ registered = true;
23
+ return { token: null, user: { id: 'untrusted-id' } };
24
+ });
25
+ const getSession = vi.fn(async () => {
26
+ if (registered) await refresh.promise;
27
+ return {
28
+ data:
29
+ registered && signedIn
30
+ ? { user: { id: 'real-user' }, session: { id: 'real-session' } }
31
+ : null,
32
+ };
33
+ });
34
+ const view = await renderWithApp(
35
+ <AuthenticationProvider>
36
+ <AuthorizationProvider>
37
+ <Routes>
38
+ <Route
39
+ path='/register'
40
+ element={
41
+ <GuestAuthentication>
42
+ <RegisterPage />
43
+ </GuestAuthentication>
44
+ }
45
+ />
46
+ <Route path='/' element={<p>Signed-in home</p>} />
47
+ </Routes>
48
+ </AuthorizationProvider>
49
+ </AuthenticationProvider>,
50
+ {
51
+ route: '/register',
52
+ locale,
53
+ namespace: 'registration-test',
54
+ namespaces: { 'registration-test': locale === 'en-US' ? enUS : zhCN },
55
+ services: (app) => {
56
+ app.container.instance(authorizationClientToken, {
57
+ resetSession: vi.fn(),
58
+ });
59
+ app.container.instance(authenticationClientToken, {
60
+ getSession,
61
+ signUp: { email },
62
+ });
63
+ },
64
+ },
65
+ );
66
+ await screen.findByRole('heading', {
67
+ name: locale === 'en-US' ? 'Create an account' : '创建账户',
68
+ });
69
+ return { ...view, refresh, email };
70
+ }
71
+
72
+ function submitForm(container: HTMLElement, locale = 'en-US') {
73
+ const labels =
74
+ locale === 'en-US'
75
+ ? ['Name', 'Username', 'Email', 'Password', 'Confirm password']
76
+ : ['姓名', '用户名', '邮箱', '密码', '确认密码'];
77
+ const values = [
78
+ 'Person',
79
+ 'person',
80
+ 'person@example.test',
81
+ 'test-password',
82
+ 'test-password',
83
+ ];
84
+ labels.forEach((label, index) =>
85
+ fireEvent.change(screen.getByLabelText(label), {
86
+ target: { value: values[index] },
87
+ }),
88
+ );
89
+ fireEvent.submit(container.querySelector('form')!);
90
+ }
91
+
92
+ describe('registration feedback with the real session provider and guest guard', () => {
93
+ it.each(['en-US', 'zh-CN'])(
94
+ 'keeps accepted unsigned-in feedback after session refresh in %s',
95
+ async (locale) => {
96
+ const { container, refresh, email } = await renderRegistration(
97
+ false,
98
+ locale,
99
+ );
100
+ submitForm(container, locale);
101
+ try {
102
+ await vi.waitFor(() => expect(email).toHaveBeenCalledOnce());
103
+ expect(container.querySelector('form')).toBeInTheDocument();
104
+ } finally {
105
+ await act(async () => {
106
+ refresh.resolve();
107
+ });
108
+ }
109
+ const status = await screen.findByRole('status');
110
+ expect(status).toHaveTextContent(
111
+ locale === 'en-US'
112
+ ? 'the new details will not take effect'
113
+ : '新填写的信息不会生效',
114
+ );
115
+ expect(container.querySelector('form')).not.toBeInTheDocument();
116
+ expect(
117
+ screen.getByRole('link', {
118
+ name: locale === 'en-US' ? 'Sign in' : '登录',
119
+ }),
120
+ ).toHaveAttribute('href', '/login');
121
+ expect(
122
+ screen.getByRole('link', {
123
+ name: locale === 'en-US' ? 'Forgot password?' : '忘记密码?',
124
+ }),
125
+ ).toHaveAttribute('href', '/forgot-password');
126
+ expect(status).not.toHaveTextContent(
127
+ /account (?:created|has been created)|email (?:sent|has been sent)|账户已创建|邮件已发送/i,
128
+ );
129
+ },
130
+ );
131
+
132
+ it('uses the refreshed session to leave the guest page', async () => {
133
+ const { container, refresh } = await renderRegistration(true);
134
+ submitForm(container);
135
+ await act(async () => {
136
+ refresh.resolve();
137
+ });
138
+ expect(await screen.findByText('Signed-in home')).toBeVisible();
139
+ expect(
140
+ screen.queryByText('Registration request submitted'),
141
+ ).not.toBeInTheDocument();
142
+ });
143
+ });