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

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.
@@ -16,10 +16,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
16
 
17
17
  import {
18
18
  AppThemeProvider,
19
- ThemeSettings,
19
+ ThemeSettings as ThemeMenu,
20
20
  useTheme,
21
21
  } from '../../client/theme/index.ts';
22
22
 
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuTrigger,
27
+ } from '../../client/components/ui/dropdown-menu';
28
+
29
+ function ThemeSettings(): ReactElement {
30
+ return (
31
+ <DropdownMenu>
32
+ <DropdownMenuTrigger>Account</DropdownMenuTrigger>
33
+ <DropdownMenuContent>
34
+ <ThemeMenu />
35
+ </DropdownMenuContent>
36
+ </DropdownMenu>
37
+ );
38
+ }
39
+
23
40
  /**
24
41
  * Renders the client configuration the server would, mounted at `/crm` so the theme's storage keys are
25
42
  * `nocobase:crm:*`. It replaces any block already on the page, since the client reads the first one it finds.
@@ -38,19 +55,65 @@ function renderClientConfig(
38
55
  document.head.append(element);
39
56
  }
40
57
 
41
- /**
42
- * Opens the appearance popover from the keyboard. Its trigger opens on hover, and a pointer press landing more than
43
- * Base UI's 500 ms patient-click threshold after that, as on a loaded runner, closes it again.
44
- */
45
- async function openAppearance(name = 'Appearance'): Promise<void> {
46
- act(() => screen.getByRole('button', { name }).focus());
58
+ /** Opens the account menu and its Theme submenu using the keyboard. */
59
+ async function openTheme(name = 'Theme'): Promise<void> {
60
+ act(() => screen.getByRole('button', { name: 'Account' }).focus());
47
61
  await userEvent.keyboard('{Enter}');
62
+ act(() => screen.getByRole('menuitem', { name }).focus());
63
+ await userEvent.keyboard('{ArrowRight}');
48
64
  }
49
65
 
50
66
  describe('app client theme', () => {
51
67
  it.each([
52
- ['en-US', 'Appearance', 'Spacious'],
53
- ['zh-CN', '外观', '宽松'],
68
+ ['Light', 'light', 'Compact', 'compact'],
69
+ ['Dark', 'dark', 'Compact', 'compact'],
70
+ ['System', 'system', 'Compact', 'compact'],
71
+ ['Light', 'light', 'Default', 'default'],
72
+ ['Dark', 'dark', 'Default', 'default'],
73
+ ['System', 'system', 'Default', 'default'],
74
+ ])(
75
+ 'saves and restores %s mode with %s / %s density',
76
+ async (modeLabel, mode, presetLabel, preset) => {
77
+ const settings = (
78
+ <AppThemeProvider>
79
+ <ThemeSettings />
80
+ </AppThemeProvider>
81
+ );
82
+ const view = render(settings);
83
+ await openTheme();
84
+ await userEvent.click(
85
+ screen.getByRole('menuitemradio', { name: modeLabel }),
86
+ );
87
+ await userEvent.click(
88
+ screen.getByRole('menuitemradio', { name: presetLabel }),
89
+ );
90
+ expect(localStorage.getItem('nocobase:crm:theme:color-scheme')).toBe(
91
+ mode,
92
+ );
93
+ expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe(preset);
94
+ view.unmount();
95
+ initializeTheme(
96
+ '/crm/',
97
+ themePresets.map(({ id }) => id),
98
+ );
99
+ render(settings);
100
+ await openTheme();
101
+ expect(document.documentElement).toHaveClass(
102
+ mode === 'light' ? 'light' : 'dark',
103
+ );
104
+ expect(document.documentElement).toHaveAttribute('data-theme', preset);
105
+ expect(
106
+ screen.getByRole('menuitemradio', { name: modeLabel }),
107
+ ).toHaveAttribute('aria-checked', 'true');
108
+ expect(
109
+ screen.getByRole('menuitemradio', { name: presetLabel }),
110
+ ).toHaveAttribute('aria-checked', 'true');
111
+ },
112
+ );
113
+
114
+ it.each([
115
+ ['en-US', 'Theme', 'Spacious'],
116
+ ['zh-CN', '主题', '宽松'],
54
117
  ])(
55
118
  'labels the default preset without implying default selection (%s)',
56
119
  async (locale, appearance, label) => {
@@ -68,28 +131,32 @@ describe('app client theme', () => {
68
131
  </AppThemeProvider>
69
132
  </I18nProvider>,
70
133
  );
71
- await openAppearance(appearance);
72
- const option = await screen.findByRole('radio', { name: label });
134
+ await openTheme(appearance);
135
+ const option = await screen.findByRole('menuitemradio', { name: label });
73
136
  await userEvent.click(option);
74
- expect(option).toBeChecked();
137
+ expect(option).toHaveAttribute('aria-checked', 'true');
75
138
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
76
139
  },
77
140
  );
78
141
 
79
- it('changes presets with arrow keys and returns focus on Escape', async () => {
142
+ it('selects a preset by keyboard and returns focus on Escape', async () => {
80
143
  render(
81
144
  <AppThemeProvider>
82
145
  <ThemeSettings />
83
146
  </AppThemeProvider>,
84
147
  );
85
- await openAppearance();
86
- const compact = await screen.findByRole('radio', { name: 'Compact' });
148
+ await openTheme();
149
+ const compact = await screen.findByRole('menuitemradio', {
150
+ name: 'Compact',
151
+ });
87
152
  act(() => compact.focus());
88
- await userEvent.keyboard('{ArrowRight}');
89
- expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
153
+ await userEvent.keyboard('{ArrowDown}{Enter}');
154
+ expect(
155
+ screen.getByRole('menuitemradio', { name: 'Default' }),
156
+ ).toHaveAttribute('aria-checked', 'true');
90
157
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('default');
91
158
  await userEvent.keyboard('{Escape}');
92
- expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus();
159
+ expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus();
93
160
  });
94
161
 
95
162
  it('falls back from the removed Ant Design preset without changing mode', async () => {
@@ -100,11 +167,13 @@ describe('app client theme', () => {
100
167
  <ThemeSettings />
101
168
  </AppThemeProvider>,
102
169
  );
103
- await openAppearance();
170
+ await openTheme();
104
171
  expect(
105
- screen.queryByRole('radio', { name: 'Ant-design' }),
172
+ screen.queryByRole('menuitemradio', { name: 'Ant-design' }),
106
173
  ).not.toBeInTheDocument();
107
- expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
174
+ expect(
175
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
176
+ ).toHaveAttribute('aria-checked', 'true');
108
177
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
109
178
  expect(document.documentElement).toHaveClass('light');
110
179
  });
@@ -115,13 +184,15 @@ describe('app client theme', () => {
115
184
  <ThemeSettings />
116
185
  </AppThemeProvider>,
117
186
  );
118
- await openAppearance();
187
+ await openTheme();
119
188
  expect(
120
- screen.queryByRole('radio', { name: 'Ocean' }),
189
+ screen.queryByRole('menuitemradio', { name: 'Ocean' }),
121
190
  ).not.toBeInTheDocument();
122
- expect(await screen.findByRole('radio', { name: 'Compact' })).toBeChecked();
123
191
  expect(
124
- await screen.findByRole('radio', { name: 'Default' }),
192
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
193
+ ).toHaveAttribute('aria-checked', 'true');
194
+ expect(
195
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
125
196
  ).toBeInTheDocument();
126
197
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
127
198
  });
@@ -218,12 +289,12 @@ describe('app client theme', () => {
218
289
  <ThemeSettings />
219
290
  </AppThemeProvider>,
220
291
  );
221
- await openAppearance();
292
+ await openTheme();
222
293
  expect(
223
- await screen.findByRole('radio', {
294
+ await screen.findByRole('menuitemradio', {
224
295
  name: expected === 'compact' ? 'Compact' : 'Default',
225
296
  }),
226
- ).toBeChecked();
297
+ ).toHaveAttribute('aria-checked', 'true');
227
298
  expect(document.documentElement).toHaveAttribute('data-theme', expected);
228
299
  expect(localStorage.getItem(key)).toBe(saved ?? null);
229
300
  },
@@ -334,14 +405,16 @@ describe('app client theme', () => {
334
405
  </AppThemeProvider>,
335
406
  );
336
407
 
337
- await openAppearance();
408
+ await openTheme();
338
409
  await userEvent.click(
339
- await screen.findByRole('radio', { name: 'Compact' }),
410
+ await screen.findByRole('menuitemradio', { name: 'Compact' }),
340
411
  );
341
412
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
342
413
  expect(document.documentElement).toHaveClass('dark');
343
414
  expect(localStorage.getItem('nocobase:crm:theme:preset')).toBe('compact');
344
- await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
415
+ await userEvent.click(
416
+ await screen.findByRole('menuitemradio', { name: 'Light' }),
417
+ );
345
418
  expect(document.documentElement).toHaveClass('light');
346
419
  expect(document.documentElement).toHaveAttribute('data-theme', 'compact');
347
420
  fireEvent(
@@ -359,10 +432,12 @@ describe('app client theme', () => {
359
432
  newValue: 'default',
360
433
  }),
361
434
  );
362
- expect(await screen.findByRole('radio', { name: 'Default' })).toBeChecked();
435
+ expect(
436
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
437
+ ).toHaveAttribute('aria-checked', 'true');
363
438
  await userEvent.keyboard('{Escape}');
364
439
  await waitFor(() =>
365
- expect(screen.getByRole('button', { name: 'Appearance' })).toHaveFocus(),
440
+ expect(screen.getByRole('menuitem', { name: 'Theme' })).toHaveFocus(),
366
441
  );
367
442
  });
368
443
 
@@ -395,11 +470,13 @@ describe('app client theme', () => {
395
470
  <ThemeSettings />
396
471
  </AppThemeProvider>,
397
472
  );
398
- await openAppearance();
473
+ await openTheme();
474
+ await userEvent.click(
475
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
476
+ );
399
477
  await userEvent.click(
400
- await screen.findByRole('radio', { name: 'Default' }),
478
+ await screen.findByRole('menuitemradio', { name: 'Light' }),
401
479
  );
402
- await userEvent.click(await screen.findByRole('radio', { name: 'Light' }));
403
480
  expect(document.documentElement).toHaveAttribute('data-theme', 'default');
404
481
  expect(document.documentElement).toHaveClass('light');
405
482
  });
@@ -410,9 +487,9 @@ describe('app client theme', () => {
410
487
  <ThemeSettings />
411
488
  </AppThemeProvider>,
412
489
  );
413
- await openAppearance();
490
+ await openTheme();
414
491
  await userEvent.click(
415
- await screen.findByRole('radio', { name: 'Default' }),
492
+ await screen.findByRole('menuitemradio', { name: 'Default' }),
416
493
  );
417
494
  fireEvent(
418
495
  window,
@@ -438,7 +515,9 @@ describe('app client theme', () => {
438
515
  }),
439
516
  );
440
517
  await waitFor(() => expect(document.documentElement).toHaveClass('dark'));
441
- expect(await screen.findByRole('radio', { name: 'System' })).toBeChecked();
518
+ expect(
519
+ await screen.findByRole('menuitemradio', { name: 'System' }),
520
+ ).toHaveAttribute('aria-checked', 'true');
442
521
  });
443
522
  });
444
523
 
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
4
  import { AppThemeProvider } from '../../client/theme/theme-provider';
5
- import { ThemeSettings } from '../../client/theme/theme-settings';
5
+ import { HeaderActions } from '../../client/layouts/components/header-actions';
6
6
  import { UserMenu } from '../../client/layouts/components/user-menu';
7
7
 
8
8
  import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
@@ -24,10 +24,7 @@ afterEach(() => {
24
24
  document.documentElement.removeAttribute('data-theme');
25
25
  });
26
26
 
27
- const panels = [
28
- ['Appearance', ThemeSettings, 'dialog'],
29
- ['Open account menu', UserMenu, 'menu'],
30
- ] as const;
27
+ const panels = [['Open account menu', UserMenu, 'menu']] as const;
31
28
 
32
29
  it.each(panels)(
33
30
  'opens and closes %s on hover',
@@ -75,3 +72,34 @@ it.each(panels)(
75
72
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
76
73
  },
77
74
  );
75
+
76
+ it('offers appearance only inside the account menu with keyboard selection', async () => {
77
+ const user = userEvent.setup();
78
+ await renderAuthenticatedClient(
79
+ <AppThemeProvider>
80
+ <HeaderActions />
81
+ </AppThemeProvider>,
82
+ );
83
+ expect(
84
+ screen.queryByRole('button', { name: 'Theme' }),
85
+ ).not.toBeInTheDocument();
86
+ await user.tab();
87
+ await user.keyboard('{Enter}');
88
+ const appearance = screen.getByRole('menuitem', { name: 'Theme' });
89
+ appearance.focus();
90
+ await user.keyboard('{ArrowRight}');
91
+ expect(screen.getAllByRole('menuitemradio')).toHaveLength(5);
92
+ await user.keyboard('{ArrowDown}{Enter}');
93
+ expect(document.documentElement).toHaveClass('dark');
94
+ expect(screen.getByRole('menuitemradio', { name: 'Dark' })).toHaveAttribute(
95
+ 'aria-checked',
96
+ 'true',
97
+ );
98
+ await user.keyboard('{Escape}');
99
+ expect(appearance).toHaveFocus();
100
+ expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeVisible();
101
+ await user.keyboard('{Escape}');
102
+ expect(
103
+ screen.getByRole('button', { name: 'Open account menu' }),
104
+ ).toHaveFocus();
105
+ });
@@ -0,0 +1,45 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { MemoryRouter, Route, Routes } from 'react-router';
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
+
5
+ import { StandalonePageLayout } from '../../client/routing/standalone-page-layout.js';
6
+ import { AppThemeProvider } from '../../client/theme/index.js';
7
+
8
+ beforeEach(() => {
9
+ // jsdom does not implement the media queries used by the real theme provider.
10
+ vi.stubGlobal('matchMedia', () => ({
11
+ matches: false,
12
+ addListener() {},
13
+ removeListener() {},
14
+ }));
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ document.documentElement.removeAttribute('class');
20
+ document.documentElement.removeAttribute('style');
21
+ document.documentElement.removeAttribute('data-theme');
22
+ });
23
+
24
+ it.each(['/login', '/device'])(
25
+ 'renders standalone content at %s without an account menu',
26
+ (path) => {
27
+ render(
28
+ <AppThemeProvider>
29
+ <MemoryRouter initialEntries={[path]}>
30
+ <Routes>
31
+ <Route element={<StandalonePageLayout />}>
32
+ <Route path={path} element={<h1>Standalone page</h1>} />
33
+ </Route>
34
+ </Routes>
35
+ </MemoryRouter>
36
+ </AppThemeProvider>,
37
+ );
38
+ expect(
39
+ screen.getByRole('heading', { name: 'Standalone page' }),
40
+ ).toBeVisible();
41
+ expect(
42
+ screen.queryByRole('button', { name: 'Theme' }),
43
+ ).not.toBeInTheDocument();
44
+ },
45
+ );
@@ -1,9 +1,19 @@
1
+ import { AppThemeProvider } from '../../client/theme/index.js';
1
2
  import { act, fireEvent, screen, waitFor } from '@testing-library/react';
2
3
  import userEvent from '@testing-library/user-event';
3
- import { describe, expect, it } from 'vitest';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { UserMenu } from '../../client/layouts/components/user-menu.tsx';
5
6
  import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
6
7
 
8
+ // jsdom has no media queries; the real account menu includes the theme provider.
9
+ beforeEach(() => {
10
+ vi.stubGlobal('matchMedia', () => ({
11
+ matches: false,
12
+ addListener() {},
13
+ removeListener() {},
14
+ }));
15
+ });
16
+
7
17
  async function openAccountMenu(): Promise<void> {
8
18
  act(() => screen.getByRole('button', { name: 'Open account menu' }).focus());
9
19
  await userEvent.keyboard('{Enter}');
@@ -15,7 +25,11 @@ async function signOutFromMenu() {
15
25
 
16
26
  describe('account menu sign out', () => {
17
27
  it('ends the server session and refreshes the displayed identity', async () => {
18
- const view = await renderAuthenticatedClient(<UserMenu />);
28
+ const view = await renderAuthenticatedClient(
29
+ <AppThemeProvider>
30
+ <UserMenu />
31
+ </AppThemeProvider>,
32
+ );
19
33
  await signOutFromMenu();
20
34
  await waitFor(() => expect(view.identity().session).toBeNull());
21
35
  expect(
@@ -26,13 +40,18 @@ describe('account menu sign out', () => {
26
40
  expect(screen.queryByText(view.session.user.email)).not.toBeInTheDocument();
27
41
  });
28
42
  it('reports a rejected origin without pretending the session ended', async () => {
29
- const view = await renderAuthenticatedClient(<UserMenu />, {
30
- beforeRequest(request) {
31
- // Exercise the real origin guard and error resolver, not a fabricated authentication result.
32
- if (request.url.endsWith('/sign-out'))
33
- request.headers.set('origin', 'https://untrusted.example');
43
+ const view = await renderAuthenticatedClient(
44
+ <AppThemeProvider>
45
+ <UserMenu />
46
+ </AppThemeProvider>,
47
+ {
48
+ beforeRequest(request) {
49
+ // Exercise the real origin guard and error resolver, not a fabricated authentication result.
50
+ if (request.url.endsWith('/sign-out'))
51
+ request.headers.set('origin', 'https://untrusted.example');
52
+ },
34
53
  },
35
- });
54
+ );
36
55
  await signOutFromMenu();
37
56
  await waitFor(() => expect(view.toasts()).toHaveLength(1));
38
57
  expect(view.toasts()[0]).toMatchObject({
@@ -50,13 +69,18 @@ describe('account menu sign out', () => {
50
69
  });
51
70
  it('reports a network failure and successfully retries sign-out', async () => {
52
71
  let offline = true;
53
- const view = await renderAuthenticatedClient(<UserMenu />, {
54
- beforeRequest(request) {
55
- // Network loss cannot be produced by the in-process server.
56
- if (offline && request.url.endsWith('/sign-out'))
57
- throw new TypeError('Failed to fetch');
72
+ const view = await renderAuthenticatedClient(
73
+ <AppThemeProvider>
74
+ <UserMenu />
75
+ </AppThemeProvider>,
76
+ {
77
+ beforeRequest(request) {
78
+ // Network loss cannot be produced by the in-process server.
79
+ if (offline && request.url.endsWith('/sign-out'))
80
+ throw new TypeError('Failed to fetch');
81
+ },
58
82
  },
59
- });
83
+ );
60
84
  await signOutFromMenu();
61
85
  await waitFor(() => expect(view.toasts()).toHaveLength(1));
62
86
  expect(view.toasts()[0]).toMatchObject({
@@ -0,0 +1,65 @@
1
+ import { AppThemeProvider } from '../../client/theme/index.js';
2
+ import { act, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { beforeEach, expect, it, vi } from 'vitest';
5
+
6
+ import { UserMenu } from '../../client/layouts/components/user-menu.js';
7
+ import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
8
+
9
+ // jsdom has no media queries; the real account menu includes the theme provider.
10
+ beforeEach(() => {
11
+ vi.stubGlobal('matchMedia', () => ({
12
+ matches: false,
13
+ addListener() {},
14
+ removeListener() {},
15
+ }));
16
+ });
17
+
18
+ it('uses CJK name endings in the avatar while retaining the full account name and picture', async () => {
19
+ const view = await renderAuthenticatedClient(
20
+ <AppThemeProvider>
21
+ <UserMenu />
22
+ </AppThemeProvider>,
23
+ );
24
+ const user = userEvent.setup();
25
+ const trigger = screen.getByRole('button', { name: 'Open account menu' });
26
+ await user.tab();
27
+ for (const [name, expected] of [
28
+ ['周建国', '建国'],
29
+ ['欧阳娜娜', '娜娜'],
30
+ ['李雷', '李雷'],
31
+ ['周 建国', '建国'],
32
+ ['Li Lei', 'LL'],
33
+ ['周', '周'],
34
+ ] as const) {
35
+ await act(async () => {
36
+ const response = await view.identity().client.updateUser({ name });
37
+ expect(response.error).toBeNull();
38
+ await view.identity().refresh();
39
+ });
40
+ expect(trigger).toHaveTextContent(expected);
41
+ await user.keyboard('{Enter}');
42
+ expect(
43
+ within(screen.getByRole('menu')).getByText(name, { exact: true }),
44
+ ).toBeVisible();
45
+ await user.keyboard('{Escape}');
46
+ }
47
+ await act(async () => {
48
+ const response = await view.identity().client.updateUser({ name: '' });
49
+ expect(response.error).toBeNull();
50
+ await view.identity().refresh();
51
+ });
52
+ expect(trigger).toHaveTextContent(view.session.user.email[0]!.toUpperCase());
53
+ await act(async () => {
54
+ const response = await view
55
+ .identity()
56
+ .client.updateUser({ image: 'https://example.test/avatar.png' });
57
+ expect(response.error).toBeNull();
58
+ await view.identity().refresh();
59
+ });
60
+ expect(trigger.querySelector('img')).toHaveAttribute(
61
+ 'src',
62
+ 'https://example.test/avatar.png',
63
+ );
64
+ expect(trigger).not.toHaveTextContent('周');
65
+ });
@@ -1,3 +1,8 @@
1
+ import {
2
+ DropdownMenu,
3
+ DropdownMenuTrigger,
4
+ DropdownMenuContent,
5
+ } from '../../client/components/ui/dropdown-menu';
1
6
  import {
2
7
  createAppSidebarPreferenceStore,
3
8
  SidebarPreferenceProvider,
@@ -35,7 +40,14 @@ export default function Fixture() {
35
40
  </AppSidebar>
36
41
  <main className='min-w-0 flex-1 space-y-6 overflow-y-auto p-6'>
37
42
  <div className='flex flex-wrap gap-2'>
38
- <ThemeSettings />
43
+ <DropdownMenu>
44
+ <DropdownMenuTrigger render={<Button />}>
45
+ Account
46
+ </DropdownMenuTrigger>
47
+ <DropdownMenuContent>
48
+ <ThemeSettings />
49
+ </DropdownMenuContent>
50
+ </DropdownMenu>
39
51
  <AppSidebarToggle />
40
52
  </div>
41
53
  <h1 className='text-3xl'>Theme tokens · 主题样式</h1>
@@ -10,6 +10,7 @@ import {
10
10
  buildVariablesManifest,
11
11
  } from '@nocobase/app-server/config';
12
12
  import auth from '../../server/config/auth.js';
13
+ import mailers from '../../server/config/mailers.js';
13
14
 
14
15
  it('inherits private authentication sender mappings without changing other mail consumers', async () => {
15
16
  const defaults = defaultAppConfigs({ auth });
@@ -50,30 +51,63 @@ it('inherits private authentication sender mappings without changing other mail
50
51
  ).toBe(true);
51
52
  });
52
53
 
53
- it('ships separate, valid SMTP and Resend authentication examples', async () => {
54
+ it('ships a valid shared mailer example with private mapped credentials', async () => {
54
55
  const example = await readFile(
55
56
  new URL('../../config.example.yml', import.meta.url),
56
57
  'utf8',
57
58
  );
58
- const blocks = [...example.matchAll(/^ {2}# email:\n(?: {2}# {3}.*\n)+/gm)];
59
- expect(blocks).toHaveLength(2);
60
- const directory = await mkdtemp(
61
- path.join(os.tmpdir(), 'auth-email-example-'),
62
- );
59
+ const block = example.match(/^# mailers:\n(?:#.*\n)+/m)?.[0];
60
+ expect(block).toBeTruthy();
61
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'mailers-example-'));
63
62
  try {
64
- for (const block of blocks) {
65
- const text = 'auth:\n' + block[0].replace(/^ {2}# /gm, ' ');
66
- const file = path.join(directory, 'config.yml');
67
- await writeFile(file, text);
68
- const config = new AppConfig().loadFile(file);
69
- await config.loadAll();
70
- const defaults = defaultAppConfigs({ auth });
71
- config.mergeDefaults(defaults({} as never));
72
- config.defineSections(defaults.sections!);
73
- expect(await config.validate()).toEqual([]);
74
- }
63
+ const file = path.join(directory, 'config.yml');
64
+ await writeFile(file, block!.replace(/^# ?/gm, ''));
65
+ const config = new AppConfig().loadFile(file);
66
+ await config.loadAll();
67
+ const defaults = defaultAppConfigs({ auth, mailers });
68
+ config.mergeDefaults(defaults({} as never));
69
+ config.defineSections(defaults.sections!);
70
+ await config.loadSectionEnvironment({
71
+ MAILERS_SYSTEM_PASSWORD: 'synthetic-password',
72
+ });
73
+ expect(await config.validate()).toEqual([]);
74
+ expect(config.get('mailers.default')).toBe('system');
75
+ expect(config.get('mailers.system.auth.pass')).toBe('synthetic-password');
76
+ expect(JSON.stringify(config.publicValues())).not.toContain(
77
+ 'synthetic-password',
78
+ );
79
+ expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
75
80
  } finally {
76
81
  await rm(directory, { recursive: true, force: true });
77
82
  }
78
83
  expect(example).not.toContain('emailChannel:');
79
84
  });
85
+
86
+ it('accepts an entirely environment-configured shared sender without enabling recovery', async () => {
87
+ const config = new AppConfig();
88
+ await config.loadAll();
89
+ const defaults = defaultAppConfigs({ auth, mailers });
90
+ config.mergeDefaults(defaults({} as never));
91
+ config.defineSections(defaults.sections!);
92
+ await config.loadSectionEnvironment({
93
+ MAILERS_DEFAULT: 'system',
94
+ MAILERS_SYSTEM_PROVIDER: 'smtp',
95
+ MAILERS_SYSTEM_HOST: 'localhost',
96
+ MAILERS_SYSTEM_PORT: '2525',
97
+ MAILERS_SYSTEM_SECURE: 'false',
98
+ });
99
+ expect(await config.validate()).toEqual([]);
100
+ expect(config.get('mailers.system')).toMatchObject({
101
+ provider: 'smtp',
102
+ port: 2525,
103
+ secure: false,
104
+ });
105
+ expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
106
+ expect(config.environmentVariableMappings()).toMatchObject({
107
+ MAILERS_DEFAULT: { path: 'mailers.default' },
108
+ MAILERS_SYSTEM_PASSWORD: {
109
+ path: 'mailers.system.auth.pass',
110
+ secret: true,
111
+ },
112
+ });
113
+ });