@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.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- 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
- 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
- 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
- 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
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- 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
|
+
})
|