openxiangda-cli 2.0.0-alpha.61 → 2.0.0-alpha.62

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 (38) hide show
  1. package/dist/create-workspace.d.ts.map +1 -1
  2. package/dist/create-workspace.js +1 -0
  3. package/dist/create-workspace.js.map +1 -1
  4. package/package.json +4 -4
  5. package/template/AGENTS.md +1 -1
  6. package/template/README.md +7 -7
  7. package/template/apps/server/package.json +1 -1
  8. package/template/apps/server/src/app.module.ts +1 -3
  9. package/template/apps/server/test/smoke.test.ts +6 -23
  10. package/template/apps/web/e2e/resources.spec.ts +20 -0
  11. package/template/apps/web/index.html +1 -1
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/src/AuthoritativeSelector.tsx +38 -29
  14. package/template/apps/web/src/Shell.tsx +39 -211
  15. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
  16. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +26 -5
  17. package/template/apps/web/src/components/resource/SurfaceFields.tsx +32 -1
  18. package/template/apps/web/src/data-provider.ts +23 -102
  19. package/template/apps/web/src/main.tsx +22 -82
  20. package/template/apps/web/src/platform-client.ts +71 -415
  21. package/template/apps/web/src/runtime-meta.ts +1 -1
  22. package/template/apps/web/src/styles.css +3 -3
  23. package/template/apps/web/test/contracts.test.ts +31 -769
  24. package/template/openxiangda.config.ts +14 -81
  25. package/template/package.json +3 -3
  26. package/template/packages/contracts/src/generated.ts +7 -804
  27. package/template/apps/server/src/instrument-context.controller.ts +0 -25
  28. package/template/apps/web/e2e/instruments.spec.ts +0 -1338
  29. package/template/apps/web/src/CollegePage.tsx +0 -181
  30. package/template/apps/web/src/InstrumentDetailPage.tsx +0 -176
  31. package/template/apps/web/src/InstrumentForm.tsx +0 -380
  32. package/template/apps/web/src/InstrumentFormPage.tsx +0 -82
  33. package/template/apps/web/src/InstrumentListPage.tsx +0 -457
  34. package/template/apps/web/src/fields.ts +0 -18
  35. package/template/apps/web/src/instrument.ts +0 -56
  36. package/template/platform/data/colleges.ts +0 -21
  37. package/template/platform/data/instrument-surface.ts +0 -125
  38. package/template/platform/data/instruments.ts +0 -83
@@ -1,773 +1,35 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import test from 'node:test';
4
- import {
5
- instrumentFieldAllowed,
6
- sanitizeInstrumentValues,
7
- } from '../src/InstrumentForm';
8
- import { instrumentFields, instrumentSurface } from '../src/fields';
9
- import {
10
- browseDepartmentTree,
11
- browseDepartmentUsers,
12
- instrumentDataApi,
13
- fetchDataFileBlob,
14
- loadDataFilePreview,
15
- loadCurrentIdentity,
16
- resolveCollegeScope,
17
- resolveDirectory,
18
- searchCollegeScope,
19
- searchDirectory,
20
- } from '../src/platform-client';
21
- import {
22
- applicationApiPath,
23
- readRuntimeMount,
24
- resolveAttachmentPreviewPath,
25
- resolveApplicationApiPath,
26
- resolveApplicationBasename,
27
- } from '../src/runtime-meta';
28
-
29
- const collegeId = '11111111-1111-4111-8111-111111111111';
30
- const userId = '22222222-2222-4222-8222-222222222222';
31
-
32
- test('the golden module exposes exactly 30 fields and five restricted fields', () => {
33
- assert.equal(instrumentFields.length, 30);
34
- assert.deepEqual(
35
- instrumentFields
36
- .filter(field => field.createCapability && field.updateCapability)
37
- .map(field => field.key),
38
- [
39
- 'instrumentCode',
40
- 'assetCode',
41
- 'assetValue',
42
- 'collegeId',
43
- 'instrumentAdminIds',
44
- ]
45
- );
46
- assert.deepEqual(
47
- instrumentFields.filter(field => field.system).map(field => field.key),
48
- ['id', 'revision']
49
- );
50
- });
51
-
52
- test('the instrument list, search, and filters are declared by the resource surface', () => {
53
- assert.deepEqual(
54
- Object.entries(instrumentSurface.fields)
55
- .filter(([, field]) => field.list)
56
- .map(([key]) => key)
57
- .sort(),
58
- [
59
- 'chineseName',
60
- 'collegeId',
61
- 'image',
62
- 'instrumentAdminIds',
63
- 'instrumentCode',
64
- 'instrumentStatus',
65
- 'openToOutside',
66
- 'specModel',
67
- 'assetCode',
68
- ].sort()
69
- );
70
- assert.deepEqual(instrumentSurface.list?.searchableFields, [
71
- 'chineseName',
72
- 'instrumentCode',
73
- 'assetCode',
74
- ]);
75
- assert.ok(instrumentSurface.list?.filterFields?.includes('assetValue'));
76
- const list = readFileSync(
77
- new URL('../src/InstrumentListPage.tsx', import.meta.url),
78
- 'utf8'
79
- );
80
- const client = readFileSync(
81
- new URL('../src/platform-client.ts', import.meta.url),
82
- 'utf8'
83
- );
84
- assert.match(list, /listSurfaceFields/);
85
- assert.match(list, /searchableFields/);
86
- assert.match(client, /query\.searchField/);
4
+ import { resourceDefinitions, resourceCodes } from '../../../packages/contracts/src/generated.js';
5
+ import { readRuntimeMount, resolveApplicationBasename } from '../src/runtime-meta';
6
+
7
+ test('clean template is resource-driven and contains no HGY fixture', () => {
8
+ const config = readFileSync(new URL('../../../openxiangda.config.ts', import.meta.url), 'utf8');
9
+ const source = readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8');
10
+ assert.deepEqual(Object.keys(resourceDefinitions).sort(), [...resourceCodes].sort());
11
+ assert.match(config, /data:\s*\{\s*resources:/);
12
+ assert.match(source, /Object\.keys\(resourceDefinitions\)/);
13
+ const legacyMarkers = ['instruments', 'colleges', '仪器' + '资源管理', '学院' + '字典', 'HGY', 'Instrument'];
14
+ assert.doesNotMatch(`${config}\n${source}`, new RegExp(legacyMarkers.join('|')));
15
+ });
16
+
17
+ test('standard surfaces keep authoritative directory/resource selectors and mobile routes', () => {
18
+ const surface = readFileSync(new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url), 'utf8');
19
+ const selector = readFileSync(new URL('../src/AuthoritativeSelector.tsx', import.meta.url), 'utf8');
20
+ const crud = readFileSync(new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url), 'utf8');
21
+ assert.match(surface, /PlatformDirectoryPicker/);
22
+ assert.match(surface, /source="resource"/);
23
+ assert.match(selector, /searchResource|resolveResource/);
24
+ assert.match(crud, /MobileResourceListPage/);
25
+ assert.match(crud, /fieldWritable/);
26
+ });
27
+
28
+ test('runtime metadata stays mount-scoped', () => {
29
+ const mount = readRuntimeMount(name => ({
30
+ 'openxiangda-runtime-base': '/view/example-app/',
31
+ 'openxiangda-app-code': 'example-app',
32
+ 'openxiangda-environment': 'preproduction',
33
+ }[name]));
34
+ assert.equal(resolveApplicationBasename(mount), '/view/example-app');
87
35
  });
88
-
89
- test('application shell and instrument surfaces match the approved navigation model', () => {
90
- const shell = readFileSync(new URL('../src/Shell.tsx', import.meta.url), 'utf8');
91
- const list = readFileSync(
92
- new URL('../src/InstrumentListPage.tsx', import.meta.url),
93
- 'utf8'
94
- );
95
- const routes = readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8');
96
- const form = readFileSync(
97
- new URL('../src/InstrumentForm.tsx', import.meta.url),
98
- 'utf8'
99
- );
100
- const formPage = readFileSync(
101
- new URL('../src/InstrumentFormPage.tsx', import.meta.url),
102
- 'utf8'
103
- );
104
- const colleges = readFileSync(
105
- new URL('../src/CollegePage.tsx', import.meta.url),
106
- 'utf8'
107
- );
108
- const directory = readFileSync(
109
- new URL('../src/components/platform-fields/PlatformDirectoryPicker.tsx', import.meta.url),
110
- 'utf8'
111
- );
112
- const attachment = readFileSync(
113
- new URL('../src/components/platform-fields/AttachmentFileList.tsx', import.meta.url),
114
- 'utf8'
115
- );
116
- const surfaceFields = readFileSync(
117
- new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url),
118
- 'utf8'
119
- );
120
- const preview = readFileSync(
121
- new URL('../src/FilePreviewPage.tsx', import.meta.url),
122
- 'utf8'
123
- );
124
- const standardPages = readFileSync(
125
- new URL('../src/components/resource/StandardResourcePages.tsx', import.meta.url),
126
- 'utf8'
127
- );
128
- const generatedCrud = readFileSync(
129
- new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url),
130
- 'utf8'
131
- );
132
- for (const marker of [
133
- '仪器资源管理',
134
- '工作台',
135
- '仪器管理',
136
- '仪器资源',
137
- '开放记录',
138
- '基础数据',
139
- '学院字典',
140
- '系统设置',
141
- '应用管理员',
142
- '收起侧边栏',
143
- ]) {
144
- assert.match(shell, new RegExp(marker));
145
- }
146
- assert.doesNotMatch(shell, /<span>\{identity\.userId\}<\/span>/);
147
- assert.doesNotMatch(shell, /验证本地 Nest|roleCodes\.join|environment\.key/);
148
- assert.doesNotMatch(list, /<Drawer|surface="drawer"/);
149
- assert.match(routes, /path="\/instruments\/new"[\s\S]*<InstrumentFormPage mode="create"/);
150
- assert.match(routes, /path="\/m\/instruments\/new"[\s\S]*variant="mobile"/);
151
- assert.match(routes, /path="\/m\/instruments"[\s\S]*variant="mobile"/);
152
- assert.match(routes, /path="\/m\/instruments\/:id"[\s\S]*variant="mobile"/);
153
- assert.match(routes, /path="\/m\/instruments\/:id\/edit"[\s\S]*variant="mobile"/);
154
- assert.match(colleges, /<Drawer/);
155
- assert.match(colleges, /size=\{520\}/);
156
- assert.match(directory, /width=\{kind === 'user' \? 980 : 760\}/);
157
- assert.match(directory, /browseDepartmentTree|browseDepartmentUsers/);
158
- assert.match(directory, /选择成员|选择部门|组织架构|已选/);
159
- assert.match(form, /oxa-form-full/);
160
- assert.match(form, /SurfaceFieldControl/);
161
- assert.match(surfaceFields, /PlatformDirectoryPicker/);
162
- assert.match(surfaceFields, /oxa-file-dropzone/);
163
- assert.match(form, /oxa-detail-layout/);
164
- assert.match(list, /<ResourceListPage/);
165
- assert.match(formPage, /ResourceFormPage/);
166
- assert.match(formPage, /MobileResourceFormPage/);
167
- assert.match(list, /MobileResourceListPage/);
168
- assert.match(surfaceFields, /SurfaceFilterControl/);
169
- assert.match(form, /MobileSurfaceFieldControl/);
170
- assert.match(
171
- readFileSync(new URL('../src/InstrumentDetailPage.tsx', import.meta.url), 'utf8'),
172
- /<ResourceDetailPage/
173
- );
174
- assert.match(standardPages, /批量事务合同落地后启用/);
175
- assert.match(standardPages, /DataResourceSurface/);
176
- assert.match(generatedCrud, /resourceDefinitions/);
177
- assert.match(generatedCrud, /createNativeResourceClient/);
178
- assert.match(generatedCrud, /MobileResourceDetailPage/);
179
- assert.match(routes, /path="\/files\/:resourceCode\/:fileId\/preview"/);
180
- assert.match(attachment, /Image\.PreviewGroup/);
181
- assert.match(attachment, /attachmentPreviewPath/);
182
- assert.match(preview, /import\('docx-preview'\)/);
183
- assert.match(preview, /import\('xlsx'\)/);
184
- assert.match(preview, /只读预览/);
185
- assert.doesNotMatch(form, /instrumentFileContentUrl|window\.open/);
186
- assert.doesNotMatch(
187
- `${form}\n${directory}`,
188
- /FormContext|tools\/openxiangda|RoleSession/
189
- );
190
- });
191
-
192
- test('college, user, and department selectors have authoritative owners only', () => {
193
- const appConfig = readFileSync(
194
- new URL('../../../openxiangda.config.ts', import.meta.url),
195
- 'utf8'
196
- );
197
- const colleges = readFileSync(
198
- new URL('../../../platform/data/colleges.ts', import.meta.url),
199
- 'utf8'
200
- );
201
- const fields = readFileSync(
202
- new URL('../../../platform/data/instrument-surface.ts', import.meta.url),
203
- 'utf8'
204
- );
205
- assert.match(appConfig, /valueType:\s*'uuid'/);
206
- assert.match(appConfig, /kind:\s*'native_resource'/);
207
- assert.match(appConfig, /resourceCode:\s*'colleges'/);
208
- assert.match(appConfig, /data:\s*\{\s*resources:\s*\[colleges, instruments\]/);
209
- assert.match(colleges, /code:\s*'name'.*type:\s*'string'/s);
210
- assert.match(colleges, /code:\s*'enabled'.*type:\s*'boolean'/s);
211
- assert.match(fields, /kind:\s*'scope'/);
212
- assert.match(fields, /kind:\s*'directory-user'/);
213
- assert.match(fields, /kind:\s*'directory-department'/);
214
- for (const marker of [
215
- ['college', 'am'].join('-'),
216
- ['user', 'wang'].join('-'),
217
- ['dept', 'am-test'].join('-'),
218
- ]) {
219
- assert.equal(appConfig.includes(marker), false);
220
- assert.equal(fields.includes(marker), false);
221
- }
222
- });
223
-
224
- test('college create restores five fields while update omits college reassignment', () => {
225
- const protectedFields = instrumentFields.filter(
226
- field => field.createCapability && field.updateCapability
227
- );
228
- const collegeCapabilities = new Set<string>([
229
- ...protectedFields.map(field => field.createCapability!),
230
- ...protectedFields
231
- .filter(field => field.key !== 'collegeId')
232
- .map(field => field.updateCapability!),
233
- ]);
234
- const hasCapability = (capability: string) =>
235
- collegeCapabilities.has(capability);
236
- const values = {
237
- instrumentCode: 'INS-001',
238
- chineseName: '电子显微镜',
239
- assetCode: 'ASSET-001',
240
- assetValue: 100,
241
- collegeId,
242
- instrumentAdminIds: [userId],
243
- revision: 7,
244
- };
245
- assert.equal(
246
- instrumentFieldAllowed(
247
- instrumentFields.find(field => field.key === 'collegeId')!,
248
- 'create',
249
- hasCapability
250
- ),
251
- true
252
- );
253
- assert.equal(
254
- instrumentFieldAllowed(
255
- instrumentFields.find(field => field.key === 'collegeId')!,
256
- 'edit',
257
- hasCapability
258
- ),
259
- false
260
- );
261
- assert.deepEqual(sanitizeInstrumentValues(values, 'create', hasCapability), {
262
- instrumentCode: 'INS-001',
263
- chineseName: '电子显微镜',
264
- assetCode: 'ASSET-001',
265
- assetValue: 100,
266
- collegeId,
267
- instrumentAdminIds: [userId],
268
- });
269
- assert.deepEqual(sanitizeInstrumentValues(values, 'edit', hasCapability), {
270
- instrumentCode: 'INS-001',
271
- chineseName: '电子显微镜',
272
- assetCode: 'ASSET-001',
273
- assetValue: 100,
274
- instrumentAdminIds: [userId],
275
- });
276
- });
277
-
278
- test('instrument admin update payload omits every protected field', () => {
279
- const values = Object.fromEntries(
280
- instrumentFields
281
- .filter(field => !field.system)
282
- .map(field => [field.key, `value:${field.key}`])
283
- );
284
- const sanitized = sanitizeInstrumentValues(values, 'edit', () => false);
285
- assert.equal(sanitized.chineseName, 'value:chineseName');
286
- for (const field of instrumentFields.filter(
287
- item => item.createCapability && item.updateCapability
288
- )) {
289
- assert.equal(
290
- Object.prototype.hasOwnProperty.call(sanitized, field.key),
291
- false
292
- );
293
- }
294
- });
295
-
296
- test('school admin keeps every protected field in create and update payloads', () => {
297
- const values = Object.fromEntries(
298
- instrumentFields
299
- .filter(field => !field.system)
300
- .map(field => [field.key, `value:${field.key}`])
301
- );
302
- for (const mode of ['create', 'edit'] as const) {
303
- const sanitized = sanitizeInstrumentValues(values, mode, () => true);
304
- for (const field of instrumentFields.filter(
305
- item => item.createCapability && item.updateCapability
306
- )) {
307
- assert.equal(sanitized[field.key], `value:${field.key}`);
308
- }
309
- }
310
- });
311
-
312
- test('deployed app/environment come only from platform-injected runtime meta', () => {
313
- const values = new Map([
314
- ['openxiangda-runtime-base', '/view/instrument-center/'],
315
- ['openxiangda-app-code', 'instrument-center'],
316
- ['openxiangda-environment', 'production'],
317
- ]);
318
- const mount = readRuntimeMount(name => values.get(name));
319
- assert.deepEqual(mount, {
320
- runtimeBase:
321
- '/view/instrument-center/',
322
- appCode: 'instrument-center',
323
- environmentKey: 'production',
324
- });
325
- assert.equal(readRuntimeMount(() => undefined), null);
326
- assert.throws(
327
- () =>
328
- readRuntimeMount(name =>
329
- name === 'openxiangda-environment' ? 'production' : undefined
330
- ),
331
- /OPENXIANGDA_RUNTIME_META_INVALID/
332
- );
333
- assert.equal(
334
- applicationApiPath('api/instruments/context'),
335
- '/api/instruments/context'
336
- );
337
- assert.equal(resolveApplicationBasename(mount), '/view/instrument-center');
338
- assert.equal(
339
- resolveApplicationBasename({
340
- ...mount!,
341
- runtimeBase: '/dev/instrument-center/',
342
- environmentKey: 'preproduction',
343
- }),
344
- '/dev/instrument-center'
345
- );
346
- assert.equal(
347
- resolveApplicationApiPath(mount, 'api/instruments/context'),
348
- '/service/openxiangda-app-api/v2/instrument-center/production/api/instruments/context'
349
- );
350
- assert.equal(
351
- resolveAttachmentPreviewPath(
352
- mount,
353
- 'instruments',
354
- '77777777-7777-4777-8777-777777777777'
355
- ),
356
- '/view/instrument-center/files/instruments/77777777-7777-4777-8777-777777777777/preview'
357
- );
358
- });
359
-
360
- test('managed file preview reads metadata and bytes without a RoleSession', async () => {
361
- const calls: Array<{ path: string; init?: RequestInit }> = [];
362
- const originalFetch = globalThis.fetch;
363
- globalThis.fetch = async (input, init) => {
364
- const path = String(input);
365
- calls.push({ path, init });
366
- if (path.endsWith('/dev-sessions/current')) {
367
- return Response.json({
368
- code: 200,
369
- data: {
370
- environment: { id: 'test-id', key: 'preproduction' },
371
- principal: {
372
- userId,
373
- roleCodes: ['school_admin'],
374
- capabilityCodes: [],
375
- isAppSuperAdmin: false,
376
- },
377
- },
378
- });
379
- }
380
- if (path.includes('/files/file-1/preview?')) {
381
- return Response.json({
382
- code: 200,
383
- data: {
384
- schemaVersion: 'openxiangda.data-file-preview/v2',
385
- resourceCode: 'instruments',
386
- file: {
387
- schemaVersion: 'openxiangda.data-file-ref/v2',
388
- id: 'file-1',
389
- name: 'manual.docx',
390
- size: 12,
391
- contentType:
392
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
393
- },
394
- extension: 'docx',
395
- previewType: 'office',
396
- renderMode: 'docx-html',
397
- previewSurface: 'document',
398
- previewProvider: 'browser',
399
- canPreview: true,
400
- canDownload: true,
401
- },
402
- });
403
- }
404
- if (path.includes('/files/file-1/content?')) {
405
- return new Response(new Uint8Array([80, 75, 3, 4]), {
406
- status: 200,
407
- headers: {
408
- 'content-type':
409
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
410
- },
411
- });
412
- }
413
- return Response.json({ code: 404, message: 'not mocked' }, { status: 404 });
414
- };
415
- try {
416
- await loadCurrentIdentity();
417
- const preview = await loadDataFilePreview('instruments', 'file-1');
418
- const blob = await fetchDataFileBlob('instruments', 'file-1');
419
- assert.equal(preview.renderMode, 'docx-html');
420
- assert.equal(blob.size, 4);
421
- } finally {
422
- globalThis.fetch = originalFetch;
423
- }
424
- const previewCall = calls.find(call => call.path.includes('/files/file-1/preview?'))!;
425
- const contentCall = calls.find(call => call.path.includes('/files/file-1/content?'))!;
426
- assert.match(previewCall.path, /environmentKey=preproduction/);
427
- assert.match(contentCall.path, /disposition=inline/);
428
- assert.equal(
429
- calls.some(call =>
430
- Object.keys(call.init?.headers || {}).some(header =>
431
- header.toLowerCase().includes('role-session')
432
- )
433
- ),
434
- false
435
- );
436
- });
437
-
438
- test('list uses the native Data API with server paging, filters, and sorting', async () => {
439
- const calls: Array<{ path: string; init?: RequestInit }> = [];
440
- const originalFetch = globalThis.fetch;
441
- globalThis.fetch = async (input, init) => {
442
- calls.push({ path: String(input), init });
443
- if (String(input).endsWith('/dev-sessions/current')) {
444
- return new Response(
445
- JSON.stringify({
446
- code: 200,
447
- data: {
448
- environment: { id: 'test-id', key: 'preproduction' },
449
- principal: {
450
- userId: 'user-test',
451
- roleCodes: ['college_admin'],
452
- capabilityCodes: [],
453
- isAppSuperAdmin: false,
454
- },
455
- },
456
- }),
457
- { status: 200, headers: { 'content-type': 'application/json' } }
458
- );
459
- }
460
- return new Response(
461
- JSON.stringify({
462
- code: 200,
463
- data: {
464
- schemaVersion: 'openxiangda.data-page/v2',
465
- resourceCode: 'instruments',
466
- items: [],
467
- total: 0,
468
- limit: 20,
469
- offset: 20,
470
- },
471
- }),
472
- { status: 200, headers: { 'content-type': 'application/json' } }
473
- );
474
- };
475
- try {
476
- await loadCurrentIdentity();
477
- await instrumentDataApi.list({
478
- page: 2,
479
- pageSize: 20,
480
- collegeId,
481
- instrumentAdminId: userId,
482
- sort: { field: 'assetValue', order: 'asc' },
483
- });
484
- await instrumentDataApi.list({ page: 1, pageSize: 20 });
485
- } finally {
486
- globalThis.fetch = originalFetch;
487
- }
488
- assert.equal(calls.length, 3);
489
- assert.match(
490
- calls[1]!.path,
491
- /\/native\/data\/instruments\/query$/
492
- );
493
- const body = JSON.parse(String(calls[1]!.init?.body));
494
- const resourceSource = readFileSync(
495
- new URL('../../../platform/data/instruments.ts', import.meta.url),
496
- 'utf8'
497
- );
498
- const declaredFields = new Set(
499
- [...resourceSource.matchAll(/\{\s*code:\s*'([^']+)'/g)].map(
500
- match => match[1]!
501
- )
502
- );
503
- assert.deepEqual(
504
- [...declaredFields],
505
- instrumentFields.filter(field => !field.system).map(field => field.key)
506
- );
507
- assert.equal(body.environmentKey, 'preproduction');
508
- assert.equal(body.limit, 20);
509
- assert.equal(body.offset, 20);
510
- assert.deepEqual(body.order, [{ field: 'assetValue', direction: 'asc' }]);
511
- assert.equal(
512
- [...body.filters, ...body.order].every(item =>
513
- declaredFields.has(item.field)
514
- ),
515
- true
516
- );
517
- assert.deepEqual(body.filters, [
518
- { field: 'collegeId', operator: 'eq', value: collegeId },
519
- {
520
- field: 'instrumentAdminIds',
521
- operator: 'cs',
522
- value: [userId],
523
- },
524
- ]);
525
- const defaultBody = JSON.parse(String(calls[2]!.init?.body));
526
- assert.deepEqual(defaultBody.order, [
527
- { field: 'instrumentCode', direction: 'asc' },
528
- ]);
529
- assert.equal(
530
- [...defaultBody.filters, ...defaultBody.order].every(item =>
531
- declaredFields.has(item.field)
532
- ),
533
- true
534
- );
535
- });
536
-
537
- test('list rejects an undeclared sorter before issuing a Data API request', async () => {
538
- let requests = 0;
539
- const originalFetch = globalThis.fetch;
540
- globalThis.fetch = async () => {
541
- requests += 1;
542
- throw new Error('unexpected fetch');
543
- };
544
- try {
545
- await assert.rejects(
546
- instrumentDataApi.list({
547
- page: 1,
548
- pageSize: 20,
549
- sort: { field: 'updatedAt', order: 'desc' },
550
- }),
551
- /OPENXIANGDA_INSTRUMENT_QUERY_FIELD_NOT_DECLARED:updatedAt/
552
- );
553
- } finally {
554
- globalThis.fetch = originalFetch;
555
- }
556
- assert.equal(requests, 0);
557
- });
558
-
559
- test('authoritative selectors page, resolve exact IDs, and never send RoleSession', async () => {
560
- const calls: Array<{ path: string; init?: RequestInit }> = [];
561
- const originalFetch = globalThis.fetch;
562
- globalThis.fetch = async (input, init) => {
563
- const path = String(input);
564
- calls.push({ path, init });
565
- if (path.endsWith('/dev-sessions/current')) {
566
- return Response.json({
567
- code: 200,
568
- data: {
569
- environment: {
570
- id: 'test-id',
571
- key: 'preproduction',
572
- activeAppVersionId: 'version-1',
573
- headRevision: 2,
574
- authzRevisionId: 'authz-1',
575
- authzVersion: 3,
576
- scopeDataVersion: 'scope-1',
577
- },
578
- principal: {
579
- userId,
580
- roleCodes: ['school_admin'],
581
- capabilityCodes: [],
582
- isAppSuperAdmin: false,
583
- },
584
- },
585
- });
586
- }
587
- if (path.includes('/directory/users?')) {
588
- return Response.json({
589
- code: 200,
590
- data: {
591
- schemaVersion: 'openxiangda.directory-entry-page/v2',
592
- kind: 'user',
593
- items: [
594
- { kind: 'user', id: userId, label: '王老师', selectable: true },
595
- ],
596
- nextCursor: 'next-user-page',
597
- },
598
- });
599
- }
600
- if (path.includes('/directory/departments/tree?')) {
601
- return Response.json({
602
- code: 200,
603
- data: {
604
- schemaVersion: 'openxiangda.directory-entry-page/v2',
605
- kind: 'department',
606
- items: [
607
- {
608
- kind: 'department',
609
- id: 'department-root',
610
- label: '材料学院',
611
- hasChildren: true,
612
- selectable: true,
613
- },
614
- ],
615
- nextCursor: null,
616
- },
617
- });
618
- }
619
- if (path.includes('/directory/departments/department-root/users?')) {
620
- return Response.json({
621
- code: 200,
622
- data: {
623
- schemaVersion: 'openxiangda.directory-entry-page/v2',
624
- kind: 'user',
625
- items: [
626
- { kind: 'user', id: userId, label: '王老师', selectable: true },
627
- ],
628
- nextCursor: null,
629
- },
630
- });
631
- }
632
- if (path.includes('/directory/resolve?')) {
633
- const body = JSON.parse(String(init?.body));
634
- return Response.json({
635
- code: 200,
636
- data: {
637
- schemaVersion: 'openxiangda.directory-entry-page/v2',
638
- kind: body.kind,
639
- items: body.ids.map((id: string) => ({
640
- kind: body.kind,
641
- id,
642
- label: '精确解析',
643
- selectable: false,
644
- })),
645
- nextCursor: null,
646
- },
647
- });
648
- }
649
- if (path.includes('/scope-values/resolve?')) {
650
- const body = JSON.parse(String(init?.body));
651
- return Response.json({
652
- code: 200,
653
- data: scopePage(
654
- body.operation,
655
- body.values.map((value: string) => ({
656
- value,
657
- label: '先进材料学院',
658
- selectable: false,
659
- }))
660
- ),
661
- });
662
- }
663
- if (path.includes('/scope-values?')) {
664
- return Response.json({
665
- code: 200,
666
- data: scopePage('create', [
667
- { value: collegeId, label: '先进材料学院', selectable: true },
668
- ]),
669
- });
670
- }
671
- return Response.json({ code: 404, message: 'not mocked' }, { status: 404 });
672
- };
673
- try {
674
- await loadCurrentIdentity();
675
- const users = await searchDirectory('user', {
676
- keyword: '王老',
677
- cursor: 'cursor-1',
678
- });
679
- assert.equal(users.nextCursor, 'next-user-page');
680
- assert.equal((await resolveDirectory('department', [userId])).items[0]?.selectable, false);
681
- assert.equal(
682
- (await browseDepartmentTree({ parentId: 'department-parent' })).items[0]
683
- ?.id,
684
- 'department-root'
685
- );
686
- assert.equal(
687
- (await browseDepartmentUsers('department-root', 2)).items[0]?.id,
688
- userId
689
- );
690
- assert.equal(
691
- (await searchCollegeScope('create', { keyword: '', cursor: 'scope-1' }))
692
- .items[0]?.value,
693
- collegeId
694
- );
695
- assert.equal(
696
- (await resolveCollegeScope('update', [collegeId])).items[0]?.selectable,
697
- false
698
- );
699
- await assert.rejects(
700
- resolveDirectory('user', []),
701
- /OPENXIANGDA_DIRECTORY_RESOLVE_IDS_REQUIRED/
702
- );
703
- await assert.rejects(
704
- resolveCollegeScope('update', []),
705
- /OPENXIANGDA_SCOPE_RESOLVE_VALUES_REQUIRED/
706
- );
707
- await assert.rejects(
708
- resolveDirectory(
709
- 'user',
710
- Array.from({ length: 51 }, (_, index) => `user-${index}`)
711
- ),
712
- /OPENXIANGDA_DIRECTORY_RESOLVE_IDS_EXCEEDED/
713
- );
714
- await assert.rejects(
715
- resolveCollegeScope(
716
- 'update',
717
- Array.from({ length: 51 }, (_, index) =>
718
- `${String(index).padStart(8, '0')}-0000-4000-8000-000000000000`
719
- )
720
- ),
721
- /OPENXIANGDA_SCOPE_RESOLVE_VALUES_EXCEEDED/
722
- );
723
- } finally {
724
- globalThis.fetch = originalFetch;
725
- }
726
- const userSearch = calls.find(call => call.path.includes('/directory/users?'))!;
727
- const treeBrowse = calls.find(call => call.path.includes('/directory/departments/tree?'))!;
728
- const memberBrowse = calls.find(call => call.path.includes('/directory/departments/department-root/users?'))!;
729
- const scopeSearch = calls.find(call => call.path.includes('/scope-values?'))!;
730
- assert.match(userSearch.path, /keyword=%E7%8E%8B%E8%80%81/);
731
- assert.match(userSearch.path, /cursor=cursor-1/);
732
- assert.match(treeBrowse.path, /parentId=department-parent/);
733
- assert.match(memberBrowse.path, /page=2/);
734
- assert.match(scopeSearch.path, /environmentKey=preproduction/);
735
- assert.match(
736
- scopeSearch.path,
737
- /\/native\/data-resources\/instruments\/fields\/collegeId\/scope-values\?/
738
- );
739
- assert.match(scopeSearch.path, /operation=create/);
740
- assert.match(scopeSearch.path, /cursor=scope-1/);
741
- assert.equal(
742
- calls.some(call =>
743
- Object.keys(call.init?.headers || {}).some(header =>
744
- header.toLowerCase().includes('role-session')
745
- )
746
- ),
747
- false
748
- );
749
- });
750
-
751
- function scopePage(
752
- operation: 'create' | 'update',
753
- items: Array<{ value: string; label: string; selectable: boolean }>
754
- ) {
755
- return {
756
- schemaVersion: 'openxiangda.native-scope-value-page/v2',
757
- environment: {
758
- id: 'test-id',
759
- key: 'preproduction',
760
- activeAppVersionId: 'version-1',
761
- headRevision: 2,
762
- authzRevisionId: 'authz-1',
763
- authzVersion: 3,
764
- scopeDataVersion: 'scope-1',
765
- },
766
- resourceCode: 'instruments',
767
- fieldCode: 'collegeId',
768
- dimensionCode: 'college',
769
- operation,
770
- items,
771
- nextCursor: null,
772
- };
773
- }