@csntgao/uni-base 0.1.0

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 (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,283 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import type {
5
+ NotificationCenterTransport,
6
+ NotificationDto,
7
+ NotificationRealtimeChange,
8
+ } from '@uniplat/ai-employee-client'
9
+ import '../src'
10
+ import { UniplatNotificationPopover } from '../src'
11
+
12
+ function notification(overrides: Partial<NotificationDto> = {}): NotificationDto {
13
+ return {
14
+ id: overrides.id ?? 'n1',
15
+ type: overrides.type ?? 'todo',
16
+ typeName: overrides.typeName ?? '待办提醒',
17
+ title: overrides.title ?? '社保增员申报待确认',
18
+ summary: overrides.summary ?? '3 名新员工的 8 月社保增员待你确认。',
19
+ content: overrides.content ?? '正文内容',
20
+ contentType: overrides.contentType ?? 'text',
21
+ priority: overrides.priority ?? 'normal',
22
+ source: overrides.source ?? null,
23
+ resources: overrides.resources ?? [],
24
+ attachments: overrides.attachments ?? [],
25
+ readStatus: overrides.readStatus ?? 'unread',
26
+ readAt: overrides.readAt ?? null,
27
+ archivedAt: overrides.archivedAt ?? null,
28
+ createdAt: overrides.createdAt ?? new Date(Date.now() - 12 * 60_000).toISOString(),
29
+ expiresAt: overrides.expiresAt ?? null,
30
+ }
31
+ }
32
+
33
+ function makeTransport(
34
+ items: NotificationDto[],
35
+ overrides: Partial<NotificationCenterTransport> = {},
36
+ ) {
37
+ return {
38
+ listInbox: vi.fn(async () => ({ items, hasMore: false, nextId: null })),
39
+ getUnreadCount: vi.fn(async () => ({
40
+ unreadCount: items.filter((item) => item.readStatus === 'unread').length,
41
+ })),
42
+ markRead: vi.fn(async () => ({
43
+ unreadCount: Math.max(0, items.filter((item) => item.readStatus === 'unread').length - 1),
44
+ readAt: new Date().toISOString(),
45
+ })),
46
+ markAllRead: vi.fn(async () => ({
47
+ markedCount: items.filter((item) => item.readStatus === 'unread').length,
48
+ unreadCount: 0,
49
+ })),
50
+ downloadAttachment: vi.fn(async () => new Blob(['file'])),
51
+ ...overrides,
52
+ } satisfies NotificationCenterTransport
53
+ }
54
+
55
+ async function mount(
56
+ items: NotificationDto[],
57
+ overrides: Partial<NotificationCenterTransport> = {},
58
+ ) {
59
+ const transport = makeTransport(items, overrides)
60
+ const component = document.createElement('uniplat-notification-popover')
61
+ component.configure({ applicationName: 'OfficialWebsite', transport })
62
+ document.body.append(component)
63
+ await vi.waitFor(() => expect(transport.listInbox).toHaveBeenCalledTimes(1))
64
+ await component.updateComplete
65
+ return { component, transport }
66
+ }
67
+
68
+ describe('<uniplat-notification-popover>', () => {
69
+ afterEach(() => document.body.replaceChildren())
70
+
71
+ it('registers, exports and loads the four-item unread projection while initially closed', async () => {
72
+ const items = [
73
+ notification({ id: 'n1' }),
74
+ notification({ id: 'n2', type: 'certification', typeName: '认证进度' }),
75
+ notification({ id: 'n3', type: 'report', typeName: '服务动态' }),
76
+ notification({ id: 'n4', type: 'message', typeName: '顾问消息' }),
77
+ notification({ id: 'n5', type: 'policy', typeName: '政策提醒' }),
78
+ notification({ id: 'n6', readStatus: 'read' }),
79
+ ]
80
+ const { component, transport } = await mount(items)
81
+
82
+ expect(customElements.get('uniplat-notification-popover')).toBeTruthy()
83
+ expect(component).toBeInstanceOf(UniplatNotificationPopover)
84
+ expect(component.hasAttribute('open')).toBe(false)
85
+ expect(transport.listInbox).toHaveBeenCalledWith(
86
+ expect.objectContaining({
87
+ pageSize: 4,
88
+ readStatus: 'unread',
89
+ applicationName: 'OfficialWebsite',
90
+ }),
91
+ )
92
+
93
+ component.open()
94
+ await component.updateComplete
95
+ const root = component.shadowRoot
96
+ expect(root?.querySelector('.title')?.textContent).toBe('消息通知')
97
+ expect(root?.querySelector('.unread-badge')?.textContent).toContain('5 条未读')
98
+ expect(root?.querySelectorAll('.item')).toHaveLength(4)
99
+ expect(root?.querySelector('.meta')?.textContent).toMatch(/待办提醒 · (11|12) 分钟前/)
100
+ expect(root?.querySelector('[data-prototype-icon="clipboard-check"]')).toBeTruthy()
101
+ expect(root?.querySelector('.footer')?.textContent).toContain('查看全部消息')
102
+ })
103
+
104
+ it('marks all notifications read and switches to the prototype empty state', async () => {
105
+ const { component, transport } = await mount([notification(), notification({ id: 'n2' })])
106
+ component.open()
107
+ await component.updateComplete
108
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.mark-all')?.click()
109
+
110
+ await vi.waitFor(() => expect(transport.markAllRead).toHaveBeenCalledTimes(1))
111
+ await component.updateComplete
112
+ expect(component.getUnreadCount()).toBe(0)
113
+ expect(component.shadowRoot?.querySelector('.unread-badge')).toBeNull()
114
+ expect(component.shadowRoot?.querySelector('.empty')?.textContent).toContain('暂无未读消息')
115
+ expect(component.shadowRoot?.querySelector('[data-prototype-icon="mail-check"]')).toBeTruthy()
116
+ })
117
+
118
+ it('marks an item read, closes and requests the full notification center with only its id', async () => {
119
+ const requested: unknown[] = []
120
+ const closed = vi.fn()
121
+ const { component, transport } = await mount([notification({ id: 'notice-7' })])
122
+ component.addEventListener('notification-center-requested', (event) => {
123
+ requested.push((event as CustomEvent).detail)
124
+ })
125
+ component.addEventListener('closed', closed)
126
+ component.open()
127
+ await component.updateComplete
128
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.item')?.click()
129
+ await component.updateComplete
130
+
131
+ expect(component.hasAttribute('open')).toBe(false)
132
+ expect(requested).toEqual([{ notificationId: 'notice-7' }])
133
+ expect(closed).toHaveBeenCalledTimes(1)
134
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(1))
135
+ expect(JSON.stringify(requested)).not.toContain('Bearer')
136
+ })
137
+
138
+ it('requests the full notification center without selecting an item from the footer', async () => {
139
+ const requested: unknown[] = []
140
+ const { component } = await mount([notification()])
141
+ component.addEventListener('notification-center-requested', (event) => {
142
+ requested.push((event as CustomEvent).detail)
143
+ })
144
+ component.open()
145
+ await component.updateComplete
146
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.footer')?.click()
147
+ await component.updateComplete
148
+
149
+ expect(requested).toEqual([{}])
150
+ expect(component.hasAttribute('open')).toBe(false)
151
+ })
152
+
153
+ it('closes on the prototype outside-click layer and rejects credential-bearing icon URLs', async () => {
154
+ const closed = vi.fn()
155
+ const transport = makeTransport([notification()])
156
+ const component = document.createElement('uniplat-notification-popover')
157
+ component.configure({
158
+ applicationName: 'OfficialWebsite',
159
+ transport,
160
+ typeIcons: { todo: 'https://static.example/icon.svg?access_token=must-not-render' },
161
+ })
162
+ component.addEventListener('closed', closed)
163
+ document.body.append(component)
164
+ await vi.waitFor(() => expect(transport.listInbox).toHaveBeenCalledTimes(1))
165
+ component.open()
166
+ await vi.waitFor(() => expect(component.shadowRoot?.querySelector('.item')).toBeTruthy())
167
+
168
+ expect(component.shadowRoot?.innerHTML).not.toContain('must-not-render')
169
+ expect(
170
+ component.shadowRoot?.querySelector('[data-prototype-icon="clipboard-check"]'),
171
+ ).toBeTruthy()
172
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.backdrop')?.click()
173
+ await component.updateComplete
174
+ expect(component.hasAttribute('open')).toBe(false)
175
+ expect(closed).toHaveBeenCalledTimes(1)
176
+ })
177
+
178
+ it('closes from a document-level outside click even when the backdrop cannot cover the host', async () => {
179
+ const closed = vi.fn()
180
+ const { component } = await mount([notification()])
181
+ const outside = document.createElement('button')
182
+ document.body.append(outside)
183
+ component.addEventListener('closed', closed)
184
+ component.open()
185
+ await new Promise((resolve) => setTimeout(resolve, 0))
186
+
187
+ component.shadowRoot
188
+ ?.querySelector<HTMLElement>('.panel')
189
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }))
190
+ expect(component.hasAttribute('open')).toBe(true)
191
+
192
+ outside.click()
193
+ await component.updateComplete
194
+ expect(component.hasAttribute('open')).toBe(false)
195
+ expect(closed).toHaveBeenCalledTimes(1)
196
+ })
197
+
198
+ it('does not consume the external bell click that calls toggle to open the popover', async () => {
199
+ const { component } = await mount([notification()])
200
+ const bell = document.createElement('button')
201
+ const outside = document.createElement('button')
202
+ const addEventListener = vi.spyOn(document, 'addEventListener')
203
+ document.body.append(bell, outside)
204
+ bell.addEventListener('click', () => component.toggle())
205
+
206
+ bell.click()
207
+ expect(component.opened).toBe(true)
208
+ await Promise.resolve()
209
+ expect(addEventListener.mock.calls.some(([type]) => type === 'click')).toBe(false)
210
+ await component.updateComplete
211
+ expect(component.hasAttribute('open')).toBe(true)
212
+
213
+ await new Promise((resolve) => setTimeout(resolve, 0))
214
+ expect(addEventListener.mock.calls.some(([type]) => type === 'click')).toBe(true)
215
+ expect(component.hasAttribute('open')).toBe(true)
216
+ outside.click()
217
+ await component.updateComplete
218
+ expect(component.hasAttribute('open')).toBe(false)
219
+ addEventListener.mockRestore()
220
+ })
221
+
222
+ it('removes the document-level outside-click listener when disconnected', async () => {
223
+ const closed = vi.fn()
224
+ const { component } = await mount([notification()])
225
+ component.addEventListener('closed', closed)
226
+ component.open()
227
+ await new Promise((resolve) => setTimeout(resolve, 0))
228
+ component.remove()
229
+
230
+ document.body.click()
231
+ expect(closed).not.toHaveBeenCalled()
232
+ })
233
+
234
+ it('consumes host-forwarded realtime events without creating a WebSocket', async () => {
235
+ const openedSockets: string[] = []
236
+ const originalWebSocket = globalThis.WebSocket
237
+ class TrackingSocket {
238
+ constructor(url: string) {
239
+ openedSockets.push(url)
240
+ }
241
+ }
242
+ Object.defineProperty(globalThis, 'WebSocket', {
243
+ value: TrackingSocket,
244
+ configurable: true,
245
+ writable: true,
246
+ })
247
+ try {
248
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
249
+ kind: 'created',
250
+ notification: notification({ id: 'n2', title: '企业认证已通过' }),
251
+ unreadCount: 2,
252
+ }))
253
+ const { component } = await mount([notification()], { parseRealtimeEvent })
254
+ component.applyRealtimeEvent({ event_type: 'notification_created' })
255
+ component.open()
256
+ await component.updateComplete
257
+
258
+ expect(openedSockets).toEqual([])
259
+ expect(parseRealtimeEvent).toHaveBeenCalledTimes(1)
260
+ expect(component.getUnreadCount()).toBe(2)
261
+ expect(component.shadowRoot?.querySelectorAll('.item')).toHaveLength(2)
262
+ } finally {
263
+ Object.defineProperty(globalThis, 'WebSocket', {
264
+ value: originalWebSocket,
265
+ configurable: true,
266
+ writable: true,
267
+ })
268
+ }
269
+ })
270
+
271
+ it('accepts an empty applicationName to load the all-applications projection', async () => {
272
+ const transport = makeTransport([notification()])
273
+ const component = document.createElement('uniplat-notification-popover')
274
+ component.configure({ applicationName: '', transport })
275
+ document.body.append(component)
276
+
277
+ await vi.waitFor(() => expect(transport.listInbox).toHaveBeenCalledTimes(1))
278
+ expect(transport.listInbox).toHaveBeenCalledWith(
279
+ expect.objectContaining({ applicationName: '', readStatus: 'unread' }),
280
+ )
281
+ expect(transport.getUnreadCount).toHaveBeenCalledWith({ applicationName: '' })
282
+ })
283
+ })
@@ -0,0 +1,332 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { createUniplatBaseOrganizationMultiEmployeeSelectorTransport } from '../src'
5
+ import type { OrganizationMultiEmployeeSelectorTransportError } from '../src'
6
+
7
+ function response(data: unknown, status = 200): Response {
8
+ return new Response(JSON.stringify(data), {
9
+ status,
10
+ headers: { 'content-type': 'application/json' },
11
+ })
12
+ }
13
+
14
+ function envelope(data: unknown) {
15
+ return { rescode: 0, data }
16
+ }
17
+
18
+ describe('createUniplatBaseOrganizationMultiEmployeeSelectorTransport()', () => {
19
+ it('loads the organization-scoped department tree with JWT only in Authorization', async () => {
20
+ const fetchImplementation = vi.fn<typeof fetch>().mockResolvedValue(
21
+ response(
22
+ envelope({
23
+ nodes: [
24
+ {
25
+ id: 10,
26
+ name: '研发部',
27
+ parent_id: null,
28
+ depth: 0,
29
+ member_count: 12,
30
+ },
31
+ ],
32
+ }),
33
+ ),
34
+ )
35
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
36
+ serviceBaseUrl: 'https://service.example/api/general/project/uniplat_base/service',
37
+ getAccessToken: () => 'secret-jwt',
38
+ fetch: fetchImplementation,
39
+ })
40
+
41
+ await expect(transport.loadDepartmentTree()).resolves.toEqual([
42
+ { id: '10', name: '研发部', depth: 0, memberCount: 12 },
43
+ ])
44
+ const [url, init] = fetchImplementation.mock.calls[0] ?? []
45
+ expect(url).toBe(
46
+ 'https://service.example/api/general/project/uniplat_base/service/system.org_directory/tree_full',
47
+ )
48
+ expect(init?.headers).toMatchObject({ authorization: 'Bearer secret-jwt' })
49
+ expect(JSON.parse(String(init?.body))).toEqual({
50
+ payload: { directory_scope: 'organization' },
51
+ })
52
+ expect(String(url)).not.toContain('secret-jwt')
53
+ expect(String(init?.body)).not.toContain('secret-jwt')
54
+ })
55
+
56
+ it('maps department members, pages and masked employee summaries', async () => {
57
+ const fetchImplementation = vi.fn<typeof fetch>().mockResolvedValue(
58
+ response(
59
+ envelope({
60
+ total: 2,
61
+ page: 1,
62
+ page_size: 1,
63
+ items: [
64
+ {
65
+ employee_id: '145',
66
+ display_name: '高志刚2',
67
+ mobile_masked: '186****8793',
68
+ avatar_file_id: '8d6a4835-8e45-4e8c-8509-764d5127d878',
69
+ node_id: 10,
70
+ node_name: '研发部',
71
+ is_primary: true,
72
+ employment_status: 'active',
73
+ },
74
+ ],
75
+ }),
76
+ ),
77
+ )
78
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
79
+ serviceBaseUrl: '/api/general/project/uniplat_base/service',
80
+ getAccessToken: () => 'jwt-value',
81
+ fetch: fetchImplementation,
82
+ })
83
+
84
+ const result = await transport.listDepartmentEmployees({
85
+ departmentId: '10',
86
+ page: 1,
87
+ pageSize: 1,
88
+ employmentStatuses: ['active'],
89
+ })
90
+ expect(result).toEqual({
91
+ items: [
92
+ {
93
+ employeeId: '145',
94
+ displayName: '高志刚2',
95
+ maskedMobile: '186****8793',
96
+ avatar: { kind: 'file', fileId: '8d6a4835-8e45-4e8c-8509-764d5127d878' },
97
+ primaryDepartmentId: '10',
98
+ primaryDepartmentName: '研发部',
99
+ departmentIds: ['10'],
100
+ departmentNames: ['研发部'],
101
+ employmentStatus: 'active',
102
+ },
103
+ ],
104
+ page: 1,
105
+ pageSize: 1,
106
+ total: 2,
107
+ hasMore: true,
108
+ })
109
+ expect(JSON.parse(String(fetchImplementation.mock.calls[0]?.[1]?.body))).toEqual({
110
+ payload: {
111
+ directory_scope: 'organization',
112
+ node_id: '10',
113
+ page: 1,
114
+ page_size: 1,
115
+ },
116
+ })
117
+ })
118
+
119
+ it('searches active employees and never forwards caller identity fields', async () => {
120
+ const fetchImplementation = vi
121
+ .fn<typeof fetch>()
122
+ .mockResolvedValue(response(envelope({ total: 0, page: 2, page_size: 20, items: [] })))
123
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
124
+ serviceBaseUrl: 'https://service.example/base',
125
+ getAccessToken: () => 'jwt-value',
126
+ fetch: fetchImplementation,
127
+ })
128
+ await transport.searchEmployees({
129
+ keyword: '张',
130
+ page: 2,
131
+ pageSize: 20,
132
+ employmentStatuses: ['active'],
133
+ })
134
+ const body = JSON.parse(String(fetchImplementation.mock.calls[0]?.[1]?.body))
135
+ expect(body).toEqual({
136
+ payload: {
137
+ directory_scope: 'organization',
138
+ keyword: '张',
139
+ status: 1,
140
+ employment_status: 'active',
141
+ page: 2,
142
+ page_size: 20,
143
+ },
144
+ })
145
+ expect(JSON.stringify(body)).not.toMatch(/"(?:oid|xid|eid|uid|poid)"/)
146
+ })
147
+
148
+ it('resolves initial employees with bounded batches and refreshes JWT per request', async () => {
149
+ const tokens = ['jwt-1', 'jwt-2']
150
+ const getAccessToken = vi.fn(() => tokens.shift() ?? 'jwt-next')
151
+ const fetchImplementation = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
152
+ const body = JSON.parse(String(init?.body))
153
+ const id = body.payload.employee_id
154
+ return response(
155
+ envelope({
156
+ employee_id: id,
157
+ display_name: id === '1' ? '张三' : '李四',
158
+ mobile: '138****6688',
159
+ primary_node: { id: 10, name: '研发部' },
160
+ nodes: [
161
+ { id: 10, name: '研发部' },
162
+ { id: 11, name: '项目组' },
163
+ ],
164
+ employment_status: 'active',
165
+ }),
166
+ )
167
+ })
168
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
169
+ serviceBaseUrl: 'https://service.example/base',
170
+ getAccessToken,
171
+ fetch: fetchImplementation,
172
+ })
173
+
174
+ const result = await transport.resolveEmployees({ employeeIds: ['1', '2', '1'] })
175
+ expect(result).toHaveLength(2)
176
+ expect(result[0]).toMatchObject({
177
+ employeeId: '1',
178
+ primaryDepartmentId: '10',
179
+ departmentIds: ['10', '11'],
180
+ })
181
+ expect(getAccessToken).toHaveBeenCalledTimes(2)
182
+ expect(fetchImplementation.mock.calls[0]?.[1]?.headers).toMatchObject({
183
+ authorization: 'Bearer jwt-1',
184
+ })
185
+ expect(fetchImplementation.mock.calls[1]?.[1]?.headers).toMatchObject({
186
+ authorization: 'Bearer jwt-2',
187
+ })
188
+ })
189
+
190
+ it('filters disabled employees from search, department and initial-selection results', async () => {
191
+ const fetchImplementation = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
192
+ const action = String(url).split('/').at(-1)
193
+ const body = JSON.parse(String(init?.body))
194
+ if (action === 'contact_get') {
195
+ return response(
196
+ envelope({
197
+ employee_id: body.payload.employee_id,
198
+ display_name: '已停用员工',
199
+ enable: 0,
200
+ status: 0,
201
+ employment_status: 'active',
202
+ }),
203
+ )
204
+ }
205
+ return response(
206
+ envelope({
207
+ total: 2,
208
+ page: 1,
209
+ page_size: 20,
210
+ items: [
211
+ {
212
+ employee_id: '1',
213
+ display_name: '启用员工',
214
+ enable: 1,
215
+ status: 1,
216
+ employment_status: 'active',
217
+ },
218
+ {
219
+ employee_id: '2',
220
+ display_name: '已停用员工',
221
+ enable: 0,
222
+ status: 0,
223
+ employment_status: 'active',
224
+ },
225
+ ],
226
+ }),
227
+ )
228
+ })
229
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
230
+ serviceBaseUrl: 'https://service.example/base',
231
+ getAccessToken: () => 'jwt',
232
+ fetch: fetchImplementation,
233
+ })
234
+
235
+ const search = await transport.searchEmployees({
236
+ keyword: '',
237
+ page: 1,
238
+ pageSize: 20,
239
+ employmentStatuses: ['active'],
240
+ })
241
+ const department = await transport.listDepartmentEmployees({
242
+ departmentId: '10',
243
+ page: 1,
244
+ pageSize: 20,
245
+ employmentStatuses: ['active'],
246
+ })
247
+ const resolved = await transport.resolveEmployees({ employeeIds: ['2'] })
248
+
249
+ expect(search.items.map(({ employeeId }) => employeeId)).toEqual(['1'])
250
+ expect(department.items.map(({ employeeId }) => employeeId)).toEqual(['1'])
251
+ expect(resolved).toEqual([])
252
+ expect(JSON.parse(String(fetchImplementation.mock.calls[0]?.[1]?.body))).toMatchObject({
253
+ payload: { status: 1, employment_status: 'active' },
254
+ })
255
+ })
256
+
257
+ it('drops full mobile numbers from public employee results', async () => {
258
+ const fetchImplementation = vi.fn<typeof fetch>().mockResolvedValue(
259
+ response(
260
+ envelope({
261
+ total: 1,
262
+ page: 1,
263
+ page_size: 20,
264
+ items: [
265
+ {
266
+ employee_id: '1',
267
+ display_name: '张三',
268
+ mobile: '13812345678',
269
+ employment_status: 'active',
270
+ },
271
+ ],
272
+ }),
273
+ ),
274
+ )
275
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
276
+ serviceBaseUrl: 'https://service.example/base',
277
+ getAccessToken: () => 'jwt',
278
+ fetch: fetchImplementation,
279
+ })
280
+ const result = await transport.searchEmployees({
281
+ keyword: '',
282
+ page: 1,
283
+ pageSize: 20,
284
+ employmentStatuses: ['active'],
285
+ })
286
+ expect(result.items[0]).not.toHaveProperty('maskedMobile')
287
+ })
288
+
289
+ it('maps authentication and permission failures to stable safe errors', async () => {
290
+ const authentication = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
291
+ serviceBaseUrl: 'https://service.example/base',
292
+ getAccessToken: () => '',
293
+ fetch: vi.fn(),
294
+ })
295
+ await expect(authentication.loadDepartmentTree()).rejects.toMatchObject({
296
+ name: 'OrganizationMultiEmployeeSelectorTransportError',
297
+ code: 'AUTH_REQUIRED',
298
+ retryable: false,
299
+ })
300
+
301
+ const forbidden = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
302
+ serviceBaseUrl: 'https://service.example/base',
303
+ getAccessToken: () => 'jwt',
304
+ fetch: vi.fn<typeof fetch>().mockResolvedValue(response({ rescode: 999 }, 403)),
305
+ })
306
+ await expect(forbidden.loadDepartmentTree()).rejects.toEqual(
307
+ expect.objectContaining<Partial<OrganizationMultiEmployeeSelectorTransportError>>({
308
+ code: 'FORBIDDEN',
309
+ retryable: false,
310
+ }),
311
+ )
312
+ })
313
+
314
+ it('rejects malformed responses and unsafe service URLs', async () => {
315
+ expect(() =>
316
+ createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
317
+ serviceBaseUrl: 'javascript:alert(1)',
318
+ getAccessToken: () => 'jwt',
319
+ }),
320
+ ).toThrow(TypeError)
321
+
322
+ const transport = createUniplatBaseOrganizationMultiEmployeeSelectorTransport({
323
+ serviceBaseUrl: 'https://service.example/base',
324
+ getAccessToken: () => 'jwt',
325
+ fetch: vi.fn<typeof fetch>().mockResolvedValue(response(envelope({ nodes: [{}] }))),
326
+ })
327
+ await expect(transport.loadDepartmentTree()).rejects.toMatchObject({
328
+ code: 'INVALID_RESPONSE',
329
+ retryable: false,
330
+ })
331
+ })
332
+ })