@nocobase/app-template-default 1.0.0-beta.79 → 1.0.0-beta.81

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 (89) hide show
  1. package/AGENTS.md +4 -4
  2. package/README.MD +4 -2
  3. package/client/locales/system/settings-en-US.ts +3 -0
  4. package/client/locales/system/settings-zh-CN.ts +2 -0
  5. package/client/pages/settings/user-form.tsx +13 -1
  6. package/client/pages/settings/users.tsx +1 -1
  7. package/package.json +8 -7
  8. package/tests/client/account-permissions.test.tsx +174 -0
  9. package/tests/{components → client}/app-layout-routes.test.tsx +25 -42
  10. package/tests/{logic → client}/app-locale-coverage.test.ts +1 -4
  11. package/tests/{logic → client}/client-plugin-registry.test.ts +1 -4
  12. package/tests/{logic → client}/client-route-recovery.test.tsx +27 -7
  13. package/tests/{components → client}/header-hover.test.tsx +4 -14
  14. package/tests/{components → client}/home.test.tsx +27 -41
  15. package/tests/client/inbox.test.tsx +94 -0
  16. package/tests/{components → client}/permission-management-tabs.test.tsx +23 -44
  17. package/tests/client/sidebar-permissions.test.tsx +157 -0
  18. package/tests/{logic → client}/theme-tokens.test.ts +6 -8
  19. package/tests/{components → client}/user-drawer.test.tsx +48 -0
  20. package/tests/client/user-menu-sign-out.test.tsx +78 -0
  21. package/tests/{logic → database}/device-code-migration.test.ts +0 -1
  22. package/tests/helpers/authenticated-client.tsx +148 -0
  23. package/tests/{logic → project}/agents-md.test.ts +0 -2
  24. package/tests/{logic → project}/asset-url-build.test.ts +0 -1
  25. package/tests/{logic → project}/config-example.test.ts +0 -2
  26. package/tests/{logic → project}/dev-client-watch.test.ts +0 -2
  27. package/tests/{logic → project}/plugin-commands.test.ts +0 -2
  28. package/tests/{logic → project}/server-imports.test.ts +0 -1
  29. package/tests/{logic → project}/tailwind-sources.test.ts +0 -1
  30. package/tests/{logic → project}/template-package-name.test.ts +0 -2
  31. package/tests/{logic → server}/app-server.test.ts +0 -1
  32. package/tests/{logic → server}/authorization-assignment-safety.test.ts +10 -11
  33. package/tests/{logic → server}/config.test.ts +0 -2
  34. package/tests/server/member-role.test.ts +299 -0
  35. package/tests/{logic → server}/session-isolation.test.ts +0 -2
  36. package/tests/setup/application-environment.ts +8 -0
  37. package/tests/setup/client-config.ts +0 -2
  38. package/vitest.config.ts +38 -23
  39. package/tests/components/inbox.test.tsx +0 -124
  40. package/tests/components/sidebar-permissions.test.tsx +0 -108
  41. package/tests/logic/account-permissions.test.tsx +0 -204
  42. package/tests/logic/user-menu-sign-out.test.tsx +0 -107
  43. /package/tests/{components → client}/app-title.test.tsx +0 -0
  44. /package/tests/{components → client}/auth-i18n.test.tsx +0 -0
  45. /package/tests/{components → client}/back-button.test.tsx +0 -0
  46. /package/tests/{logic → client}/client-auth.test.tsx +0 -0
  47. /package/tests/{logic → client}/client-routes.test.ts +0 -0
  48. /package/tests/{logic → client}/client-runtime.test.ts +0 -0
  49. /package/tests/{logic → client}/client-shell.test.tsx +0 -0
  50. /package/tests/{logic → client}/client-theme.test.tsx +0 -0
  51. /package/tests/{components → client}/data-table.test.tsx +0 -0
  52. /package/tests/{components → client}/default-access-editor.test.tsx +0 -0
  53. /package/tests/{logic → client}/default-access-model.test.ts +0 -0
  54. /package/tests/{components → client}/default-access.test.tsx +0 -0
  55. /package/tests/{components → client}/file-preview.test.tsx +0 -0
  56. /package/tests/{logic → client}/foundation-integration.test.ts +0 -0
  57. /package/tests/{components → client}/language-switcher.test.tsx +0 -0
  58. /package/tests/{components → client}/layout-containers.test.tsx +0 -0
  59. /package/tests/{components → client}/layout-i18n.test.tsx +0 -0
  60. /package/tests/{components → client}/loading.test.tsx +0 -0
  61. /package/tests/{components → client}/navigation-menu.test.tsx +0 -0
  62. /package/tests/{components → client}/page-harness.test.tsx +0 -0
  63. /package/tests/{logic → client}/page-permission-navigation.test.ts +0 -0
  64. /package/tests/{logic → client}/page-permission-titles.test.ts +0 -0
  65. /package/tests/{components → client}/permission-editor.test.tsx +0 -0
  66. /package/tests/{components → client}/permission-filter-binding.test.tsx +0 -0
  67. /package/tests/{components → client}/permission-filter.test.tsx +0 -0
  68. /package/tests/{logic → client}/permission-management-routes.test.ts +0 -0
  69. /package/tests/{logic → client}/permission-workspace.test.ts +0 -0
  70. /package/tests/{components → client}/primitive-labels.test.tsx +0 -0
  71. /package/tests/{logic → client}/return-to.test.ts +0 -0
  72. /package/tests/{components → client}/role-list.test.tsx +0 -0
  73. /package/tests/{components → client}/roles.test.tsx +0 -0
  74. /package/tests/{logic → client}/route-navigation.test.ts +0 -0
  75. /package/tests/{logic → client}/route-overlay.test.tsx +0 -0
  76. /package/tests/{logic → client}/scrollbar.test.ts +0 -0
  77. /package/tests/{logic → client}/session-preparation.test.tsx +0 -0
  78. /package/tests/{components → client}/settings-continuity.test.tsx +0 -0
  79. /package/tests/{components → client}/settings-feedback.test.tsx +0 -0
  80. /package/tests/{logic → client}/settings-permissions.test.ts +0 -0
  81. /package/tests/{components → client}/settings.test.tsx +0 -0
  82. /package/tests/{components → client}/setup-readiness.test.tsx +0 -0
  83. /package/tests/{components → client}/sidebar-preference.test.tsx +0 -0
  84. /package/tests/{components → client}/status-badge.test.tsx +0 -0
  85. /package/tests/{components → client}/table-url-state.test.tsx +0 -0
  86. /package/tests/{logic → client}/theme-preferences.test.ts +0 -0
  87. /package/tests/{logic → client}/toaster.test.tsx +0 -0
  88. /package/tests/{components → client}/user-permissions.test.tsx +0 -0
  89. /package/tests/{logic → server}/lifecycle.test.ts +0 -0
@@ -1,124 +0,0 @@
1
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
- import { I18nRuntime } from '@nocobase/i18n';
3
- import { I18nProvider } from '@nocobase/i18n/client';
4
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
- import type { ReactElement } from 'react';
6
- import { MemoryRouter } from 'react-router';
7
- import { beforeEach, expect, it, vi } from 'vitest';
8
-
9
- import locales from '../../client/locales/index.js';
10
- import { TooltipProvider } from '../../client/components/ui/tooltip';
11
-
12
- const mocks = vi.hoisted(() => ({
13
- session: { user: { id: 'user-1' } } as { user: { id: string } } | null,
14
- unread: 2,
15
- request: vi.fn(),
16
- }));
17
- vi.mock('@nocobase/app-plugin-authentication/client', () => ({
18
- useAuthentication: () => ({ session: mocks.session, isPending: false }),
19
- }));
20
- vi.mock('@nocobase/app-client', async (importOriginal) => {
21
- const actual = await importOriginal<typeof import('@nocobase/app-client')>();
22
- const client = { request: mocks.request };
23
- const realtime = {
24
- watch: () => ({ close: vi.fn() }),
25
- subscribe: () => vi.fn(),
26
- onOpen: () => vi.fn(),
27
- };
28
- return {
29
- ...actual,
30
- useApiClient: () => client,
31
- useService: (token: unknown) =>
32
- token === actual.realtimeClientToken ? realtime : client,
33
- };
34
- });
35
-
36
- import InboxPage from '../../client/pages/inbox.tsx';
37
-
38
- const messages = [
39
- {
40
- id: 'm1',
41
- deliveryId: 'd1',
42
- notificationId: 'n1',
43
- title: 'Weekly report is ready',
44
- body: 'The sales report for week 40 is ready.',
45
- createdAt: '2026-10-01T08:00:00.000Z',
46
- },
47
- {
48
- id: 'm2',
49
- deliveryId: 'd2',
50
- notificationId: 'n2',
51
- title: 'Backup finished',
52
- body: 'All databases were backed up.',
53
- readAt: '2026-10-01T09:00:00.000Z',
54
- createdAt: '2026-10-01T07:00:00.000Z',
55
- },
56
- ];
57
-
58
- let runtime: I18nRuntime;
59
- beforeEach(async () => {
60
- mocks.session = { user: { id: 'user-1' } };
61
- mocks.unread = 2;
62
- mocks.request.mockReset();
63
- mocks.request.mockImplementation(
64
- async ({ path }: { readonly path: string }) => {
65
- if (path.endsWith('/unreadCount'))
66
- return { data: { count: mocks.unread } };
67
- if (path.startsWith('notificationInApp/messages?'))
68
- return { data: messages, meta: {} };
69
- return { data: { ...messages[0], readAt: '2026-10-01T10:00:00.000Z' } };
70
- },
71
- );
72
- runtime = new I18nRuntime({
73
- defaultLocale: 'en-US',
74
- locales: ['en-US', 'zh-CN'],
75
- applicationNamespace: 'test-app',
76
- });
77
- runtime.registerApplicationNamespace('test-app', locales);
78
- await runtime.init('en-US');
79
- });
80
-
81
- function Frame({
82
- children,
83
- path,
84
- }: {
85
- readonly children: ReactElement;
86
- readonly path: string;
87
- }): ReactElement {
88
- return (
89
- <QueryClientProvider
90
- client={
91
- new QueryClient({ defaultOptions: { queries: { retry: false } } })
92
- }
93
- >
94
- <MemoryRouter initialEntries={[path]}>
95
- <I18nProvider runtime={runtime}>
96
- <TooltipProvider>{children}</TooltipProvider>
97
- </I18nProvider>
98
- </MemoryRouter>
99
- </QueryClientProvider>
100
- );
101
- }
102
-
103
- it("lists the plugin's messages as notifications and marks one read when it is opened", async () => {
104
- render(
105
- <Frame path='/inbox'>
106
- <InboxPage />
107
- </Frame>,
108
- );
109
- expect(
110
- await screen.findByRole('heading', { name: /Notifications/u }),
111
- ).toBeInTheDocument();
112
- expect(screen.getByText('Backup finished')).toBeInTheDocument();
113
- // The first message is shown, and opening it marks it read.
114
- expect(
115
- screen.getByRole('heading', { name: 'Weekly report is ready', level: 2 }),
116
- ).toBeInTheDocument();
117
- fireEvent.click(screen.getAllByText('Weekly report is ready')[0]!);
118
- await waitFor(() =>
119
- expect(mocks.request).toHaveBeenCalledWith({
120
- path: 'notificationInApp/messages/m1/markRead',
121
- method: 'POST',
122
- }),
123
- );
124
- });
@@ -1,108 +0,0 @@
1
- import {
2
- createAppSidebarPreferenceStore,
3
- SidebarPreferenceProvider,
4
- } from '../../client/layouts/use-sidebar-preference.js';
5
- import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
6
- import { act, render, screen, waitFor } from '@testing-library/react';
7
- import { MemoryRouter } from 'react-router';
8
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
- import {
10
- useRouteNavigation,
11
- routeKey,
12
- } from '../../client/routing/route-navigation';
13
- import { NavigationMenu } from '../../client/layouts/components/navigation-menu';
14
- import { AppSidebarProvider } from '../../client/layouts/components/app-sidebar';
15
-
16
- const sidebarPreference = createAppSidebarPreferenceStore('/main');
17
-
18
- const auth = vi.hoisted(() => ({ revision: 0, client: { can: vi.fn() } }));
19
- vi.mock('@nocobase/app-plugin-authorization/client', () => ({
20
- useAuthorizationClient: () => auth.client,
21
- useAuthorizationRevision: () => auth.revision,
22
- }));
23
- vi.mock('@nocobase/i18n/client', () => ({
24
- useTranslation: () => ({ t: (key: string) => key }),
25
- }));
26
- beforeEach(() => {
27
- auth.revision = 0;
28
- localStorage.clear();
29
- vi.stubGlobal('matchMedia', () => ({
30
- matches: false,
31
- addEventListener: vi.fn(),
32
- removeEventListener: vi.fn(),
33
- }));
34
- });
35
- afterEach(() => vi.unstubAllGlobals());
36
- function page(name: string): AppClientRegisteredRoute {
37
- return {
38
- id: name,
39
- name,
40
- path: `/${name}`,
41
- packageName: 'test',
42
- source: 'application',
43
- auth: 'required',
44
- authz: { resource: { type: 'page', id: name }, action: 'access' },
45
- navigation: { title: name },
46
- componentLoader: async () => ({ default: () => null }),
47
- };
48
- }
49
- const visible = page('Allowed');
50
- const hidden = page('Denied');
51
- const routes: AppClientRegisteredRoute[] = [
52
- { ...page('Group'), componentLoader: undefined, children: [visible, hidden] },
53
- { ...page('Empty'), componentLoader: undefined, children: [hidden] },
54
- ];
55
- function Menu() {
56
- const { items } = useRouteNavigation(routes);
57
- return (
58
- <NavigationMenu
59
- items={items}
60
- label='Navigation'
61
- selectedKey={routeKey(visible)}
62
- />
63
- );
64
- }
65
- describe.each(['expanded', 'collapsed', 'mobile'])(
66
- 'permissions in %s navigation',
67
- (mode) => {
68
- it('hides pending and denied entries, removes empty groups, and refreshes open menus', async () => {
69
- if (mode === 'mobile') vi.stubGlobal('innerWidth', 390);
70
- if (mode === 'collapsed')
71
- localStorage.setItem('nocobase:sidebar:collapsed:app:/main', 'true');
72
- const resolve: ((allowed: boolean) => void)[] = [];
73
- auth.client.can.mockImplementation(
74
- () => new Promise<boolean>((done) => resolve.push(done)),
75
- );
76
- const tree = () => (
77
- <MemoryRouter>
78
- <SidebarPreferenceProvider value={sidebarPreference}>
79
- <AppSidebarProvider>
80
- <Menu />
81
- </AppSidebarProvider>
82
- </SidebarPreferenceProvider>
83
- </MemoryRouter>
84
- );
85
- const { rerender } = render(tree());
86
- expect(screen.queryByText('Group')).not.toBeInTheDocument();
87
- await act(async () => {
88
- resolve.forEach((done, i) => done(i === 0));
89
- });
90
- // Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
91
- expect(
92
- await screen.findByRole('link', { name: 'Allowed' }),
93
- ).toHaveAttribute('href', '/Allowed');
94
- expect(screen.queryByText('Denied')).not.toBeInTheDocument();
95
- expect(screen.queryByText('Empty')).not.toBeInTheDocument();
96
- auth.client.can.mockResolvedValue(false);
97
- auth.revision++;
98
- rerender(tree());
99
- await waitFor(() =>
100
- expect(screen.queryByText('Group')).not.toBeInTheDocument(),
101
- );
102
- expect(
103
- screen.queryByRole('link', { name: 'Allowed' }),
104
- ).not.toBeInTheDocument();
105
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
106
- });
107
- },
108
- );
@@ -1,204 +0,0 @@
1
- import {
2
- ClientApplicationContext,
3
- type ClientApplication,
4
- } from '@nocobase/app-client';
5
- import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
6
- import {
7
- AuthorizationClient,
8
- authorizationClientToken,
9
- } from '@nocobase/app-plugin-authorization/client';
10
- import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
11
- import { ServiceContainer } from '@nocobase/service-provider';
12
- import {
13
- TestI18nProvider,
14
- createTestI18nRuntime,
15
- } from '@nocobase/i18n/testing';
16
- import { act, render, screen, waitFor } from '@testing-library/react';
17
- import type { ReactNode } from 'react';
18
- import { MemoryRouter } from 'react-router';
19
- import { beforeEach, describe, expect, it, vi } from 'vitest';
20
- import { ClientRoute } from '../../client/routing/client-route.js';
21
- import { useRouteNavigation } from '../../client/routing/route-navigation.js';
22
- import enUS from '../../client/locales/en-US.js';
23
-
24
- const AuthorizationProvider = reactProviders[0].component;
25
-
26
- const runtime = await createTestI18nRuntime({
27
- application: { namespace: '@nocobase/app-template-default', resources: enUS },
28
- });
29
-
30
- function I18n({ children }: { readonly children: ReactNode }) {
31
- return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
32
- }
33
-
34
- const authentication = vi.hoisted(() => ({
35
- session: null as null | { user: { id: string }; session: { id: string } },
36
- isPending: false,
37
- }));
38
- vi.mock('@nocobase/app-plugin-authentication/client', () => ({
39
- useAuthentication: () => authentication,
40
- }));
41
- const routes: AppClientRegisteredRoute[] = ['apps', 'users'].map((name) => ({
42
- id: name,
43
- name,
44
- path: `/${name}`,
45
- auth: 'required',
46
- authz: { resource: { type: 'page', id: name }, action: 'access' },
47
- packageName: 'test',
48
- source: 'application',
49
- navigation: { title: name },
50
- componentLoader: async () => ({ default: () => <div>{name} content</div> }),
51
- }));
52
- const permissions = (admin: boolean) => ({
53
- data: {
54
- permissions: (admin ? ['apps', 'users'] : ['apps']).map((id) => ({
55
- resource: { type: 'page', id },
56
- actions: ['access'],
57
- })),
58
- },
59
- });
60
- function Menu() {
61
- const { items, loading } = useRouteNavigation(routes);
62
- return (
63
- <nav aria-label='Pages'>
64
- {loading
65
- ? 'Checking'
66
- : items.map(({ route }) => <span key={route.id}>{route.name}</span>)}
67
- </nav>
68
- );
69
- }
70
- function session(user: string, id = user) {
71
- authentication.session = { user: { id: user }, session: { id } };
72
- }
73
- function setup(
74
- request = vi
75
- .fn()
76
- .mockImplementation(() =>
77
- Promise.resolve(permissions(authentication.session?.user.id === 'admin')),
78
- ),
79
- ) {
80
- const client = new AuthorizationClient({ request } as never);
81
- const container = new ServiceContainer();
82
- container.instance(authorizationClientToken, client);
83
- const app = { services: container } as unknown as ClientApplication;
84
- const tree = () => (
85
- <ClientApplicationContext.Provider value={app}>
86
- <MemoryRouter>
87
- <AuthorizationProvider>
88
- <Menu />
89
- <ClientRoute route={routes[1]} />
90
- </AuthorizationProvider>
91
- </MemoryRouter>
92
- </ClientApplicationContext.Provider>
93
- );
94
- const view = render(tree(), { wrapper: I18n });
95
- return { client, request, rerender: () => view.rerender(tree()) };
96
- }
97
- async function expectMenu(admin: boolean) {
98
- await waitFor(() =>
99
- expect(screen.getByRole('navigation', { name: 'Pages' }).textContent).toBe(
100
- admin ? 'appsusers' : 'apps',
101
- ),
102
- );
103
- if (admin)
104
- await waitFor(() =>
105
- expect(screen.getByText('users content')).toBeInTheDocument(),
106
- );
107
- else {
108
- await waitFor(() =>
109
- expect(screen.getByText('Access denied')).toBeInTheDocument(),
110
- );
111
- expect(screen.queryByText('users content')).not.toBeInTheDocument();
112
- }
113
- }
114
-
115
- describe('account permission changes without a browser reload', () => {
116
- beforeEach(() => {
117
- authentication.session = null;
118
- authentication.isPending = false;
119
- });
120
-
121
- it('updates menus and cached route guards from admin to operator and back', async () => {
122
- session('admin');
123
- const view = setup();
124
- await expectMenu(true);
125
- session('operator');
126
- view.rerender();
127
- expect(screen.queryByText('users content')).not.toBeInTheDocument();
128
- await expectMenu(false);
129
- session('admin');
130
- view.rerender();
131
- await expectMenu(true);
132
- });
133
-
134
- it('clears permissions on logout and same-user re-login with a new session', async () => {
135
- session('admin', 'first');
136
- const view = setup();
137
- await expectMenu(true);
138
- authentication.isPending = true;
139
- view.rerender();
140
- expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
141
- authentication.session = null;
142
- authentication.isPending = false;
143
- view.rerender();
144
- await expectMenu(false);
145
- view.request.mockResolvedValue(permissions(false));
146
- session('admin', 'second');
147
- view.rerender();
148
- await expectMenu(false);
149
- expect(view.request).toHaveBeenCalledTimes(3);
150
- });
151
-
152
- it('updates a mounted menu and page when permissions change', async () => {
153
- session('admin');
154
- const view = setup();
155
- await expectMenu(true);
156
- view.request.mockResolvedValue(permissions(false));
157
- act(() => view.client.invalidate());
158
- expect(screen.queryByText('users content')).not.toBeInTheDocument();
159
- await expectMenu(false);
160
- view.request.mockResolvedValue(permissions(true));
161
- act(() => view.client.invalidate());
162
- await expectMenu(true);
163
- });
164
-
165
- it('hides previously allowed pages when revalidation fails and recovers on retry', async () => {
166
- session('admin');
167
- const view = setup();
168
- await expectMenu(true);
169
- view.request.mockRejectedValue(new Error('Offline'));
170
- act(() => view.client.invalidate());
171
- await waitFor(() =>
172
- expect(
173
- screen.getByRole('navigation', { name: 'Pages' }).textContent,
174
- ).toBe(''),
175
- );
176
- await waitFor(() =>
177
- expect(screen.getByText('Access denied')).toBeInTheDocument(),
178
- );
179
- expect(screen.queryByText('users content')).not.toBeInTheDocument();
180
- view.request.mockResolvedValue(permissions(true));
181
- act(() => view.client.invalidate());
182
- await expectMenu(true);
183
- });
184
-
185
- it('does not restore admin menus when an old request returns after switching accounts', async () => {
186
- const old = Promise.withResolvers<ReturnType<typeof permissions>>();
187
- const request = vi
188
- .fn()
189
- .mockReturnValueOnce(old.promise)
190
- .mockResolvedValue(permissions(false));
191
- session('admin');
192
- const view = setup(request);
193
- await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
194
- session('operator');
195
- view.rerender();
196
- await expectMenu(false);
197
- await act(async () => {
198
- old.resolve(permissions(true));
199
- await old.promise;
200
- });
201
- await expectMenu(false);
202
- expect(request).toHaveBeenCalledTimes(2);
203
- });
204
- });
@@ -1,107 +0,0 @@
1
- import {
2
- act,
3
- fireEvent,
4
- render,
5
- screen,
6
- waitFor,
7
- } from '@testing-library/react';
8
- import userEvent from '@testing-library/user-event';
9
- import { beforeEach, describe, expect, it, vi } from 'vitest';
10
- import {
11
- TestI18nProvider,
12
- createTestI18nRuntime,
13
- } from '@nocobase/i18n/testing';
14
- import type { ReactNode } from 'react';
15
- import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
16
- import enUS from '../../client/locales/en-US.js';
17
-
18
- const runtime = await createTestI18nRuntime({
19
- application: { namespace: '@nocobase/app-template-default', resources: enUS },
20
- });
21
-
22
- function I18n({ children }: { readonly children: ReactNode }) {
23
- return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
24
- }
25
-
26
- const { signOut, refresh, showToast, resolveError } = vi.hoisted(() => ({
27
- resolveError: vi.fn(),
28
- signOut: vi.fn(),
29
- refresh: vi.fn(),
30
- showToast: vi.fn(),
31
- }));
32
- vi.mock('@nocobase/app-plugin-authentication/client/actions', () => ({
33
- useAuthenticationErrorResolver: () => resolveError,
34
- }));
35
- vi.mock('@nocobase/app-client', () => {
36
- const toaster = { show: showToast, close: vi.fn() };
37
- return { useToaster: () => toaster };
38
- });
39
- vi.mock('@nocobase/app-plugin-authentication/client', () => ({
40
- useAuthentication: () => ({
41
- client: { signOut },
42
- session: {
43
- user: { id: 'operator', name: 'Operator', email: 'operator@example.com' },
44
- },
45
- isPending: false,
46
- refresh,
47
- }),
48
- }));
49
- vi.mock('../../client/layouts/components/language-switcher.js', () => ({
50
- LanguageSwitcher: () => null,
51
- }));
52
-
53
- /**
54
- * Opens the account menu from the keyboard. A pointer opens it on hover and again on the press, one frame later, which
55
- * can reopen the menu after a test has already chosen an item and closed it.
56
- */
57
- async function openAccountMenu(): Promise<void> {
58
- act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
59
- await userEvent.keyboard('{Enter}');
60
- }
61
-
62
- describe('account menu sign out', () => {
63
- beforeEach(() => {
64
- vi.resetAllMocks();
65
- refresh.mockResolvedValue(undefined);
66
- resolveError.mockReturnValue({ message: 'Localized authentication error' });
67
- });
68
- async function signOutFromMenu() {
69
- render(<UserMenu />, { wrapper: I18n });
70
- await openAccountMenu();
71
- fireEvent.click(await screen.findByRole('menuitem', { name: 'Sign out' }));
72
- }
73
- it('refreshes the server session after successful sign-out', async () => {
74
- signOut.mockResolvedValue({ data: { success: true }, error: null });
75
- await signOutFromMenu();
76
- await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
77
- expect(showToast).not.toHaveBeenCalled();
78
- });
79
- it('reports a rejected origin without pretending the session ended', async () => {
80
- signOut.mockResolvedValue({
81
- data: null,
82
- error: { status: 403, code: 'INVALID_ORIGIN' },
83
- });
84
- await signOutFromMenu();
85
- await waitFor(() =>
86
- expect(showToast).toHaveBeenCalledWith({
87
- type: 'error',
88
- title: 'Localized authentication error',
89
- }),
90
- );
91
- expect(resolveError).toHaveBeenCalledWith({
92
- status: 403,
93
- code: 'INVALID_ORIGIN',
94
- });
95
- expect(refresh).not.toHaveBeenCalled();
96
- await openAccountMenu();
97
- expect(
98
- await screen.findByRole('menuitem', { name: 'Sign out' }),
99
- ).not.toHaveAttribute('aria-disabled', 'true');
100
- });
101
- it('reports network failures and allows retry', async () => {
102
- signOut.mockRejectedValue(new Error('Network unavailable'));
103
- await signOutFromMenu();
104
- await waitFor(() => expect(showToast).toHaveBeenCalledTimes(1));
105
- expect(refresh).not.toHaveBeenCalled();
106
- });
107
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes