@carthooks/arcubase-cli 0.1.12 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/arcubase-admin.mjs +15019 -3802
- package/bundle/arcubase.mjs +15019 -3802
- package/dist/generated/command_registry.generated.d.ts +108 -0
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +145 -0
- package/dist/generated/zod_registry.generated.d.ts +23 -23
- package/dist/runtime/entity_import_mapping.d.ts +12 -0
- package/dist/runtime/entity_import_mapping.d.ts.map +1 -0
- package/dist/runtime/entity_import_mapping.js +133 -0
- package/dist/runtime/entity_save_schema.d.ts.map +1 -1
- package/dist/runtime/entity_save_schema.js +23 -2
- package/dist/runtime/errors.d.ts +1 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +414 -8
- package/dist/runtime/local_upload.d.ts +10 -0
- package/dist/runtime/local_upload.d.ts.map +1 -0
- package/dist/runtime/local_upload.js +73 -0
- package/dist/tests/entity_save_schema.test.js +38 -0
- package/dist/tests/execute_validation.test.js +64 -2
- package/dist/tests/help.test.js +2 -0
- package/package.json +7 -7
- package/sdk-dist/docs/runtime-reference/README.md +4 -0
- package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/textarea.md +2 -2
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/row-crud.md +68 -0
- package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +26 -1
- package/sdk-dist/docs/runtime-reference/table-lifecycle.md +1 -1
- package/sdk-dist/generated/command_registry.generated.ts +145 -0
- package/sdk-dist/types/common.ts +1 -0
- package/src/generated/command_registry.generated.ts +145 -0
- package/src/runtime/entity_import_mapping.ts +172 -0
- package/src/runtime/entity_save_schema.ts +27 -3
- package/src/runtime/errors.ts +1 -0
- package/src/runtime/execute.ts +434 -10
- package/src/runtime/local_upload.ts +84 -0
- package/src/tests/command_registry.test.ts +4 -0
- package/src/tests/entity_import_mapping.test.ts +87 -0
- package/src/tests/entity_save_schema.test.ts +40 -0
- package/src/tests/execute_validation.test.ts +299 -5
- package/src/tests/help.test.ts +16 -0
- package/src/tests/local_upload.test.ts +47 -0
- package/src/tests/zod_registry.test.ts +27 -0
|
@@ -11,10 +11,12 @@ test('admin surface is restricted to reset white list', () => {
|
|
|
11
11
|
const actual = commandPaths(adminCommands)
|
|
12
12
|
const expected = [
|
|
13
13
|
'app create',
|
|
14
|
+
'app create-from-bundle-url',
|
|
14
15
|
'app delete',
|
|
15
16
|
'app get',
|
|
16
17
|
'app list',
|
|
17
18
|
'app update',
|
|
19
|
+
'app watch-import-task',
|
|
18
20
|
'table create',
|
|
19
21
|
'table delete',
|
|
20
22
|
'table get',
|
|
@@ -45,6 +47,8 @@ test('user surface is restricted to reset white list', () => {
|
|
|
45
47
|
'row create',
|
|
46
48
|
'row delete',
|
|
47
49
|
'row get',
|
|
50
|
+
'row import-csv',
|
|
51
|
+
'row import-excel',
|
|
48
52
|
'row query',
|
|
49
53
|
'row selection-action',
|
|
50
54
|
'row update',
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { compileCSVImportMapping, compileExcelImportMapping } from '../runtime/entity_import_mapping.js'
|
|
4
|
+
import { CLIError } from '../runtime/errors.js'
|
|
5
|
+
|
|
6
|
+
test('excel mapping compiles column letters to backend mapper', () => {
|
|
7
|
+
const result = compileExcelImportMapping({
|
|
8
|
+
sheet: 'Sheet1',
|
|
9
|
+
mode: 'addorupdate',
|
|
10
|
+
headerRow: 1,
|
|
11
|
+
dataStartRow: 2,
|
|
12
|
+
recordId: { column: 'A' },
|
|
13
|
+
fields: [
|
|
14
|
+
{ fieldId: 1001, column: 'B' },
|
|
15
|
+
{ fieldId: 1002, column: 'AA' },
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
assert.deepEqual(result, {
|
|
19
|
+
fileFormat: 'excel',
|
|
20
|
+
importMode: 'addorupdate',
|
|
21
|
+
workingSheet: 'Sheet1',
|
|
22
|
+
titleRowIndex: 0,
|
|
23
|
+
mapper: {
|
|
24
|
+
'$id': 0,
|
|
25
|
+
'1001': 1,
|
|
26
|
+
'1002': 26,
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('excel mapping rejects string field id', () => {
|
|
32
|
+
assert.throws(() => {
|
|
33
|
+
compileExcelImportMapping({
|
|
34
|
+
sheet: 'Sheet1',
|
|
35
|
+
mode: 'addonly',
|
|
36
|
+
dataStartRow: 2,
|
|
37
|
+
fields: [{ fieldId: '1001', column: 'A' }],
|
|
38
|
+
} as any)
|
|
39
|
+
}, (error: unknown) => {
|
|
40
|
+
assert.ok(error instanceof CLIError)
|
|
41
|
+
assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
|
|
42
|
+
assert.match(error.message, /fieldId must be a number/)
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('csv mapping resolves headers to backend mapper', () => {
|
|
48
|
+
const result = compileCSVImportMapping({
|
|
49
|
+
mode: 'updateonly',
|
|
50
|
+
header: true,
|
|
51
|
+
dataStartRow: 2,
|
|
52
|
+
delimiter: ',',
|
|
53
|
+
recordId: { header: 'ID' },
|
|
54
|
+
fields: [
|
|
55
|
+
{ fieldId: 1001, header: '客户名称' },
|
|
56
|
+
{ fieldId: 1002, header: '手机号' },
|
|
57
|
+
],
|
|
58
|
+
}, ['ID', '客户名称', '手机号'])
|
|
59
|
+
assert.deepEqual(result, {
|
|
60
|
+
fileFormat: 'csv',
|
|
61
|
+
importMode: 'updateonly',
|
|
62
|
+
titleRowIndex: 0,
|
|
63
|
+
csvDelimiter: ',',
|
|
64
|
+
mapper: {
|
|
65
|
+
'$id': 0,
|
|
66
|
+
'1001': 1,
|
|
67
|
+
'1002': 2,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('csv mapping missing header lists available headers', () => {
|
|
73
|
+
assert.throws(() => {
|
|
74
|
+
compileCSVImportMapping({
|
|
75
|
+
mode: 'addonly',
|
|
76
|
+
header: true,
|
|
77
|
+
dataStartRow: 2,
|
|
78
|
+
fields: [{ fieldId: 1001, header: 'missing' }],
|
|
79
|
+
}, ['客户名称', '手机号'])
|
|
80
|
+
}, (error: unknown) => {
|
|
81
|
+
assert.ok(error instanceof CLIError)
|
|
82
|
+
assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
|
|
83
|
+
assert.match(error.message, /missing/)
|
|
84
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('客户名称')))
|
|
85
|
+
return true
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -65,6 +65,46 @@ test('EntitySaveReqVO normalizes mechanical FieldVO defaults', () => {
|
|
|
65
65
|
assert.deepEqual(parsed.data.fields[0].depends, [])
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
test('EntitySaveReqVO rejects textarea size as string', () => {
|
|
69
|
+
assert.ok(schema)
|
|
70
|
+
const parsed = schema!.safeParse({
|
|
71
|
+
name: '订单',
|
|
72
|
+
field_id_seq: 1003,
|
|
73
|
+
layout: [[1001], [1002]],
|
|
74
|
+
fields: [
|
|
75
|
+
buildMinimalField(1001),
|
|
76
|
+
{
|
|
77
|
+
...buildMinimalField(1002),
|
|
78
|
+
label: '描述',
|
|
79
|
+
type: 'textarea',
|
|
80
|
+
key: 'description',
|
|
81
|
+
options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
assert.equal(parsed.success, false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('EntitySaveReqVO accepts textarea size as number', () => {
|
|
89
|
+
assert.ok(schema)
|
|
90
|
+
const parsed = schema!.safeParse({
|
|
91
|
+
name: '订单',
|
|
92
|
+
field_id_seq: 1003,
|
|
93
|
+
layout: [[1001], [1002]],
|
|
94
|
+
fields: [
|
|
95
|
+
buildMinimalField(1001),
|
|
96
|
+
{
|
|
97
|
+
...buildMinimalField(1002),
|
|
98
|
+
label: '描述',
|
|
99
|
+
type: 'textarea',
|
|
100
|
+
key: 'description',
|
|
101
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
})
|
|
105
|
+
assert.equal(parsed.success, true)
|
|
106
|
+
})
|
|
107
|
+
|
|
68
108
|
test('EntitySaveReqVO rejects fields as object', () => {
|
|
69
109
|
assert.ok(schema)
|
|
70
110
|
const parsed = schema!.safeParse({
|
|
@@ -146,15 +146,196 @@ test('table update-schema rejects invalid body file', async () => {
|
|
|
146
146
|
})
|
|
147
147
|
|
|
148
148
|
test('app create rejects name longer than 20 chars', async () => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
const bodyFile = tempJSONFile({ name: 'CLI Verify Happy Path' })
|
|
150
|
+
await assert.rejects(async () => {
|
|
151
|
+
await executeCLI('admin', ['app', 'create', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
|
|
152
152
|
}, (error: unknown) => {
|
|
153
153
|
assert.ok(error instanceof CLIError)
|
|
154
154
|
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
155
155
|
assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.name'))
|
|
156
156
|
return true
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('app create-from-bundle-url posts url body and returns task id', async () => {
|
|
161
|
+
const calls: Array<{ url: string; method?: string; body?: string }> = []
|
|
162
|
+
const out = await executeCLI(
|
|
163
|
+
'admin',
|
|
164
|
+
['app', 'create-from-bundle-url', '--url', 'https://cdn.example.com/app-bundles/2026/06/17/a.zip', '--name', 'Demo'],
|
|
165
|
+
env as any,
|
|
166
|
+
async (url, init) => {
|
|
167
|
+
calls.push({ url: String(url), method: init?.method, body: String(init?.body ?? '') })
|
|
168
|
+
return new Response(JSON.stringify({ data: { task_id: 'task-1' } }), { status: 200 })
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
assert.equal(calls[0].method, 'POST')
|
|
172
|
+
assert.match(calls[0].url, /\/api\/apps\/import-task$/)
|
|
173
|
+
assert.deepEqual(JSON.parse(calls[0].body ?? '{}'), {
|
|
174
|
+
url: 'https://cdn.example.com/app-bundles/2026/06/17/a.zip',
|
|
175
|
+
name: 'Demo',
|
|
176
|
+
})
|
|
177
|
+
assert.equal((out as any).data.data.task_id, 'task-1')
|
|
178
|
+
assert.deepEqual((out as any).nextAction, {
|
|
179
|
+
command: 'app watch-import-task',
|
|
180
|
+
args: ['--task-id', 'task-1', '--ttl-seconds', '300'],
|
|
181
|
+
hint: 'run this command until the task returns status=finished and app_id > 0; task_id alone is not success',
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('app create --url points to bundle import flow', async () => {
|
|
186
|
+
await assert.rejects(async () => {
|
|
187
|
+
await executeCLI(
|
|
188
|
+
'admin',
|
|
189
|
+
['app', 'create', '--url', 'https://cdn.example.com/app-bundles/2026/06/17/a.zip', '--name', 'Demo'],
|
|
190
|
+
env as any,
|
|
191
|
+
async () => new Response('{}', { status: 200 }),
|
|
192
|
+
)
|
|
193
|
+
}, (error: unknown) => {
|
|
194
|
+
assert.ok(error instanceof CLIError)
|
|
195
|
+
assert.equal(error.code, 'UNKNOWN_FLAG')
|
|
196
|
+
assert.equal(error.details?.hint, 'app bundle URLs must use app create-from-bundle-url; do not retry app create')
|
|
197
|
+
assert.deepEqual(error.details?.commonMistakes, [
|
|
198
|
+
'do not use app create for app bundle URLs',
|
|
199
|
+
'do not put url inside app create --body-json',
|
|
200
|
+
])
|
|
201
|
+
assert.deepEqual(error.details?.suggestions, [
|
|
202
|
+
'retry with: app create-from-bundle-url --url <public_http_zip_url> --name <app_name>',
|
|
203
|
+
'then watch with: app watch-import-task --task-id <task_id> --ttl-seconds 300',
|
|
204
|
+
])
|
|
205
|
+
return true
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('app create-from-bundle-url rejects unsupported url scheme before request', async () => {
|
|
210
|
+
let called = false
|
|
211
|
+
await assert.rejects(async () => {
|
|
212
|
+
await executeCLI(
|
|
213
|
+
'admin',
|
|
214
|
+
['app', 'create-from-bundle-url', '--url', 'file:///tmp/app.zip', '--name', 'Demo'],
|
|
215
|
+
env as any,
|
|
216
|
+
async () => {
|
|
217
|
+
called = true
|
|
218
|
+
return new Response('{}', { status: 200 })
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
}, (error: unknown) => {
|
|
222
|
+
assert.ok(error instanceof CLIError)
|
|
223
|
+
assert.equal(error.code, 'INVALID_IMPORT_URL')
|
|
224
|
+
assert.equal(called, false)
|
|
225
|
+
return true
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('app watch-import-task polls until finished', async () => {
|
|
230
|
+
const statuses = [
|
|
231
|
+
{ data: { task_id: 'task-1', status: 'processing' } },
|
|
232
|
+
{ data: { task_id: 'task-1', status: 'finished', app_id: 2188889999 } },
|
|
233
|
+
]
|
|
234
|
+
const calls: string[] = []
|
|
235
|
+
const out = await executeCLI(
|
|
236
|
+
'admin',
|
|
237
|
+
['app', 'watch-import-task', '--task-id', 'task-1', '--ttl-seconds', '1'],
|
|
238
|
+
{ ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
|
|
239
|
+
async (url) => {
|
|
240
|
+
calls.push(String(url))
|
|
241
|
+
return new Response(JSON.stringify(statuses.shift()), { status: 200 })
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
assert.equal(calls.length, 2)
|
|
245
|
+
assert.equal((out as any).data.data.status, 'finished')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('app watch-import-task rejects finished status without app id', async () => {
|
|
249
|
+
await assert.rejects(async () => {
|
|
250
|
+
await executeCLI(
|
|
251
|
+
'admin',
|
|
252
|
+
['app', 'watch-import-task', '--task-id', 'task-1', '--ttl-seconds', '1'],
|
|
253
|
+
{ ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
|
|
254
|
+
async () => new Response(JSON.stringify({ data: { task_id: 'task-1', status: 'finished' } }), { status: 200 }),
|
|
255
|
+
)
|
|
256
|
+
}, (error: unknown) => {
|
|
257
|
+
assert.ok(error instanceof CLIError)
|
|
258
|
+
assert.equal(error.code, 'IMPORT_TASK_APP_ID_MISSING')
|
|
259
|
+
return true
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('row import-csv validates mapping, uploads local file, starts import, and watches task', async () => {
|
|
264
|
+
const csvFile = tempJSONFile('ID,客户名称\n12,Alice\n').replace(/payload\.json$/, 'records.csv')
|
|
265
|
+
fs.writeFileSync(csvFile, 'ID,客户名称\n12,Alice\n')
|
|
266
|
+
const mappingFile = tempJSONFile({
|
|
267
|
+
mode: 'addorupdate',
|
|
268
|
+
header: true,
|
|
269
|
+
dataStartRow: 2,
|
|
270
|
+
recordId: { header: 'ID' },
|
|
271
|
+
fields: [{ fieldId: 1001, header: '客户名称' }],
|
|
157
272
|
})
|
|
273
|
+
const calls: Array<{ url: string; method?: string; body?: string; isForm?: boolean }> = []
|
|
274
|
+
const out = await executeCLI(
|
|
275
|
+
'user',
|
|
276
|
+
['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile, '--ttl-seconds', '1'],
|
|
277
|
+
{ ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
|
|
278
|
+
async (url, init) => {
|
|
279
|
+
calls.push({
|
|
280
|
+
url: String(url),
|
|
281
|
+
method: init?.method,
|
|
282
|
+
body: typeof init?.body === 'string' ? init.body : undefined,
|
|
283
|
+
isForm: init?.body instanceof FormData,
|
|
284
|
+
})
|
|
285
|
+
if (String(url).endsWith('/api/entity/app_1/table_1/import') && init?.method === 'POST') {
|
|
286
|
+
const body = JSON.parse(String(init.body))
|
|
287
|
+
if (body._import_action === 'init-token') {
|
|
288
|
+
return new Response(JSON.stringify({ data: { taskId: 'task-1', token: { id: 123, mode: 's3', token_data: { host: 'https://uploads.example.com', dir: 'upload/123/', policy: 'p', signature: 's', accessid: 'a' } } } }), { status: 200 })
|
|
289
|
+
}
|
|
290
|
+
if (body._import_action === 'uploaded') {
|
|
291
|
+
return new Response(JSON.stringify({ data: 'task-1' }), { status: 200 })
|
|
292
|
+
}
|
|
293
|
+
if (body._import_action === 'do-process') {
|
|
294
|
+
assert.deepEqual(body.config, {
|
|
295
|
+
fileFormat: 'csv',
|
|
296
|
+
importMode: 'addorupdate',
|
|
297
|
+
titleRowIndex: 0,
|
|
298
|
+
csvDelimiter: ',',
|
|
299
|
+
mapper: { '$id': 0, '1001': 1 },
|
|
300
|
+
})
|
|
301
|
+
return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'processing', state: {} } }), { status: 200 })
|
|
302
|
+
}
|
|
303
|
+
if (body._import_action === 'get-state') {
|
|
304
|
+
return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'finished', state: { state: { SuccessNum: 1 } } } }), { status: 200 })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (String(url) === 'https://uploads.example.com') {
|
|
308
|
+
return new Response(null, { status: 204 })
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`unexpected fetch ${url}`)
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
assert.equal(out.kind, 'result')
|
|
314
|
+
assert.equal(out.data.data.status, 'finished')
|
|
315
|
+
assert.ok(calls.some((call) => call.isForm), 'expected local file upload form request')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('row import-csv missing header fails before fetch', async () => {
|
|
319
|
+
const csvFile = tempJSONFile('Name\nAlice\n').replace(/payload\.json$/, 'records.csv')
|
|
320
|
+
fs.writeFileSync(csvFile, 'Name\nAlice\n')
|
|
321
|
+
const mappingFile = tempJSONFile({
|
|
322
|
+
mode: 'addonly',
|
|
323
|
+
header: true,
|
|
324
|
+
dataStartRow: 2,
|
|
325
|
+
fields: [{ fieldId: 1001, header: 'Missing' }],
|
|
326
|
+
})
|
|
327
|
+
let called = false
|
|
328
|
+
await assert.rejects(async () => {
|
|
329
|
+
await executeCLI('user', ['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile], env as any, async () => {
|
|
330
|
+
called = true
|
|
331
|
+
return new Response('{}')
|
|
332
|
+
})
|
|
333
|
+
}, (error: unknown) => {
|
|
334
|
+
assert.ok(error instanceof CLIError)
|
|
335
|
+
assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
|
|
336
|
+
return true
|
|
337
|
+
})
|
|
338
|
+
assert.equal(called, false)
|
|
158
339
|
})
|
|
159
340
|
|
|
160
341
|
test('table create rejects empty schema body before request', async () => {
|
|
@@ -281,6 +462,38 @@ test('body json parse errors include command docs and type hints', async () => {
|
|
|
281
462
|
})
|
|
282
463
|
})
|
|
283
464
|
|
|
465
|
+
test('access-rule invalid json returns copyable create retry examples', async () => {
|
|
466
|
+
await assert.rejects(async () => {
|
|
467
|
+
await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"Admin full access","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889977}],"policy":{"key":"custom","actions":["view","add","edit","delete"],"all_fields_read":true,"all_fields_write":true},"list_options":{}}'], env as any, async () => new Response('{}', { status: 200 }))
|
|
468
|
+
}, (error: unknown) => {
|
|
469
|
+
assert.ok(error instanceof CLIError)
|
|
470
|
+
assert.equal(error.code, 'INVALID_BODY_JSON')
|
|
471
|
+
assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
|
|
472
|
+
assert.match(error.message, /retryToolCalls/)
|
|
473
|
+
const toolCall = (error.details?.retryToolCalls ?? [])[0] as any
|
|
474
|
+
assert.ok(toolCall)
|
|
475
|
+
assert.equal(toolCall.command, 'access-rule create')
|
|
476
|
+
assert.deepEqual(toolCall.args.slice(0, 5), ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json'])
|
|
477
|
+
const body = JSON.parse(toolCall.args[5])
|
|
478
|
+
assert.deepEqual(body, {
|
|
479
|
+
name: 'Admin full access',
|
|
480
|
+
enabled: true,
|
|
481
|
+
type: 'form',
|
|
482
|
+
options: {
|
|
483
|
+
user_scope: [{ type: 'user', id: 2188889977 }],
|
|
484
|
+
policy: {
|
|
485
|
+
key: 'custom',
|
|
486
|
+
actions: ['view', 'add', 'edit', 'delete'],
|
|
487
|
+
all_fields_read: true,
|
|
488
|
+
all_fields_write: true,
|
|
489
|
+
},
|
|
490
|
+
list_options: {},
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
return true
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
284
497
|
test('access-rule list executes against external root path', async () => {
|
|
285
498
|
const out = await executeCLI(
|
|
286
499
|
'admin',
|
|
@@ -355,7 +568,7 @@ test('table update-schema rejects per-user permission patches', async () => {
|
|
|
355
568
|
layout: [[1001], [1002]],
|
|
356
569
|
fields: [
|
|
357
570
|
{ id: 1001, label: 'Title', type: 'text', required: true, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'title', code_index: false, options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 }, transfers: null, children: null },
|
|
358
|
-
{ id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size:
|
|
571
|
+
{ id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 }, transfers: null, children: null, permission: { user: { read: false } } },
|
|
359
572
|
],
|
|
360
573
|
options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
|
|
361
574
|
workflow_enabled: false,
|
|
@@ -477,6 +690,43 @@ test('row get resolves internal policy_id as query parameter', async () => {
|
|
|
477
690
|
assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1')
|
|
478
691
|
})
|
|
479
692
|
|
|
693
|
+
test('row get returns machine-readable row value evidence', async () => {
|
|
694
|
+
const out = await executeCLI(
|
|
695
|
+
'user',
|
|
696
|
+
['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
|
|
697
|
+
env as any,
|
|
698
|
+
withRowPolicyResolver('2188900694', ['view'], async () => new Response(JSON.stringify({
|
|
699
|
+
data: {
|
|
700
|
+
record: {
|
|
701
|
+
id: 4000000046,
|
|
702
|
+
fields: { 1001: '复测投票' },
|
|
703
|
+
},
|
|
704
|
+
fieldPermits: {
|
|
705
|
+
all_fields_read: false,
|
|
706
|
+
fields: [
|
|
707
|
+
{ key: ':1001', read: true, write: true, report: false },
|
|
708
|
+
{ key: ':1002', read: false, write: false, report: false },
|
|
709
|
+
{ key: ':1003', read: true, write: true, report: false },
|
|
710
|
+
],
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
}), { status: 200 })),
|
|
714
|
+
)
|
|
715
|
+
assert.deepEqual(out.rowValueEvidence, {
|
|
716
|
+
source: 'row get record.fields',
|
|
717
|
+
returnedFieldIds: ['1001'],
|
|
718
|
+
notReturnedFieldIds: ['1002', '1003'],
|
|
719
|
+
deniedReadFieldIds: ['1002'],
|
|
720
|
+
absentFieldMeaning: 'not returned to current user; no row-value evidence',
|
|
721
|
+
rules: [
|
|
722
|
+
'current row values come only from row query fields or row get record.fields',
|
|
723
|
+
'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
|
|
724
|
+
'a field id absent from returned row fields has no row-value evidence',
|
|
725
|
+
'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
|
|
726
|
+
],
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
480
730
|
test('row command rejects user-provided policy_id', async () => {
|
|
481
731
|
const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' })
|
|
482
732
|
await assert.rejects(async () => {
|
|
@@ -701,6 +951,45 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
|
|
|
701
951
|
})
|
|
702
952
|
})
|
|
703
953
|
|
|
954
|
+
test('row query rejects invalid search shape through EntityQueryReqVO zod schema', async () => {
|
|
955
|
+
for (const search of [
|
|
956
|
+
{ conditions: [{ field_id: 1002, op: 'eq', value: 'member_1' }] },
|
|
957
|
+
{ field_filters: [{ field_id: 1002, operator: 'eq', value: 'member_1' }] },
|
|
958
|
+
{ q: ['SO-1001'] },
|
|
959
|
+
]) {
|
|
960
|
+
const bodyFile = tempJSONFile({ limit: 20, offset: 0, search })
|
|
961
|
+
let dataQueryFetchCount = 0
|
|
962
|
+
await assert.rejects(async () => {
|
|
963
|
+
await executeCLI(
|
|
964
|
+
'user',
|
|
965
|
+
['row', 'query', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
|
|
966
|
+
env as any,
|
|
967
|
+
withRowPolicyResolver('table_1', ['view'], async () => {
|
|
968
|
+
dataQueryFetchCount += 1
|
|
969
|
+
return new Response('{}', { status: 200 })
|
|
970
|
+
}),
|
|
971
|
+
)
|
|
972
|
+
}, (error: unknown) => {
|
|
973
|
+
assert.ok(error instanceof CLIError)
|
|
974
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
975
|
+
assert.equal(error.details?.requestType, 'EntityQueryReqVO')
|
|
976
|
+
assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.search.')))
|
|
977
|
+
assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityQueryReqVO' && item.file === '/runtime/arcubase-sdk/types/user-action.ts'))
|
|
978
|
+
assert.deepEqual(error.details?.shapeHint, {
|
|
979
|
+
limit: 20,
|
|
980
|
+
offset: 0,
|
|
981
|
+
search: { q: 'SO-1001' },
|
|
982
|
+
sorts: [{ column: ':1001', order: 'asc' }],
|
|
983
|
+
})
|
|
984
|
+
assert.ok(error.details?.commonMistakes?.some((item) => item.includes('string route-query keys')))
|
|
985
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('"search":{"q":"SO-1001"}')))
|
|
986
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('"filter_:1002"')))
|
|
987
|
+
return true
|
|
988
|
+
})
|
|
989
|
+
assert.equal(dataQueryFetchCount, 0)
|
|
990
|
+
}
|
|
991
|
+
})
|
|
992
|
+
|
|
704
993
|
test('row query unknown limit flag gives body-json retry path', async () => {
|
|
705
994
|
await assert.rejects(async () => {
|
|
706
995
|
await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
|
|
@@ -708,7 +997,12 @@ test('row query unknown limit flag gives body-json retry path', async () => {
|
|
|
708
997
|
assert.ok(error instanceof CLIError)
|
|
709
998
|
assert.equal(error.code, 'UNKNOWN_FLAG')
|
|
710
999
|
assert.equal(error.details?.requestType, 'EntityQueryReqVO')
|
|
711
|
-
assert.deepEqual(error.details?.shapeHint, {
|
|
1000
|
+
assert.deepEqual(error.details?.shapeHint, {
|
|
1001
|
+
limit: 20,
|
|
1002
|
+
offset: 0,
|
|
1003
|
+
search: { q: 'SO-1001' },
|
|
1004
|
+
sorts: [{ column: ':1001', order: 'asc' }],
|
|
1005
|
+
})
|
|
712
1006
|
assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
|
|
713
1007
|
return true
|
|
714
1008
|
})
|
package/src/tests/help.test.ts
CHANGED
|
@@ -66,6 +66,20 @@ test('user row help does not expose internal policy id input', async () => {
|
|
|
66
66
|
assert.doesNotMatch(out.text, /policy_id/)
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
+
test('row import help explains mapping formats and local files', async () => {
|
|
70
|
+
const excel = await executeCLI('user', ['row', 'import-excel', '--help'], env as any, async () => new Response('{}'))
|
|
71
|
+
assert.equal(excel.kind, 'help')
|
|
72
|
+
assert.match(excel.text, /mapping-file/)
|
|
73
|
+
assert.match(excel.text, /Excel columns use letters/)
|
|
74
|
+
assert.match(excel.text, /local file/)
|
|
75
|
+
|
|
76
|
+
const csv = await executeCLI('user', ['row', 'import-csv', '--help'], env as any, async () => new Response('{}'))
|
|
77
|
+
assert.equal(csv.kind, 'help')
|
|
78
|
+
assert.match(csv.text, /CSV mapping uses headers/)
|
|
79
|
+
assert.match(csv.text, /does not support embedded images/)
|
|
80
|
+
assert.match(csv.text, /arcubase-admin table get/)
|
|
81
|
+
})
|
|
82
|
+
|
|
69
83
|
test('admin table update-schema help uses table nouns and body-json', async () => {
|
|
70
84
|
const out = await executeCLI('admin', ['table', 'update-schema', '--help'], env as any, async () => new Response('{}'))
|
|
71
85
|
assert.equal(out.kind, 'help')
|
|
@@ -90,6 +104,8 @@ test('admin access-rule help gives update whitelist and assign-users body-json e
|
|
|
90
104
|
assert.equal(create.kind, 'help')
|
|
91
105
|
assert.match(create.text, /Access rule/)
|
|
92
106
|
assert.match(create.text, /access-rule\.md/)
|
|
107
|
+
assert.match(create.text, /full access/)
|
|
108
|
+
assert.match(create.text, /"actions":\["view","add","edit","delete"\]/)
|
|
93
109
|
assert.match(create.text, /"user_scope":\[\{"type":"user","id":2188889901\}\]/)
|
|
94
110
|
assert.doesNotMatch(create.text, /"user_scope":\[\{"type":"member"/)
|
|
95
111
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import { uploadLocalFileWithToken } from '../runtime/local_upload.js'
|
|
7
|
+
import { CLIError } from '../runtime/errors.js'
|
|
8
|
+
|
|
9
|
+
function tempFile(name: string, content = 'hello'): string {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcubase-upload-'))
|
|
11
|
+
const file = path.join(dir, name)
|
|
12
|
+
fs.writeFileSync(file, content)
|
|
13
|
+
return file
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('uploadLocalFileWithToken uploads aliyun-compatible post policy', async () => {
|
|
17
|
+
const file = tempFile('records.csv')
|
|
18
|
+
const calls: Array<{ url: string; method?: string; body: any }> = []
|
|
19
|
+
await uploadLocalFileWithToken(file, {
|
|
20
|
+
mode: 's3',
|
|
21
|
+
token_data: {
|
|
22
|
+
host: 'https://uploads.example.com',
|
|
23
|
+
dir: 'upload/123/',
|
|
24
|
+
policy: 'policy',
|
|
25
|
+
signature: 'signature',
|
|
26
|
+
accessid: 'access',
|
|
27
|
+
expire: 123,
|
|
28
|
+
},
|
|
29
|
+
}, async (url, init) => {
|
|
30
|
+
calls.push({ url: String(url), method: init?.method, body: init?.body })
|
|
31
|
+
return new Response(null, { status: 204 })
|
|
32
|
+
})
|
|
33
|
+
assert.equal(calls[0].url, 'https://uploads.example.com')
|
|
34
|
+
assert.equal(calls[0].method, 'POST')
|
|
35
|
+
assert.ok(calls[0].body instanceof FormData)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('uploadLocalFileWithToken rejects unsupported token shape', async () => {
|
|
39
|
+
const file = tempFile('records.csv')
|
|
40
|
+
await assert.rejects(async () => {
|
|
41
|
+
await uploadLocalFileWithToken(file, { mode: 'unknown', token_data: {} }, async () => new Response(''))
|
|
42
|
+
}, (error: unknown) => {
|
|
43
|
+
assert.ok(error instanceof CLIError)
|
|
44
|
+
assert.equal(error.code, 'UPLOAD_TOKEN_UNSUPPORTED')
|
|
45
|
+
return true
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -14,6 +14,33 @@ test('EntityQueryReqVO schema exists', () => {
|
|
|
14
14
|
assert.ok(getBodySchema('EntityQueryReqVO'))
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
+
test('EntityQueryReqVO search only accepts route-query string keys', () => {
|
|
18
|
+
const schema = getBodySchema('EntityQueryReqVO')
|
|
19
|
+
assert.ok(schema)
|
|
20
|
+
|
|
21
|
+
assert.equal(schema.safeParse({
|
|
22
|
+
limit: 100,
|
|
23
|
+
offset: 0,
|
|
24
|
+
search: {
|
|
25
|
+
q: 'SO-1001',
|
|
26
|
+
tab: 'all',
|
|
27
|
+
'filter_:1002': 'eq:member_1',
|
|
28
|
+
},
|
|
29
|
+
}).success, true)
|
|
30
|
+
|
|
31
|
+
for (const search of [
|
|
32
|
+
{ conditions: [{ field_id: 1002, op: 'eq', value: 'member_1' }] },
|
|
33
|
+
{ condition: { field_id: 1002, op: 'eq', value: 'member_1' } },
|
|
34
|
+
{ field_filters: [{ field_id: 1002, operator: 'eq', value: 'member_1' }] },
|
|
35
|
+
{ filter: { field_id: 1002, value: 'member_1' } },
|
|
36
|
+
{ order: [{ column: ':1001', order: 'asc' }] },
|
|
37
|
+
{ q: ['SO-1001'] },
|
|
38
|
+
{ 'filter_:1002': { op: 'eq', value: 'member_1' } },
|
|
39
|
+
]) {
|
|
40
|
+
assert.equal(schema.safeParse({ limit: 100, offset: 0, search }).success, false)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
17
44
|
test('unknown request type has no schema and no unsupported reason', () => {
|
|
18
45
|
assert.equal(getBodySchema('DefinitelyMissingReqVO'), null)
|
|
19
46
|
assert.equal(getUnsupportedBodySchemaReason('DefinitelyMissingReqVO'), null)
|