@carthooks/arcubase-cli 0.1.7 → 0.1.9

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 (66) hide show
  1. package/bundle/arcubase-admin.mjs +1042 -5056
  2. package/bundle/arcubase.mjs +1042 -5056
  3. package/dist/generated/command_registry.generated.d.ts +693 -2812
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +812 -4100
  6. package/dist/generated/type_index.generated.d.ts +8 -397
  7. package/dist/generated/type_index.generated.d.ts.map +1 -1
  8. package/dist/generated/type_index.generated.js +8 -403
  9. package/dist/generated/zod_registry.generated.d.ts +33 -261
  10. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  11. package/dist/generated/zod_registry.generated.js +36 -365
  12. package/dist/runtime/body_loader.d.ts.map +1 -1
  13. package/dist/runtime/body_loader.js +12 -0
  14. package/dist/runtime/command_registry.d.ts +3 -21
  15. package/dist/runtime/command_registry.d.ts.map +1 -1
  16. package/dist/runtime/command_registry.js +17 -23
  17. package/dist/runtime/execute.d.ts +3 -3
  18. package/dist/runtime/execute.d.ts.map +1 -1
  19. package/dist/runtime/execute.js +53 -157
  20. package/dist/runtime/http.js +1 -1
  21. package/dist/runtime/upload.js +3 -3
  22. package/dist/runtime/zod_registry.d.ts.map +1 -1
  23. package/dist/runtime/zod_registry.js +7 -24
  24. package/dist/tests/command_registry.test.js +81 -28
  25. package/dist/tests/execute_validation.test.js +175 -510
  26. package/dist/tests/help.test.js +39 -62
  27. package/dist/tests/upload.test.js +4 -4
  28. package/dist/tests/zod_registry.test.js +0 -10
  29. package/package.json +1 -1
  30. package/sdk-dist/docs/runtime-reference/README.md +20 -30
  31. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +4 -5
  32. package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
  33. package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
  34. package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
  35. package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
  36. package/sdk-dist/docs/runtime-reference/entity-schema/subform.md +2 -2
  37. package/sdk-dist/docs/runtime-reference/entity-schema.md +4 -4
  38. package/sdk-dist/docs/runtime-reference/examples/README.md +2 -2
  39. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +12 -12
  40. package/sdk-dist/docs/runtime-reference/examples/oms-01/app-overview.md +1 -1
  41. package/sdk-dist/docs/runtime-reference/row-crud.md +11 -32
  42. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +16 -47
  43. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +11 -27
  44. package/sdk-dist/docs/runtime-reference/uploads.md +1 -1
  45. package/sdk-dist/docs/runtime-reference/workflow/README.md +19 -0
  46. package/sdk-dist/generated/command_registry.generated.ts +814 -4102
  47. package/sdk-dist/generated/type_index.generated.ts +8 -403
  48. package/sdk-dist/generated/zod_registry.generated.ts +44 -500
  49. package/sdk-dist/types/app.ts +3 -10
  50. package/sdk-dist/types/common.ts +7 -0
  51. package/sdk-dist/types/global-action.ts +2 -1
  52. package/src/generated/command_registry.generated.ts +814 -4102
  53. package/src/generated/type_index.generated.ts +8 -403
  54. package/src/generated/zod_registry.generated.ts +44 -500
  55. package/src/runtime/body_loader.ts +11 -0
  56. package/src/runtime/command_registry.ts +22 -44
  57. package/src/runtime/execute.ts +157 -165
  58. package/src/runtime/http.ts +1 -1
  59. package/src/runtime/upload.ts +3 -3
  60. package/src/runtime/zod_registry.ts +7 -25
  61. package/src/tests/command_registry.test.ts +81 -31
  62. package/src/tests/execute_validation.test.ts +236 -603
  63. package/src/tests/help.test.ts +47 -66
  64. package/src/tests/upload.test.ts +4 -4
  65. package/src/tests/zod_registry.test.ts +0 -13
  66. package/sdk-dist/docs/runtime-reference/app-discovery.md +0 -68
@@ -22,242 +22,200 @@ function tempJSONFile(payload: unknown): string {
22
22
  return filePath
23
23
  }
24
24
 
25
- test('admin save entity rejects invalid body file', async () => {
25
+ function validEntitySavePayload(overrides: Record<string, unknown> = {}) {
26
+ return {
27
+ id: 2188889845,
28
+ app_id: 2188889844,
29
+ name: '订单',
30
+ schema_version: 1,
31
+ field_id_seq: 1002,
32
+ layout: [[1001]],
33
+ fields: [
34
+ {
35
+ id: 1001,
36
+ label: '订单号',
37
+ type: 'text',
38
+ required: true,
39
+ unique: false,
40
+ editable: true,
41
+ visible: true,
42
+ show_label: true,
43
+ scannable: false,
44
+ default_value_mode: 0,
45
+ description: '',
46
+ value: null,
47
+ number_decimal: 0,
48
+ depends: [],
49
+ key: 'order_id',
50
+ code_index: false,
51
+ options: {
52
+ placeholder: '',
53
+ type: 'text',
54
+ lengthLimit: false,
55
+ lengthMin: 0,
56
+ lengthMax: 255,
57
+ },
58
+ transfers: null,
59
+ children: null,
60
+ },
61
+ ],
62
+ options: {
63
+ Buttons: [],
64
+ CustomActions: [],
65
+ FrontendEvents: [],
66
+ TitleFormat: {
67
+ Fields: [],
68
+ Parts: [],
69
+ Body: '',
70
+ },
71
+ misc: {},
72
+ },
73
+ workflow_enabled: false,
74
+ ...overrides,
75
+ }
76
+ }
77
+
78
+ test('table update-schema rejects invalid body file', async () => {
26
79
  const invalidFile = tempJSONFile({ name: '订单', layout: 'bad-layout' })
27
80
  await assert.rejects(async () => {
28
- await executeCLI('admin', ['entity', 'admin-save-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', invalidFile], env as any, async () => new Response('{}'))
81
+ await executeCLI('admin', ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', invalidFile], env as any, async () => new Response('{}'))
29
82
  }, (error: unknown) => {
30
83
  assert.ok(error instanceof CLIError)
31
84
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
32
85
  assert.equal(error.details?.requestType, 'EntitySaveReqVO')
33
86
  assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.layout')))
34
- assert.ok((error.details?.tsHints ?? []).some((item) => item.file.startsWith('/opt/arcubase-sdk/')))
35
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/entity-schema.md')))
36
87
  return true
37
88
  })
38
89
  })
39
90
 
40
- test('admin create app by tenants rejects name longer than 20 chars', async () => {
91
+ test('app create rejects name longer than 20 chars', async () => {
41
92
  const bodyFile = tempJSONFile({ name: 'CLI Verify Happy Path' })
42
93
  await assert.rejects(async () => {
43
- await executeCLI(
44
- 'admin',
45
- ['app', 'create-app-by-tenants', '--body-file', bodyFile],
46
- env as any,
47
- async () => new Response('{}', { status: 200 }),
48
- )
94
+ await executeCLI('admin', ['app', 'create', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
49
95
  }, (error: unknown) => {
50
96
  assert.ok(error instanceof CLIError)
51
97
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
52
98
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.name'))
53
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/table-lifecycle.md')))
54
99
  return true
55
100
  })
56
101
  })
57
102
 
58
- test('admin create entity executes with valid body file', async () => {
103
+ test('table create executes with valid body file', async () => {
59
104
  const bodyFile = tempJSONFile({ name: '订单', parent: 0 })
60
105
  const out = await executeCLI(
61
106
  'admin',
62
- ['app', 'create-entity', '--id', 'app_1', '--body-file', bodyFile],
107
+ ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile],
63
108
  env as any,
64
- async (url, init) => new Response(JSON.stringify({ ok: true, url, method: init?.method, headers: init?.headers }), { status: 200 })
109
+ async (url, init) =>
110
+ new Response(JSON.stringify({
111
+ url,
112
+ method: init?.method,
113
+ hasTenantHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Tenant-Id'),
114
+ hasHostHeader: Object.prototype.hasOwnProperty.call((init?.headers as Record<string, string>) ?? {}, 'X-Host'),
115
+ }), { status: 200 }),
65
116
  )
66
117
  assert.equal(out.kind, 'result')
67
- assert.equal(out.status, 200)
68
- assert.equal(out.data.url, 'https://arcubase.example.com/apps/app_1/entities')
69
- assert.equal(out.data.headers['Authorization'], 'Bearer tok')
70
- assert.equal(out.data.headers['X-Host'], undefined)
71
- assert.equal(out.data.headers['X-Tenant-Id'], undefined)
118
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
119
+ assert.equal(out.data.hasTenantHeader, false)
120
+ assert.equal(out.data.hasHostHeader, false)
72
121
  })
73
122
 
74
- test('admin api-prefixed command executes against external root path', async () => {
123
+ test('table create executes with valid body json', async () => {
75
124
  const out = await executeCLI(
76
125
  'admin',
77
- ['app-entity-share', 'get-app-entity-shares', '--app-id', 'app_1'],
126
+ ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":"订单","parent":0}'],
78
127
  env as any,
79
- async (url, init) => new Response(JSON.stringify({ ok: true, url, method: init?.method }), { status: 200 })
128
+ async (url, init) => new Response(JSON.stringify({
129
+ url,
130
+ method: init?.method,
131
+ body: init?.body ? JSON.parse(String(init.body)) : null,
132
+ }), { status: 200 }),
80
133
  )
81
134
  assert.equal(out.kind, 'result')
82
- assert.equal(out.data.url, 'https://arcubase.example.com/apps/app_1/entity-shares')
135
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entities')
136
+ assert.deepEqual(out.data.body, { name: '订单', parent: 0 })
137
+ })
138
+
139
+ test('body json conflicts with body file', async () => {
140
+ const bodyFile = tempJSONFile({ name: '订单', parent: 0 })
141
+ await assert.rejects(async () => {
142
+ await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-file', bodyFile, '--body-json', '{"name":"订单"}'], env as any, async () => new Response('{}', { status: 200 }))
143
+ }, (error: unknown) => {
144
+ assert.ok(error instanceof CLIError)
145
+ assert.equal(error.code, 'BODY_JSON_CONFLICT')
146
+ return true
147
+ })
148
+ })
149
+
150
+ test('body json is rejected for commands without request type', async () => {
151
+ await assert.rejects(async () => {
152
+ await executeCLI('admin', ['app', 'list', '--body-json', '{"name":"ignored"}'], env as any, async () => new Response('{}', { status: 200 }))
153
+ }, (error: unknown) => {
154
+ assert.ok(error instanceof CLIError)
155
+ assert.equal(error.code, 'BODY_JSON_NOT_SUPPORTED')
156
+ return true
157
+ })
83
158
  })
84
159
 
85
- test('app inventory returns apps with entity lists', async () => {
86
- const calls: string[] = []
160
+ test('body json parse errors fail fast', async () => {
161
+ await assert.rejects(async () => {
162
+ await executeCLI('admin', ['table', 'create', '--app-id', 'app_1', '--body-json', '{"name":'], env as any, async () => new Response('{}', { status: 200 }))
163
+ }, (error: unknown) => {
164
+ assert.ok(error instanceof CLIError)
165
+ assert.equal(error.code, 'INVALID_BODY_JSON')
166
+ return true
167
+ })
168
+ })
169
+
170
+ test('access-rule list executes against external root path', async () => {
87
171
  const out = await executeCLI(
88
172
  'admin',
89
- ['app', 'inventory'],
173
+ ['access-rule', 'list', '--app-id', 'app_1', '--table-id', 'table_1'],
90
174
  env as any,
91
- async (url, init) => {
92
- calls.push(`${init?.method ?? 'GET'} ${String(url)}`)
93
- if (String(url) === 'https://arcubase.example.com/apps') {
94
- return new Response(JSON.stringify({
95
- data: [
96
- { id: 101, name: 'Workspace' },
97
- { id: 102, name: 'CRM' },
98
- ],
99
- }), { status: 200 })
100
- }
101
- if (String(url) === 'https://arcubase.example.com/apps/101/entities') {
102
- return new Response(JSON.stringify({
103
- data: [
104
- { id: 201, name: 'Orders' },
105
- { id: 202, name: 'Customers' },
106
- ],
107
- }), { status: 200 })
108
- }
109
- if (String(url) === 'https://arcubase.example.com/apps/102/entities') {
110
- return new Response(JSON.stringify({
111
- data: [
112
- { id: 203, name: 'Leads' },
113
- ],
114
- }), { status: 200 })
115
- }
116
- return new Response('{}', { status: 404 })
117
- },
175
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
118
176
  )
119
177
  assert.equal(out.kind, 'result')
120
- assert.deepEqual(out.data, {
121
- apps: [
122
- {
123
- id: '101',
124
- name: 'Workspace',
125
- entities: [
126
- { id: '201', name: 'Orders' },
127
- { id: '202', name: 'Customers' },
128
- ],
129
- },
130
- {
131
- id: '102',
132
- name: 'CRM',
133
- entities: [
134
- { id: '203', name: 'Leads' },
135
- ],
136
- },
137
- ],
138
- })
139
- assert.deepEqual(calls, [
140
- 'GET https://arcubase.example.com/apps',
141
- 'POST https://arcubase.example.com/apps/101/entities',
142
- 'POST https://arcubase.example.com/apps/102/entities',
143
- ])
178
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/apps/app_1/entity/table_1/ingress')
144
179
  })
145
180
 
146
- test('admin save entity treats top-level error body as failure even on http 200', async () => {
147
- const bodyFile = tempJSONFile({
148
- id: 2188889845,
149
- app_id: 2188889844,
150
- name: '订单',
151
- schema_version: 1,
152
- field_id_seq: 1002,
153
- layout: [[1001]],
154
- fields: [
155
- {
156
- id: 1001,
157
- label: '订单号',
158
- type: 'text',
159
- required: true,
160
- unique: false,
161
- editable: true,
162
- visible: true,
163
- show_label: true,
164
- scannable: false,
165
- default_value_mode: 0,
166
- description: '',
167
- value: null,
168
- number_decimal: 0,
169
- depends: [],
170
- key: 'order_id',
171
- code_index: false,
172
- options: {},
173
- transfers: null,
174
- children: null,
175
- },
176
- ],
177
- workflow_enabled: false,
178
- })
181
+ test('table update-schema treats top-level error body as failure even on http 200', async () => {
182
+ const bodyFile = tempJSONFile(validEntitySavePayload())
179
183
  await assert.rejects(async () => {
180
184
  await executeCLI(
181
185
  'admin',
182
- ['entity', 'admin-save-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
186
+ ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
183
187
  env as any,
184
- async () =>
185
- new Response(
186
- JSON.stringify({
187
- error: {
188
- message: 'json: cannot unmarshal object into Go struct field .EntityVO.fields of type []models.Field',
189
- type: 'system',
190
- key: 'error.system',
191
- },
192
- trace_id: 'req_xxx',
193
- }),
194
- { status: 200 },
195
- ),
188
+ async () => new Response(JSON.stringify({
189
+ error: { message: 'json: cannot unmarshal object into Go struct field .EntityVO.fields of type []models.Field', type: 'system', key: 'error.system' },
190
+ trace_id: 'req_xxx',
191
+ }), { status: 200 }),
196
192
  )
197
193
  }, (error: unknown) => {
198
194
  assert.ok(error instanceof CLIError)
199
195
  assert.equal(error.code, 'UPSTREAM_BODY_ERROR')
200
- assert.equal(error.details?.endpoint, '/apps/app_1/entity/entity_1')
196
+ assert.equal(error.details?.endpoint, '/api/apps/app_1/entity/table_1')
201
197
  assert.equal(error.details?.method, 'PUT')
202
198
  assert.equal(error.details?.traceId, 'req_xxx')
203
199
  return true
204
200
  })
205
201
  })
206
202
 
207
- test('admin save entity normalizes shell null placeholders before validation', async () => {
208
- const bodyFile = tempJSONFile({
209
- id: 2188889845,
210
- app_id: 2188889844,
211
- name: '订单',
203
+ test('table update-schema normalizes shell null placeholders before validation', async () => {
204
+ const bodyFile = tempJSONFile(validEntitySavePayload({
212
205
  schema_version: 0,
213
- field_id_seq: 1002,
214
206
  layout: null,
215
207
  current_serial: null,
216
- fields: [
217
- {
218
- id: 1001,
219
- label: '订单号',
220
- type: 'text',
221
- required: true,
222
- unique: false,
223
- editable: true,
224
- visible: true,
225
- show_label: true,
226
- scannable: false,
227
- default_value_mode: 0,
228
- description: '',
229
- value: null,
230
- number_decimal: 0,
231
- depends: [],
232
- key: 'order_id',
233
- code_index: false,
234
- options: {
235
- placeholder: '',
236
- type: 'text',
237
- lengthLimit: false,
238
- lengthMin: 0,
239
- lengthMax: 255,
240
- },
241
- transfers: null,
242
- children: null,
243
- },
244
- ],
245
208
  options: {
246
209
  Buttons: null,
247
210
  CustomActions: null,
248
211
  FrontendEvents: null,
249
- TitleFormat: {
250
- Fields: null,
251
- Parts: null,
252
- Body: '',
253
- },
212
+ TitleFormat: { Fields: null, Parts: null, Body: '' },
254
213
  misc: null,
255
214
  },
256
- workflow_enabled: false,
257
- })
215
+ }))
258
216
  const out = await executeCLI(
259
217
  'admin',
260
- ['entity', 'admin-save-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
218
+ ['table', 'update-schema', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
261
219
  env as any,
262
220
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
263
221
  )
@@ -273,52 +231,26 @@ test('admin save entity normalizes shell null placeholders before validation', a
273
231
  assert.deepEqual(requestBody.options.misc, {})
274
232
  })
275
233
 
276
- test('delete entity row rejects selection type outside ids', async () => {
277
- const bodyFile = tempJSONFile({
278
- selection: {
279
- type: 'selection',
280
- ids: [4000000003],
281
- length: 1,
282
- },
283
- })
284
-
234
+ test('row delete rejects selection type outside ids', async () => {
235
+ const bodyFile = tempJSONFile({ selection: { type: 'selection', ids: [4000000003], length: 1 } })
285
236
  await assert.rejects(async () => {
286
- await executeCLI(
287
- 'user',
288
- ['rows', 'delete-entity-row', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
289
- env as any,
290
- async () => new Response('{}', { status: 200 }),
291
- )
237
+ await executeCLI('user', ['row', 'delete', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
292
238
  }, (error: unknown) => {
293
239
  assert.ok(error instanceof CLIError)
294
240
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
295
241
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.selection'))
296
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/search-and-bulk-actions.md')))
297
242
  return true
298
243
  })
299
244
  })
300
245
 
301
- test('bulk update accepts ids selection with number_add action', async () => {
246
+ test('row bulk-update accepts ids selection with number_add action', async () => {
302
247
  const bodyFile = tempJSONFile({
303
- selection: {
304
- type: 'ids',
305
- ids: [4000000003],
306
- length: 1,
307
- },
308
- fields: [
309
- {
310
- id: 1002,
311
- action: {
312
- type: 'number_add',
313
- number_add: 10,
314
- },
315
- },
316
- ],
248
+ selection: { type: 'ids', ids: [4000000003], length: 1 },
249
+ fields: [{ id: 1002, action: { type: 'number_add', number_add: 10 } }],
317
250
  })
318
-
319
251
  const out = await executeCLI(
320
252
  'user',
321
- ['entity', 'update-entity-bulk', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
253
+ ['row', 'bulk-update', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
322
254
  env as any,
323
255
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
324
256
  )
@@ -327,69 +259,18 @@ test('bulk update accepts ids selection with number_add action', async () => {
327
259
  assert.equal(out.data.fields[0].action.type, 'number_add')
328
260
  })
329
261
 
330
- test('selection action accepts condition selection with selectAll', async () => {
331
- const bodyFile = tempJSONFile({
332
- selection: {
333
- type: 'condition',
334
- length: 1,
335
- condition: {
336
- q: 'A-100',
337
- selectAll: true,
338
- },
339
- sorts: [
340
- { column: ':1002', order: 'desc' },
341
- ],
342
- },
343
- })
344
-
345
- const out = await executeCLI(
346
- 'user',
347
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
348
- env as any,
349
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
350
- )
351
- assert.equal(out.kind, 'result')
352
- assert.equal(out.data.selection.type, 'condition')
353
- assert.equal(out.data.selection.condition.selectAll, true)
354
- })
355
-
356
- test('selection action accepts condition selection with filter_* keys', async () => {
357
- const bodyFile = tempJSONFile({
358
- selection: {
359
- type: 'condition',
360
- length: 1,
361
- condition: {
362
- 'filter_:1001': '%7B%22is%22%3A%22A-100%22%7D',
363
- selectAll: true,
364
- },
365
- },
366
- })
367
-
368
- const out = await executeCLI(
369
- 'user',
370
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
371
- env as any,
372
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
373
- )
374
- assert.equal(out.kind, 'result')
375
- assert.equal(out.data.selection.type, 'condition')
376
- assert.equal(out.data.selection.condition.selectAll, true)
377
- })
378
-
379
- test('selection action accepts condition selection with selectAll only for batch_print', async () => {
262
+ test('row selection-action accepts condition selection with selectAll', async () => {
380
263
  const bodyFile = tempJSONFile({
381
264
  selection: {
382
265
  type: 'condition',
383
266
  length: 1,
384
- condition: {
385
- selectAll: true,
386
- },
267
+ condition: { q: 'A-100', selectAll: true },
268
+ sorts: [{ column: ':1002', order: 'desc' }],
387
269
  },
388
270
  })
389
-
390
271
  const out = await executeCLI(
391
272
  'user',
392
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'batch_print', '--body-file', bodyFile],
273
+ ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'archive', '--body-file', bodyFile],
393
274
  env as any,
394
275
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
395
276
  )
@@ -398,64 +279,10 @@ test('selection action accepts condition selection with selectAll only for batch
398
279
  assert.equal(out.data.selection.condition.selectAll, true)
399
280
  })
400
281
 
401
- test('selection action accepts all selection for query-style actions', async () => {
402
- const bodyFile = tempJSONFile({
403
- selection: {
404
- type: 'all',
405
- },
406
- })
407
-
408
- const out = await executeCLI(
409
- 'user',
410
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'query', '--body-file', bodyFile],
411
- env as any,
412
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
413
- )
414
- assert.equal(out.kind, 'result')
415
- assert.equal(out.data.selection.type, 'all')
416
- })
417
-
418
- test('selection action rejects condition selection for query action', async () => {
419
- const bodyFile = tempJSONFile({
420
- selection: {
421
- type: 'condition',
422
- length: 1,
423
- condition: {
424
- q: 'A-100',
425
- selectAll: true,
426
- },
427
- },
428
- })
429
-
430
- await assert.rejects(async () => {
431
- await executeCLI(
432
- 'user',
433
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'query', '--body-file', bodyFile],
434
- env as any,
435
- async () => new Response('{}', { status: 200 }),
436
- )
437
- }, (error: unknown) => {
438
- assert.ok(error instanceof CLIError)
439
- assert.equal(error.code, 'BODY_VALIDATION_FAILED')
440
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.selection.type'))
441
- return true
442
- })
443
- })
444
-
445
- test('selection action rejects all selection for archive action', async () => {
446
- const bodyFile = tempJSONFile({
447
- selection: {
448
- type: 'all',
449
- },
450
- })
451
-
282
+ test('row selection-action rejects condition selection for query action', async () => {
283
+ const bodyFile = tempJSONFile({ selection: { type: 'condition', length: 1, condition: { q: 'A-100', selectAll: true } } })
452
284
  await assert.rejects(async () => {
453
- await executeCLI(
454
- 'user',
455
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
456
- env as any,
457
- async () => new Response('{}', { status: 200 }),
458
- )
285
+ await executeCLI('user', ['row', 'selection-action', '--app-id', 'app_1', '--table-id', 'table_1', '--action', 'query', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
459
286
  }, (error: unknown) => {
460
287
  assert.ok(error instanceof CLIError)
461
288
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
@@ -464,59 +291,18 @@ test('selection action rejects all selection for archive action', async () => {
464
291
  })
465
292
  })
466
293
 
467
- test('selection action rejects selectAll-only condition for archive action', async () => {
468
- const bodyFile = tempJSONFile({
469
- selection: {
470
- type: 'condition',
471
- length: 1,
472
- condition: {
473
- selectAll: true,
474
- },
475
- },
476
- })
477
-
478
- await assert.rejects(async () => {
479
- await executeCLI(
480
- 'user',
481
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
482
- env as any,
483
- async () => new Response('{}', { status: 200 }),
484
- )
485
- }, (error: unknown) => {
486
- assert.ok(error instanceof CLIError)
487
- assert.equal(error.code, 'BODY_VALIDATION_FAILED')
488
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.selection.condition'))
489
- return true
490
- })
491
- })
492
-
493
- test('insert entity rejects local file path for file field and points to arcubase upload', async () => {
494
- const bodyFile = tempJSONFile({
495
- fields: {
496
- 1001: 'Lead A',
497
- 1007: './contract.pdf',
498
- },
499
- })
294
+ test('row create rejects local file path for file field and points to arcubase upload', async () => {
295
+ const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: './contract.pdf' } })
500
296
  const calls: Array<{ url: string; method?: string }> = []
501
-
502
297
  await assert.rejects(async () => {
503
298
  await executeCLI(
504
299
  'user',
505
- ['rows', 'insert-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
300
+ ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
506
301
  env as any,
507
302
  async (url, init) => {
508
303
  calls.push({ url: String(url), method: init?.method })
509
- if (String(url) === 'https://arcubase.example.com/entity/app_1/entity_1' && init?.method === 'GET') {
510
- return new Response(JSON.stringify({
511
- data: {
512
- id: 1,
513
- app_id: 1,
514
- fields: [
515
- { id: 1001, type: 'text', label: 'Lead Name' },
516
- { id: 1007, type: 'file', label: 'Contract File' },
517
- ],
518
- },
519
- }), { status: 200 })
304
+ if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
305
+ return new Response(JSON.stringify({ data: { id: 1, app_id: 1, fields: [{ id: 1001, type: 'text', label: 'Lead Name' }, { id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
520
306
  }
521
307
  return new Response('{}', { status: 200 })
522
308
  },
@@ -526,44 +312,21 @@ test('insert entity rejects local file path for file field and points to arcubas
526
312
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
527
313
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields.1007'))
528
314
  assert.match(error.message, /arcubase upload \.\/contract\.pdf/)
529
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/uploads.md')))
530
315
  return true
531
316
  })
532
-
533
- assert.deepEqual(calls, [
534
- { url: 'https://arcubase.example.com/entity/app_1/entity_1', method: 'GET' },
535
- ])
317
+ assert.deepEqual(calls, [{ url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' }])
536
318
  })
537
319
 
538
- test('update row rejects object value for image field and requires upload array', async () => {
539
- const bodyFile = tempJSONFile({
540
- changed_fields: [1008],
541
- data: {
542
- 1008: {
543
- upload_id: 123456,
544
- file: 'lead-photo.jpg',
545
- file_name: 'lead-photo.jpg',
546
- meta: {},
547
- },
548
- },
549
- })
550
-
320
+ test('row update rejects object value for image field and requires upload array', async () => {
321
+ const bodyFile = tempJSONFile({ changed_fields: [1008], data: { 1008: { upload_id: 123456, file: 'lead-photo.jpg', file_name: 'lead-photo.jpg', meta: {} } } })
551
322
  await assert.rejects(async () => {
552
323
  await executeCLI(
553
324
  'user',
554
- ['rows', 'update-entity-row', '--app-id', 'app_1', '--entity-id', 'entity_1', '--row-id', 'row_1', '--body-file', bodyFile],
325
+ ['row', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
555
326
  env as any,
556
327
  async (url, init) => {
557
- if (String(url) === 'https://arcubase.example.com/entity/app_1/entity_1' && init?.method === 'GET') {
558
- return new Response(JSON.stringify({
559
- data: {
560
- id: 1,
561
- app_id: 1,
562
- fields: [
563
- { id: 1008, type: 'image', label: 'Lead Photo' },
564
- ],
565
- },
566
- }), { status: 200 })
328
+ if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
329
+ return new Response(JSON.stringify({ data: { id: 1, app_id: 1, fields: [{ id: 1008, type: 'image', label: 'Lead Photo' }] } }), { status: 200 })
567
330
  }
568
331
  return new Response('{}', { status: 200 })
569
332
  },
@@ -573,88 +336,47 @@ test('update row rejects object value for image field and requires upload array'
573
336
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
574
337
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.data.1008'))
575
338
  assert.match(error.message, /must be an array/)
576
- assert.match(error.message, /arcubase upload <local-file>/)
577
339
  return true
578
340
  })
579
341
  })
580
342
 
581
- test('insert entity accepts upload array for file field after schema preflight', async () => {
582
- const bodyFile = tempJSONFile({
583
- fields: {
584
- 1001: 'Lead A',
585
- 1007: [
586
- {
587
- upload_id: 123456,
588
- file: 'contract.pdf',
589
- file_name: 'contract.pdf',
590
- meta: {},
591
- },
592
- ],
593
- },
594
- })
343
+ test('row create accepts upload array for file field after schema preflight', async () => {
344
+ const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: [{ upload_id: 123456, file: 'contract.pdf', file_name: 'contract.pdf', meta: {} }] } })
595
345
  const calls: Array<{ url: string; method?: string }> = []
596
-
597
346
  const out = await executeCLI(
598
347
  'user',
599
- ['rows', 'insert-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
348
+ ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
600
349
  env as any,
601
350
  async (url, init) => {
602
351
  calls.push({ url: String(url), method: init?.method })
603
- if (String(url) === 'https://arcubase.example.com/entity/app_1/entity_1' && init?.method === 'GET') {
604
- return new Response(JSON.stringify({
605
- data: {
606
- id: 1,
607
- app_id: 1,
608
- fields: [
609
- { id: 1001, type: 'text', label: 'Lead Name' },
610
- { id: 1007, type: 'file', label: 'Contract File' },
611
- ],
612
- },
613
- }), { status: 200 })
352
+ if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
353
+ return new Response(JSON.stringify({ data: { id: 1, app_id: 1, fields: [{ id: 1001, type: 'text', label: 'Lead Name' }, { id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
614
354
  }
615
355
  return new Response(String(init?.body ?? '{}'), { status: 200 })
616
356
  },
617
357
  )
618
-
619
358
  assert.equal(out.kind, 'result')
620
359
  assert.deepEqual(calls, [
621
- { url: 'https://arcubase.example.com/entity/app_1/entity_1', method: 'GET' },
622
- { url: 'https://arcubase.example.com/entity/app_1/entity_1/insert', method: 'POST' },
360
+ { url: 'https://arcubase.example.com/api/entity/app_1/table_1', method: 'GET' },
361
+ { url: 'https://arcubase.example.com/api/entity/app_1/table_1/insert', method: 'POST' },
623
362
  ])
624
363
  assert.equal(out.data.fields['1007'][0].upload_id, 123456)
625
364
  })
626
365
 
627
- test('insert entity decorates upload bind failures with actionable guidance', async () => {
628
- const bodyFile = tempJSONFile({
629
- fields: {
630
- 1001: 'Lead A',
631
- 1007: [
632
- {
633
- upload_id: 123456,
634
- file: 'contract.pdf',
635
- file_name: 'contract.pdf',
636
- meta: {},
637
- },
638
- ],
639
- },
640
- })
641
-
366
+ test('row create decorates upload bind failures with actionable guidance', async () => {
367
+ const bodyFile = tempJSONFile({ fields: { 1001: 'Lead A', 1007: [{ upload_id: 123456, file: 'contract.pdf', file_name: 'contract.pdf', meta: {} }] } })
642
368
  await assert.rejects(async () => {
643
369
  await executeCLI(
644
370
  'user',
645
- ['rows', 'insert-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
371
+ ['row', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
646
372
  env as any,
647
373
  async (url, init) => {
648
- if (String(url) === 'https://arcubase.example.com/entity/app_1/entity_1' && init?.method === 'GET') {
649
- return new Response(JSON.stringify({
650
- data: {
651
- fields: [{ id: 1007, type: 'file', label: 'Contract File' }],
652
- },
653
- }), { status: 200 })
374
+ if (String(url) === 'https://arcubase.example.com/api/entity/app_1/table_1' && init?.method === 'GET') {
375
+ return new Response(JSON.stringify({ data: { fields: [{ id: 1007, type: 'file', label: 'Contract File' }] } }), { status: 200 })
654
376
  }
655
377
  return new Response(JSON.stringify({
656
378
  error: {
657
- message: 'failed to bind upload to assets: Head \"http://test-bucket.test.aliyuncs.com/upload/123456/contract.pdf\": dial tcp: lookup test-bucket.test.aliyuncs.com: no such host',
379
+ message: 'failed to bind upload to assets: Head "http://test-bucket.test.aliyuncs.com/upload/123456/contract.pdf": dial tcp: lookup test-bucket.test.aliyuncs.com: no such host',
658
380
  type: 'system',
659
381
  key: 'error.system',
660
382
  },
@@ -668,239 +390,150 @@ test('insert entity decorates upload bind failures with actionable guidance', as
668
390
  assert.equal(error.details?.traceId, 'req_upload_bind')
669
391
  assert.equal(error.details?.reason, 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record')
670
392
  assert.match(error.details?.hint ?? '', /rerun arcubase upload/)
671
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/uploads.md')))
672
393
  return true
673
394
  })
674
395
  })
675
396
 
676
- test('selection action rejects condition selection without selectAll', async () => {
677
- const bodyFile = tempJSONFile({
678
- selection: {
679
- type: 'condition',
680
- length: 1,
681
- condition: {
682
- q: 'A-100',
683
- },
684
- },
685
- })
686
-
397
+ test('row query rejects camelCase viewMode and points to row crud doc', async () => {
398
+ const bodyFile = tempJSONFile({ limit: 20, offset: 0, viewMode: 'grid' })
687
399
  await assert.rejects(async () => {
688
- await executeCLI(
689
- 'user',
690
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
691
- env as any,
692
- async () => new Response('{}', { status: 200 }),
693
- )
400
+ await executeCLI('user', ['row', 'query', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
694
401
  }, (error: unknown) => {
695
402
  assert.ok(error instanceof CLIError)
696
403
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
697
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.selection.condition.selectAll'))
404
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.viewMode'))
698
405
  return true
699
406
  })
700
407
  })
701
408
 
702
- test('selection action rejects condition selection that uses text_search instead of q/filter keys', async () => {
703
- const bodyFile = tempJSONFile({
704
- selection: {
705
- type: 'condition',
706
- length: 1,
707
- condition: {
708
- text_search: 'A-100',
709
- selectAll: true,
710
- },
711
- },
712
- })
713
-
409
+ test('row update rejects fields key', async () => {
410
+ const bodyFile = tempJSONFile({ fields: { 1002: 99 }, data: { '1002': 99 }, changed_fields: [1002] })
714
411
  await assert.rejects(async () => {
715
- await executeCLI(
716
- 'user',
717
- ['rows', 'query-entity-selection', '--app-id', 'app_1', '--entity-id', 'entity_1', '--action', 'archive', '--body-file', bodyFile],
718
- env as any,
719
- async () => new Response('{}', { status: 200 }),
720
- )
412
+ await executeCLI('user', ['row', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
721
413
  }, (error: unknown) => {
722
414
  assert.ok(error instanceof CLIError)
723
415
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
724
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.selection.condition'))
725
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/search-and-bulk-actions.md')))
416
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields'))
726
417
  return true
727
418
  })
728
419
  })
729
420
 
730
- test('bulk update accepts condition selection with q', async () => {
731
- const bodyFile = tempJSONFile({
732
- selection: {
733
- type: 'condition',
734
- length: 1,
735
- condition: {
736
- q: 'A-100',
737
- selectAll: true,
738
- },
739
- sorts: [
740
- { column: ':1002', order: 'desc' },
741
- ],
742
- },
743
- fields: [
744
- {
745
- id: 1002,
746
- action: {
747
- type: 'number_add',
748
- number_add: 2,
749
- },
750
- },
751
- ],
421
+ test('row update rejects changed_fields that are missing from data', async () => {
422
+ const bodyFile = tempJSONFile({ data: { '1002': 99 }, changed_fields: [1003] })
423
+ await assert.rejects(async () => {
424
+ await executeCLI('user', ['row', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
425
+ }, (error: unknown) => {
426
+ assert.ok(error instanceof CLIError)
427
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
428
+ assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.changed_fields.0'))
429
+ return true
752
430
  })
753
-
754
- const out = await executeCLI(
755
- 'user',
756
- ['entity', 'update-entity-bulk', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
757
- env as any,
758
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
759
- )
760
- assert.equal(out.kind, 'result')
761
- assert.equal(out.data.selection.type, 'condition')
762
- assert.equal(out.data.fields[0].action.type, 'number_add')
763
431
  })
764
432
 
765
- test('entity tags get-selection-tags accepts ids selection', async () => {
766
- const bodyFile = tempJSONFile({
767
- policy_id: '',
768
- selection: {
769
- type: 'ids',
770
- ids: [4000000003],
771
- length: 1,
772
- },
773
- })
774
-
433
+ test('workflow approve maps to fixed action path', async () => {
434
+ const bodyFile = tempJSONFile({ task_id: 1001, fields: {}, changed: [] })
775
435
  const out = await executeCLI(
776
- 'admin',
777
- ['entity-tags', 'get-selection-tags', '--appId', 'app_1', '--entityId', 'entity_1', '--body-file', bodyFile],
436
+ 'user',
437
+ ['workflow', 'approve', '--app-id', 'app_1', '--table-id', 'table_1', '--row-id', 'row_1', '--body-file', bodyFile],
778
438
  env as any,
779
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
439
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : null }), { status: 200 }),
780
440
  )
781
441
  assert.equal(out.kind, 'result')
782
- assert.equal(out.data.selection.type, 'ids')
442
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entity/app_1/table_1/row/row_1/action/2')
443
+ assert.equal(out.data.body.task_id, 1001)
783
444
  })
784
445
 
785
- test('entity tags batch-add accepts tag_names and condition selection', async () => {
786
- const bodyFile = tempJSONFile({
787
- policy_id: '',
788
- selection: {
789
- type: 'condition',
790
- length: 1,
791
- condition: {
792
- q: 'A-100',
793
- selectAll: true,
794
- },
795
- },
796
- tag_names: ['tag-a'],
446
+ test('workflow query-approve-users requires request body', async () => {
447
+ await assert.rejects(async () => {
448
+ await executeCLI('user', ['workflow', 'query-approve-users', '--app-id', 'app_1', '--table-id', 'table_1'], env as any, async () => new Response('{}', { status: 200 }))
449
+ }, (error: unknown) => {
450
+ assert.ok(error instanceof CLIError)
451
+ assert.equal(error.code, 'MISSING_BODY_FILE')
452
+ return true
797
453
  })
798
-
799
- const out = await executeCLI(
800
- 'admin',
801
- ['entity-tags', 'batch-add-entity-tags', '--appId', 'app_1', '--entityId', 'entity_1', '--body-file', bodyFile],
802
- env as any,
803
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
804
- )
805
- assert.equal(out.kind, 'result')
806
- assert.equal(out.data.tag_names[0], 'tag-a')
807
454
  })
808
455
 
809
- test('invoke batch operator accepts backend request shape', async () => {
810
- const bodyFile = tempJSONFile({
811
- id: 'event_1',
812
- data: {
813
- action_id: 'action_1',
814
- policy_id: '',
815
- selection: {
816
- type: 'all',
817
- },
818
- },
819
- params: {},
820
- })
821
-
456
+ test('entry passes include_app_tables query flag', async () => {
822
457
  const out = await executeCLI(
823
458
  'user',
824
- ['entity', 'invoke-entity-batch-operator', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
459
+ ['entry', '--include-app-tables', 'true'],
825
460
  env as any,
826
- async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
461
+ async (url, init) => new Response(JSON.stringify({ url, method: init?.method }), { status: 200 }),
827
462
  )
828
463
  assert.equal(out.kind, 'result')
829
- assert.equal(out.data.id, 'event_1')
830
- assert.equal(out.data.data.action_id, 'action_1')
831
- assert.equal(out.data.data.selection.type, 'all')
464
+ assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
832
465
  })
833
466
 
834
- test('query entity rejects camelCase viewMode and points to row crud doc', async () => {
835
- const bodyFile = tempJSONFile({
836
- limit: 20,
837
- offset: 0,
838
- viewMode: 'grid',
839
- })
840
-
467
+ test('access-rule update rejects enabled without update whitelist', async () => {
841
468
  await assert.rejects(async () => {
842
469
  await executeCLI(
843
- 'user',
844
- ['rows', 'query-entity', '--app-id', 'app_1', '--entity-id', 'entity_1', '--body-file', bodyFile],
470
+ 'admin',
471
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"enabled":true}'],
845
472
  env as any,
846
473
  async () => new Response('{}', { status: 200 }),
847
474
  )
848
475
  }, (error: unknown) => {
849
476
  assert.ok(error instanceof CLIError)
850
477
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
851
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.viewMode'))
852
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/row-crud.md')))
478
+ assert.match(error.message, /update.*enabled/)
479
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
480
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'))
853
481
  return true
854
482
  })
855
483
  })
856
484
 
857
- test('update entity row rejects fields key', async () => {
858
- const bodyFile = tempJSONFile({
859
- fields: {
860
- 1002: 99,
861
- },
862
- data: {
863
- '1002': 99,
864
- },
865
- changed_fields: [1002],
866
- })
485
+ test('access-rule update accepts enabled with update whitelist', async () => {
486
+ const out = await executeCLI(
487
+ 'admin',
488
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["enabled"],"enabled":true}'],
489
+ env as any,
490
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
491
+ )
492
+ assert.equal(out.kind, 'result')
493
+ assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
494
+ })
867
495
 
496
+ test('access-rule assign-users rejects users shortcut', async () => {
868
497
  await assert.rejects(async () => {
869
498
  await executeCLI(
870
- 'user',
871
- ['rows', 'update-entity-row', '--app-id', 'app_1', '--entity-id', 'entity_1', '--row-id', 'row_1', '--body-file', bodyFile],
499
+ 'admin',
500
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'],
872
501
  env as any,
873
502
  async () => new Response('{}', { status: 200 }),
874
503
  )
875
504
  }, (error: unknown) => {
876
505
  assert.ok(error instanceof CLIError)
877
506
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
878
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.fields'))
879
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/row-crud.md')))
507
+ assert.match(error.message, /options.user_scope/)
508
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
880
509
  return true
881
510
  })
882
511
  })
883
512
 
884
- test('update entity row rejects changed_fields that are missing from data', async () => {
885
- const bodyFile = tempJSONFile({
886
- data: {
887
- '1002': 99,
888
- },
889
- changed_fields: [1003],
890
- })
891
-
513
+ test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
892
514
  await assert.rejects(async () => {
893
515
  await executeCLI(
894
- 'user',
895
- ['rows', 'update-entity-row', '--app-id', 'app_1', '--entity-id', 'entity_1', '--row-id', 'row_1', '--body-file', bodyFile],
516
+ 'admin',
517
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[]}}'],
896
518
  env as any,
897
519
  async () => new Response('{}', { status: 200 }),
898
520
  )
899
521
  }, (error: unknown) => {
900
522
  assert.ok(error instanceof CLIError)
901
523
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
902
- assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.changed_fields.0'))
903
- assert.ok((error.details?.docHints ?? []).some((item) => item.file.endsWith('/docs/runtime-reference/row-crud.md')))
524
+ assert.match(error.message, /non-empty/)
904
525
  return true
905
526
  })
906
527
  })
528
+
529
+ test('access-rule assign-users accepts canonical user_scope body', async () => {
530
+ const out = await executeCLI(
531
+ 'admin',
532
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'],
533
+ env as any,
534
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
535
+ )
536
+ assert.equal(out.kind, 'result')
537
+ assert.deepEqual(out.data.update, ['user_scope'])
538
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 123 }])
539
+ })