@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,145 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ ORGANIZATION_SINGLE_EMPLOYEE_SELECTOR_TAG,
6
+ UniplatOrganizationSingleEmployeeSelectorElement,
7
+ type OrganizationEmployeeSelectionItem,
8
+ type OrganizationSingleEmployeeSelectionConfirmedDetail,
9
+ type UniplatOrganizationSingleEmployeeSelectorTransport,
10
+ } from '../src'
11
+
12
+ function employee(employeeId: string, displayName: string): OrganizationEmployeeSelectionItem {
13
+ return {
14
+ employeeId,
15
+ displayName,
16
+ maskedMobile: '138****6688',
17
+ primaryDepartmentId: '10',
18
+ primaryDepartmentName: '研发部',
19
+ departmentIds: ['10'],
20
+ departmentNames: ['研发部'],
21
+ employmentStatus: 'active',
22
+ }
23
+ }
24
+
25
+ function transport(): UniplatOrganizationSingleEmployeeSelectorTransport {
26
+ return {
27
+ loadDepartmentTree: vi.fn(async () => [{ id: '10', name: '研发部', depth: 0, memberCount: 2 }]),
28
+ listDepartmentEmployees: vi.fn(async ({ page, pageSize }) => ({
29
+ items: [employee('1', '张三'), employee('2', '李四')],
30
+ page,
31
+ pageSize,
32
+ total: 2,
33
+ hasMore: false,
34
+ })),
35
+ searchEmployees: vi.fn(async ({ page, pageSize }) => ({
36
+ items: [employee('1', '张三'), employee('2', '李四')],
37
+ page,
38
+ pageSize,
39
+ total: 2,
40
+ hasMore: false,
41
+ })),
42
+ resolveEmployees: vi.fn(async ({ employeeIds }) =>
43
+ employeeIds.map((employeeId: string) =>
44
+ employee(employeeId, employeeId === '1' ? '张三' : '李四'),
45
+ ),
46
+ ),
47
+ }
48
+ }
49
+
50
+ async function createSelector(selectorTransport = transport()) {
51
+ const selector = document.createElement(ORGANIZATION_SINGLE_EMPLOYEE_SELECTOR_TAG)
52
+ selector.configure({ applicationName: 'OfficialWebsite', transport: selectorTransport })
53
+ document.body.append(selector)
54
+ return { selector, selectorTransport }
55
+ }
56
+
57
+ async function settle(selector: UniplatOrganizationSingleEmployeeSelectorElement): Promise<void> {
58
+ await selector.updateComplete
59
+ await Promise.resolve()
60
+ await selector.updateComplete
61
+ }
62
+
63
+ describe('<uniplat-organization-single-employee-selector>', () => {
64
+ afterEach(() => {
65
+ document.body.replaceChildren()
66
+ })
67
+
68
+ it('registers a distinct public element and requires exactly one employee', async () => {
69
+ expect(customElements.get(ORGANIZATION_SINGLE_EMPLOYEE_SELECTOR_TAG)).toBe(
70
+ UniplatOrganizationSingleEmployeeSelectorElement,
71
+ )
72
+ const { selector } = await createSelector()
73
+ await selector.open({ title: '选择负责人' })
74
+ await settle(selector)
75
+
76
+ expect(selector.shadowRoot?.textContent).toContain('选择负责人')
77
+ expect(selector.shadowRoot?.querySelector<HTMLButtonElement>('.primary')?.disabled).toBe(true)
78
+ expect(selector.shadowRoot?.querySelector('.selected-pane')).toBeNull()
79
+ expect(selector.shadowRoot?.querySelector('.mobile-tabs')).toBeNull()
80
+ expect(selector.shadowRoot?.querySelector('.body.single-selection')).toBeTruthy()
81
+ expect(selector.shadowRoot?.querySelector('.selection-indicator.radio')).toBeTruthy()
82
+ expect(selector.shadowRoot?.querySelector('.checkbox')).toBeNull()
83
+ expect(selector.shadowRoot?.querySelector('[role="radiogroup"]')).toBeTruthy()
84
+
85
+ selector.shadowRoot?.querySelector<HTMLButtonElement>('.employee')?.click()
86
+ await settle(selector)
87
+ expect(selector.shadowRoot?.querySelector<HTMLButtonElement>('.primary')?.disabled).toBe(false)
88
+ expect(selector.shadowRoot?.textContent).not.toContain('确认(1)')
89
+ })
90
+
91
+ it('uses a scalar initial value and replaces the previous selection', async () => {
92
+ const { selector, selectorTransport } = await createSelector()
93
+ await selector.open({ initialSelectedEmployeeId: '1' })
94
+ await settle(selector)
95
+
96
+ expect(selectorTransport.resolveEmployees).toHaveBeenCalledWith({ employeeIds: ['1'] })
97
+ expect(selector.shadowRoot?.querySelectorAll('.employee.selected')).toHaveLength(1)
98
+ expect(selector.shadowRoot?.querySelector('.employee.selected')?.textContent).toContain('张三')
99
+
100
+ selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.employee')[1]?.click()
101
+ await settle(selector)
102
+ const selected = selector.shadowRoot?.querySelector('.employee.selected')
103
+ expect(selector.shadowRoot?.querySelectorAll('.employee.selected')).toHaveLength(1)
104
+ expect(selected?.textContent).toContain('李四')
105
+ expect(selected?.textContent).not.toContain('张三')
106
+ })
107
+
108
+ it('emits a scalar employee result without the multi-select arrays', async () => {
109
+ const { selector } = await createSelector()
110
+ let detail: OrganizationSingleEmployeeSelectionConfirmedDetail | undefined
111
+ selector.addEventListener('selection-confirmed', (event) => {
112
+ detail = event.detail
113
+ })
114
+ await selector.open({ initialSelectedEmployeeId: '1' })
115
+ await settle(selector)
116
+
117
+ selector.shadowRoot?.querySelector<HTMLButtonElement>('.primary')?.click()
118
+ await settle(selector)
119
+ expect(detail).toEqual({ employeeId: '1', employee: employee('1', '张三') })
120
+ expect(detail).not.toHaveProperty('employeeIds')
121
+ expect(detail).not.toHaveProperty('employees')
122
+ expect(JSON.stringify(detail)).not.toContain('token')
123
+ expect(selector.shadowRoot?.querySelector('[role="dialog"]')).toBeNull()
124
+ })
125
+
126
+ it('keeps a protected initial employee and rejects multi-selector open options', async () => {
127
+ const { selector } = await createSelector()
128
+ await selector.open({
129
+ initialSelectedEmployeeId: '1',
130
+ disabledSelections: [{ employeeId: '1', reason: '组织所有者不能替换' }],
131
+ })
132
+ await settle(selector)
133
+
134
+ selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.employee')[1]?.click()
135
+ await settle(selector)
136
+ expect(selector.shadowRoot?.textContent).toContain('组织所有者不能替换')
137
+ expect(selector.shadowRoot?.querySelector('.employee.selected')?.textContent).toContain('张三')
138
+
139
+ selector.close()
140
+ await settle(selector)
141
+ expect(() => selector.open({ maxSelection: 2 } as never)).toThrowError(
142
+ 'Multi employee selector options are not supported by the single selector',
143
+ )
144
+ })
145
+ })
@@ -0,0 +1,496 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createUniplatBaseAccountContextTransport,
4
+ UniplatBaseAccountContextTransportError,
5
+ } from '../src'
6
+
7
+ const serviceBaseUrl = 'https://gateway.example.com/api/general/project/uniplat_base/service'
8
+ const listEndpoint = `${serviceBaseUrl}/application.identity/list_available_applications`
9
+ const organizationsEndpoint = `${serviceBaseUrl}/application.identity/list_available_organizations`
10
+ const switchEndpoint = `${serviceBaseUrl}/application.identity/switch_current_application`
11
+ const currentEmployeeInfoEndpoint = `${serviceBaseUrl}/system.employee_profile/get_current_employee_info`
12
+ const accessToken = 'test-account-context-access-token'
13
+ const nextAccessToken = 'next-test-account-context-access-token'
14
+ const accountSettingsTicket = 'account_settings_ticket_1234567890'
15
+
16
+ function organization(
17
+ oid: string,
18
+ type: 'enterprise' | 'team',
19
+ overrides: Record<string, unknown> = {},
20
+ ) {
21
+ return {
22
+ oid,
23
+ eid: String(Number(oid) + 100),
24
+ team_name: type === 'enterprise' ? '北京星河科技' : '星河增长项目组',
25
+ customer_name: type === 'enterprise' ? '北京星河科技有限公司' : null,
26
+ organization_type: type,
27
+ verification_status: type === 'enterprise' ? 'verified' : 'not_applicable',
28
+ role: type === 'enterprise' ? 'owner' : 'member',
29
+ member_count: type === 'enterprise' ? 187 : 12,
30
+ employee_display_name: type === 'enterprise' ? '企业员工张伟' : '团队成员张伟',
31
+ employee_avatar_url: null,
32
+ employee_department_name: type === 'enterprise' ? '总裁办' : '增长中心',
33
+ employee_employment_status: 'active',
34
+ is_current: false,
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ function application(
40
+ xid: string,
41
+ oid: string,
42
+ applicationName: string,
43
+ applicationScope: 'open' | 'organization',
44
+ ) {
45
+ return {
46
+ xid,
47
+ oid,
48
+ application_name: applicationName,
49
+ application_scope: applicationScope,
50
+ application_title: applicationName,
51
+ entry_url: `https://apps.example.com/${applicationName}`,
52
+ }
53
+ }
54
+
55
+ function response(data: Record<string, unknown>): Response {
56
+ return new Response(JSON.stringify({ rescode: 0, data }), {
57
+ status: 200,
58
+ headers: { 'content-type': 'application/json' },
59
+ })
60
+ }
61
+
62
+ function openProjection(overrides: Record<string, unknown> = {}) {
63
+ const openApplication = application('21', '0', 'OfficialWebsite', 'open')
64
+ return {
65
+ application_name: 'OfficialWebsite',
66
+ application_scope: 'open',
67
+ current_identity: {
68
+ xid: '21',
69
+ oid: '0',
70
+ eid: '0',
71
+ mid: '0',
72
+ application_name: 'OfficialWebsite',
73
+ application_scope: 'open',
74
+ },
75
+ available_applications: [openApplication],
76
+ ...overrides,
77
+ }
78
+ }
79
+
80
+ function organizationProjection(overrides: Record<string, unknown> = {}) {
81
+ return {
82
+ application_name: 'OfficialWebsite',
83
+ application_scope: 'open',
84
+ current_identity: {
85
+ xid: '21',
86
+ oid: '0',
87
+ eid: '0',
88
+ mid: '0',
89
+ application_name: 'OfficialWebsite',
90
+ application_scope: 'open',
91
+ },
92
+ display_name: '用户张伟',
93
+ avatar_url: 'https://static.example.com/user-avatar.png',
94
+ mobile_masked: '138****6688',
95
+ organizations: [organization('11', 'enterprise'), organization('12', 'team')],
96
+ ...overrides,
97
+ }
98
+ }
99
+
100
+ describe('createUniplatBaseAccountContextTransport', () => {
101
+ it('loads the server-projected organizations for the requested open application', async () => {
102
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
103
+ void init
104
+ return response(
105
+ String(input) === organizationsEndpoint ? organizationProjection() : openProjection(),
106
+ )
107
+ })
108
+ const getAccessToken = vi.fn(async () => accessToken)
109
+ const transport = createUniplatBaseAccountContextTransport({
110
+ serviceBaseUrl,
111
+ getAccessToken,
112
+ setAccessToken: vi.fn(),
113
+ fetch: fetchMock as typeof fetch,
114
+ })
115
+
116
+ const projection = await transport.load('OfficialWebsite')
117
+
118
+ expect(getAccessToken).toHaveBeenCalledTimes(1)
119
+ expect(fetchMock).toHaveBeenCalledTimes(2)
120
+ const [listUrl, listInit] = fetchMock.mock.calls.find(([input]) => input === listEndpoint)!
121
+ expect(listUrl).toBe(listEndpoint)
122
+ const request = listInit as RequestInit
123
+ expect(request.body).toBe('{}')
124
+ expect(new Headers(request.headers).get('authorization')).toBe(`Bearer ${accessToken}`)
125
+ expect(String(listUrl)).not.toContain(accessToken)
126
+ expect(String(request.body)).not.toContain(accessToken)
127
+ const [, organizationInit] = fetchMock.mock.calls.find(
128
+ ([input]) => input === organizationsEndpoint,
129
+ )!
130
+ expect((organizationInit as RequestInit).body).toBe(
131
+ JSON.stringify({ application_name: 'OfficialWebsite' }),
132
+ )
133
+ expect(
134
+ fetchMock.mock.calls.some(([input]) => String(input).endsWith('/system.user/info')),
135
+ ).toBe(false)
136
+ expect(projection).toMatchObject({
137
+ applicationName: 'OfficialWebsite',
138
+ applicationScope: 'open',
139
+ user: {
140
+ displayName: '用户张伟',
141
+ maskedMobile: '138****6688',
142
+ avatarUrl: 'https://static.example.com/user-avatar.png',
143
+ },
144
+ currentOrganization: null,
145
+ currentOrganizationId: null,
146
+ organizations: [
147
+ {
148
+ id: '11',
149
+ name: '北京星河科技',
150
+ type: 'enterprise',
151
+ verificationStatus: 'verified',
152
+ role: '所有者',
153
+ memberCount: 187,
154
+ employeeDisplayName: '企业员工张伟',
155
+ companyName: '北京星河科技有限公司',
156
+ },
157
+ {
158
+ id: '12',
159
+ name: '星河增长项目组',
160
+ type: 'team',
161
+ role: '成员',
162
+ memberCount: 12,
163
+ },
164
+ ],
165
+ })
166
+ expect('applicationNames' in projection.organizations[0]!).toBe(false)
167
+ expect(JSON.stringify(projection)).not.toContain(accessToken)
168
+ })
169
+
170
+ it('loads the JWT-bound employee department on demand without sending identity fields', async () => {
171
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
172
+ void init
173
+ if (String(input) === currentEmployeeInfoEndpoint) {
174
+ return response({ primary_department: { id: '101', name: '总裁办' } })
175
+ }
176
+ return response({})
177
+ })
178
+ const transport = createUniplatBaseAccountContextTransport({
179
+ serviceBaseUrl,
180
+ getAccessToken: async () => accessToken,
181
+ setAccessToken: vi.fn(),
182
+ fetch: fetchMock as typeof fetch,
183
+ })
184
+
185
+ await expect(transport.loadCurrentEmployeeInfo()).resolves.toEqual({ departmentName: '总裁办' })
186
+
187
+ const [url, init] = fetchMock.mock.calls[0]!
188
+ expect(url).toBe(currentEmployeeInfoEndpoint)
189
+ expect((init as RequestInit).body).toBe('{}')
190
+ expect(new Headers((init as RequestInit).headers).get('authorization')).toBe(
191
+ `Bearer ${accessToken}`,
192
+ )
193
+ })
194
+
195
+ it('accepts an unbound Open Application identity while preserving its available organizations', async () => {
196
+ const unboundOpenApplication = application('0', '0', 'OfficialWebsite', 'open')
197
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
198
+ response(
199
+ String(input) === organizationsEndpoint
200
+ ? organizationProjection()
201
+ : openProjection({
202
+ current_identity: {
203
+ xid: '0',
204
+ oid: '0',
205
+ eid: '0',
206
+ mid: '0',
207
+ application_name: 'OfficialWebsite',
208
+ application_scope: 'open',
209
+ },
210
+ available_applications: [unboundOpenApplication],
211
+ }),
212
+ ),
213
+ )
214
+ const transport = createUniplatBaseAccountContextTransport({
215
+ serviceBaseUrl,
216
+ getAccessToken: async () => accessToken,
217
+ setAccessToken: vi.fn(),
218
+ fetch: fetchMock as typeof fetch,
219
+ })
220
+
221
+ const projection = await transport.load('OfficialWebsite')
222
+
223
+ expect(projection.currentIdentity).toMatchObject({
224
+ xid: '0',
225
+ oid: '0',
226
+ applicationScope: 'open',
227
+ })
228
+ expect(projection.currentOrganization).toBeNull()
229
+ expect(projection.organizations).toHaveLength(2)
230
+ })
231
+
232
+ it('falls back safely when user profile fields contain a legacy file locator', async () => {
233
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
234
+ response(
235
+ String(input) === organizationsEndpoint
236
+ ? organizationProjection({
237
+ display_name: '',
238
+ avatar_url: { file_id: 'UF1.legacy-file' },
239
+ mobile_masked: '13800138000',
240
+ })
241
+ : openProjection(),
242
+ ),
243
+ )
244
+ const transport = createUniplatBaseAccountContextTransport({
245
+ serviceBaseUrl,
246
+ getAccessToken: async () => accessToken,
247
+ setAccessToken: vi.fn(),
248
+ fetch: fetchMock as typeof fetch,
249
+ })
250
+
251
+ await expect(transport.load('OfficialWebsite')).resolves.toMatchObject({
252
+ user: { displayName: '用户' },
253
+ })
254
+ })
255
+
256
+ it('converts a managed employee avatar path to the anonymous image endpoint', async () => {
257
+ const fileId = '8d6a4835-8e45-4e8c-8509-764d5127d878'
258
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
259
+ response(
260
+ String(input) === organizationsEndpoint
261
+ ? organizationProjection({
262
+ organizations: [
263
+ organization('11', 'enterprise', {
264
+ employee_avatar_url: `21/.root/.avatars/111/avatar-${fileId}.png`,
265
+ }),
266
+ ],
267
+ })
268
+ : openProjection(),
269
+ ),
270
+ )
271
+ const transport = createUniplatBaseAccountContextTransport({
272
+ serviceBaseUrl,
273
+ getAccessToken: async () => accessToken,
274
+ setAccessToken: vi.fn(),
275
+ fetch: fetchMock as typeof fetch,
276
+ })
277
+
278
+ const projection = await transport.load('OfficialWebsite')
279
+
280
+ expect(projection.organizations[0]?.employeeAvatarUrl).toBe(
281
+ `${serviceBaseUrl}/anonymous/system.employee_profile/get_avatar?xid=21&eid=111&file_id=${fileId}`,
282
+ )
283
+ expect(JSON.stringify(projection)).not.toContain('.root/.avatars')
284
+ expect(JSON.stringify(projection)).not.toContain(accessToken)
285
+ })
286
+
287
+ it('binds an open application to a selected organization and persists the returned JWT first', async () => {
288
+ const nextIdentity = {
289
+ xid: '21',
290
+ oid: '12',
291
+ eid: '112',
292
+ mid: '0',
293
+ application_name: 'OfficialWebsite',
294
+ application_scope: 'open',
295
+ }
296
+ const switchedApplications = openProjection({
297
+ current_identity: {
298
+ ...nextIdentity,
299
+ },
300
+ })
301
+ const switchedOrganizations = organizationProjection({
302
+ current_identity: nextIdentity,
303
+ organizations: [
304
+ organization('11', 'enterprise'),
305
+ organization('12', 'team', { is_current: true }),
306
+ ],
307
+ })
308
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
309
+ if (String(input) === switchEndpoint) return response({ token: nextAccessToken })
310
+ const nextTokenRequest =
311
+ new Headers(init?.headers).get('authorization') === `Bearer ${nextAccessToken}`
312
+ if (nextTokenRequest) {
313
+ return response(
314
+ String(input) === organizationsEndpoint ? switchedOrganizations : switchedApplications,
315
+ )
316
+ }
317
+ return response(
318
+ String(input) === organizationsEndpoint ? organizationProjection() : openProjection(),
319
+ )
320
+ })
321
+ const setAccessToken = vi.fn(async (token: string, metadata: unknown) => {
322
+ void token
323
+ void metadata
324
+ })
325
+ const transport = createUniplatBaseAccountContextTransport({
326
+ serviceBaseUrl,
327
+ getAccessToken: async () => accessToken,
328
+ setAccessToken,
329
+ fetch: fetchMock as typeof fetch,
330
+ })
331
+
332
+ const projection = await transport.switchOrganization('OfficialWebsite', '12')
333
+
334
+ const [, requestInit] = fetchMock.mock.calls.find(([input]) => input === switchEndpoint)!
335
+ const request = requestInit as RequestInit
336
+ expect(JSON.parse(String(request.body))).toEqual({
337
+ application_name: 'OfficialWebsite',
338
+ xid: '21',
339
+ organization_oid: '12',
340
+ })
341
+ expect(String(request.body)).not.toContain(accessToken)
342
+ expect(setAccessToken).toHaveBeenCalledTimes(1)
343
+ expect(setAccessToken.mock.calls[0]?.[0]).toBe(nextAccessToken)
344
+ expect(setAccessToken.mock.calls[0]?.[1]).toBe(projection)
345
+ expect(JSON.stringify(setAccessToken.mock.calls[0]?.[1])).not.toContain(nextAccessToken)
346
+ expect(projection.currentOrganizationId).toBe('12')
347
+ expect(projection.currentOrganization?.employeeDisplayName).toBe('团队成员张伟')
348
+ })
349
+
350
+ it('creates a short-lived ticket for the personal center without exposing the JWT', async () => {
351
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
352
+ if (String(input) !== switchEndpoint) throw new Error('Unexpected endpoint')
353
+ void init
354
+ return response({ ticket: accountSettingsTicket, expires_in: 15 })
355
+ })
356
+ const transport = createUniplatBaseAccountContextTransport({
357
+ serviceBaseUrl,
358
+ getAccessToken: async () => accessToken,
359
+ setAccessToken: vi.fn(),
360
+ fetch: fetchMock as typeof fetch,
361
+ })
362
+
363
+ await expect(transport.launchAccountSettings('OfficialWebsite')).resolves.toEqual({
364
+ url: `https://localhost/personal#uniplat_application_ticket=${accountSettingsTicket}`,
365
+ })
366
+ const [, init] = fetchMock.mock.calls[0]!
367
+ expect(init?.body).toBe(
368
+ JSON.stringify({
369
+ application_name: 'OfficialWebsite',
370
+ response_type: 'ticket',
371
+ entry_url: 'https://localhost/personal',
372
+ }),
373
+ )
374
+ expect(String(init?.body)).not.toContain(accessToken)
375
+ expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain(accessToken)
376
+ expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${accessToken}`)
377
+ })
378
+
379
+ it('reports the backend capability gap when switching an organization application', async () => {
380
+ const targetA = application('31', '11', 'HrSaas', 'organization')
381
+ const currentApplications = {
382
+ application_name: 'HrSaas',
383
+ application_scope: 'organization',
384
+ current_identity: {
385
+ xid: '31',
386
+ oid: '11',
387
+ eid: '111',
388
+ mid: '401',
389
+ application_name: 'HrSaas',
390
+ application_scope: 'organization',
391
+ },
392
+ available_applications: [targetA],
393
+ }
394
+ const currentOrganizations = {
395
+ application_name: 'HrSaas',
396
+ application_scope: 'organization',
397
+ display_name: '用户张伟',
398
+ avatar_url: 'https://static.example.com/user-avatar.png',
399
+ mobile_masked: '138****6688',
400
+ current_identity: {
401
+ xid: '31',
402
+ oid: '11',
403
+ eid: '111',
404
+ mid: '401',
405
+ application_name: 'HrSaas',
406
+ application_scope: 'organization',
407
+ },
408
+ organizations: [
409
+ organization('11', 'enterprise', { is_current: true }),
410
+ organization('12', 'team'),
411
+ ],
412
+ }
413
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
414
+ void init
415
+ return response(
416
+ String(input) === organizationsEndpoint ? currentOrganizations : currentApplications,
417
+ )
418
+ })
419
+ const transport = createUniplatBaseAccountContextTransport({
420
+ serviceBaseUrl,
421
+ getAccessToken: async () => accessToken,
422
+ setAccessToken: vi.fn(),
423
+ fetch: fetchMock as typeof fetch,
424
+ })
425
+
426
+ await expect(transport.switchOrganization('HrSaas', '12')).rejects.toMatchObject({
427
+ code: 'ORGANIZATION_SWITCH_UNSUPPORTED',
428
+ message: 'Organization switch is unavailable for this application',
429
+ })
430
+ expect(fetchMock.mock.calls.some(([input]) => input === switchEndpoint)).toBe(false)
431
+ })
432
+
433
+ it('rejects unavailable organizations and malformed projections without exposing raw data', async () => {
434
+ const setAccessToken = vi.fn()
435
+ const transport = createUniplatBaseAccountContextTransport({
436
+ serviceBaseUrl,
437
+ getAccessToken: async () => accessToken,
438
+ setAccessToken,
439
+ fetch: vi.fn(async (input: RequestInfo | URL) =>
440
+ response(
441
+ String(input) === organizationsEndpoint ? organizationProjection() : openProjection(),
442
+ ),
443
+ ),
444
+ })
445
+
446
+ await expect(transport.switchOrganization('OfficialWebsite', '999')).rejects.toMatchObject({
447
+ code: 'ORGANIZATION_UNAVAILABLE',
448
+ message: 'Organization is unavailable',
449
+ })
450
+ expect(setAccessToken).not.toHaveBeenCalled()
451
+
452
+ const invalidTransport = createUniplatBaseAccountContextTransport({
453
+ serviceBaseUrl,
454
+ getAccessToken: async () => accessToken,
455
+ setAccessToken,
456
+ fetch: vi.fn(async (input: RequestInfo | URL) =>
457
+ response(
458
+ String(input) === organizationsEndpoint
459
+ ? organizationProjection({ application_scope: 'unexpected-internal-scope' })
460
+ : openProjection(),
461
+ ),
462
+ ),
463
+ })
464
+ const failure = await invalidTransport.load('OfficialWebsite').catch((error: unknown) => error)
465
+ expect(failure).toBeInstanceOf(UniplatBaseAccountContextTransportError)
466
+ expect(failure).toMatchObject({
467
+ code: 'INVALID_RESPONSE',
468
+ message: 'Account context response is invalid',
469
+ })
470
+ expect(JSON.stringify(failure)).not.toContain('unexpected-internal-scope')
471
+
472
+ const mismatchedAvatarTransport = createUniplatBaseAccountContextTransport({
473
+ serviceBaseUrl,
474
+ getAccessToken: async () => accessToken,
475
+ setAccessToken,
476
+ fetch: vi.fn(async (input: RequestInfo | URL) =>
477
+ response(
478
+ String(input) === organizationsEndpoint
479
+ ? organizationProjection({
480
+ organizations: [
481
+ organization('11', 'enterprise', {
482
+ employee_avatar_url:
483
+ '21/.root/.avatars/999/avatar-8d6a4835-8e45-4e8c-8509-764d5127d878.png',
484
+ }),
485
+ ],
486
+ })
487
+ : openProjection(),
488
+ ),
489
+ ),
490
+ })
491
+ await expect(mismatchedAvatarTransport.load('OfficialWebsite')).rejects.toMatchObject({
492
+ code: 'INVALID_RESPONSE',
493
+ message: 'Account context response is invalid',
494
+ })
495
+ })
496
+ })