Skip to content

Commit d495994

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(netsuite): close validation gaps
1 parent c3b4996 commit d495994

19 files changed

Lines changed: 1158 additions & 152 deletions

apps/docs/content/docs/en/integrations/netsuite.mdx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Create a NetSuite record using the account-specific record metadata schema.
124124
| --------- | ---- | ----------- |
125125
| `status` | number | HTTP status returned by NetSuite |
126126
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
127-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
127+
| `location` | string | Newly created record URL from the Location response header |
128128

129129
### NetSuite Update Record
130130

@@ -403,7 +403,7 @@ Submit an asynchronous request to retrieve up to 100 records of one type.
403403
| --------- | ---- | ----------- |
404404
| `status` | number | HTTP status returned by NetSuite |
405405
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
406-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
406+
| `location` | string | Asynchronous job URL from the Location response header |
407407
| `jobId` | string | Asynchronous job ID parsed from the Location header |
408408

409409
### NetSuite Batch Create Records
@@ -419,7 +419,7 @@ Submit an asynchronous batch that creates up to 100 records of one type.
419419
| `certificateId` | string | Yes | Certificate ID from OAuth 2.0 Client Credentials \(M2M\) Setup |
420420
| `privateKey` | string | Yes | Oracle-supported RSA \(3072/4096-bit\) or EC \(P-256/P-384/P-521\) PEM private key matching the certificate uploaded to NetSuite |
421421
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
422-
| `items` | json | Yes | Array of 1-100 records matching the account-specific metadata schema |
422+
| `items` | array | Yes | Array of 1-100 records matching the account-specific metadata schema |
423423
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
424424

425425
#### Output
@@ -428,7 +428,7 @@ Submit an asynchronous batch that creates up to 100 records of one type.
428428
| --------- | ---- | ----------- |
429429
| `status` | number | HTTP status returned by NetSuite |
430430
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
431-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
431+
| `location` | string | Asynchronous job URL from the Location response header |
432432
| `jobId` | string | Asynchronous job ID parsed from the Location header |
433433

434434
### NetSuite Batch Update Records
@@ -444,7 +444,7 @@ Submit an asynchronous batch that updates up to 100 records of one type.
444444
| `certificateId` | string | Yes | Certificate ID from OAuth 2.0 Client Credentials \(M2M\) Setup |
445445
| `privateKey` | string | Yes | Oracle-supported RSA \(3072/4096-bit\) or EC \(P-256/P-384/P-521\) PEM private key matching the certificate uploaded to NetSuite |
446446
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
447-
| `items` | json | Yes | Array of 1-100 records; every item must include an internal or external ID |
447+
| `items` | array | Yes | Array of 1-100 records; every item must include an internal or external ID |
448448
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
449449

450450
#### Output
@@ -453,7 +453,7 @@ Submit an asynchronous batch that updates up to 100 records of one type.
453453
| --------- | ---- | ----------- |
454454
| `status` | number | HTTP status returned by NetSuite |
455455
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
456-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
456+
| `location` | string | Asynchronous job URL from the Location response header |
457457
| `jobId` | string | Asynchronous job ID parsed from the Location header |
458458

459459
### NetSuite Batch Upsert Records
@@ -469,7 +469,7 @@ Submit an asynchronous batch that creates or updates up to 100 records by extern
469469
| `certificateId` | string | Yes | Certificate ID from OAuth 2.0 Client Credentials \(M2M\) Setup |
470470
| `privateKey` | string | Yes | Oracle-supported RSA \(3072/4096-bit\) or EC \(P-256/P-384/P-521\) PEM private key matching the certificate uploaded to NetSuite |
471471
| `recordType` | string | Yes | NetSuite REST record type script ID, such as customer or salesOrder |
472-
| `items` | json | Yes | Array of 1-100 records; every item must include externalId |
472+
| `items` | array | Yes | Array of 1-100 records; every item must include externalId |
473473
| `idempotencyKey` | string | No | Optional unique idempotency key for retrying the batch |
474474

475475
#### Output
@@ -478,7 +478,7 @@ Submit an asynchronous batch that creates or updates up to 100 records by extern
478478
| --------- | ---- | ----------- |
479479
| `status` | number | HTTP status returned by NetSuite |
480480
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
481-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
481+
| `location` | string | Asynchronous job URL from the Location response header |
482482
| `jobId` | string | Asynchronous job ID parsed from the Location header |
483483

484484
### NetSuite Batch Delete Records
@@ -503,7 +503,7 @@ Submit an asynchronous request to delete up to 100 records of one type.
503503
| --------- | ---- | ----------- |
504504
| `status` | number | HTTP status returned by NetSuite |
505505
| `data` | json | NetSuite response body; record fields are account-specific and dynamic |
506-
| `location` | string | Created resource or asynchronous job location returned by NetSuite |
506+
| `location` | string | Asynchronous job URL from the Location response header |
507507
| `jobId` | string | Asynchronous job ID parsed from the Location header |
508508

509509
### NetSuite Execute SuiteQL

apps/sim/blocks/blocks/netsuite.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,66 @@ function getSubBlock(id: string) {
1010
return subBlock
1111
}
1212

13+
const OPERATION_VISIBLE_INPUTS: Record<string, string[]> = {
14+
netsuite_list_records: ['recordType', 'q', 'limit', 'offset'],
15+
netsuite_get_record: ['recordType', 'recordId', 'fields', 'expand', 'expandSubResources'],
16+
netsuite_create_record: ['recordType', 'body'],
17+
netsuite_update_record: ['recordType', 'recordId', 'body', 'replace'],
18+
netsuite_upsert_record: ['recordType', 'externalId', 'body', 'replace'],
19+
netsuite_delete_record: ['recordType', 'recordId'],
20+
netsuite_get_subresource: ['recordType', 'recordId', 'subresourcePath'],
21+
netsuite_get_record_form: [
22+
'recordType',
23+
'recordId',
24+
'fields',
25+
'body',
26+
'expand',
27+
'expandSubResources',
28+
],
29+
netsuite_get_select_options: ['recordType', 'recordId', 'q', 'fields', 'body', 'limit', 'offset'],
30+
netsuite_attach_record: [
31+
'recordType',
32+
'recordId',
33+
'relatedType',
34+
'relatedId',
35+
'roleId',
36+
'roleExternalId',
37+
],
38+
netsuite_detach_record: ['recordType', 'recordId', 'relatedType', 'relatedId'],
39+
netsuite_execute_action: ['recordType', 'recordId', 'body', 'action'],
40+
netsuite_transform_record: ['recordType', 'recordId', 'body', 'targetRecordType'],
41+
netsuite_batch_get_records: [
42+
'recordType',
43+
'fields',
44+
'ids',
45+
'expand',
46+
'expandSubResources',
47+
'idempotencyKey',
48+
],
49+
netsuite_batch_create_records: ['recordType', 'items', 'idempotencyKey'],
50+
netsuite_batch_update_records: ['recordType', 'items', 'idempotencyKey'],
51+
netsuite_batch_upsert_records: ['recordType', 'items', 'idempotencyKey'],
52+
netsuite_batch_delete_records: ['recordType', 'ids', 'idempotencyKey'],
53+
netsuite_execute_suiteql: ['query', 'limit', 'offset'],
54+
netsuite_list_datasets: ['limit', 'offset'],
55+
netsuite_execute_dataset: ['datasetId', 'limit', 'offset'],
56+
netsuite_list_record_types: [],
57+
netsuite_get_record_metadata: ['recordType', 'format'],
58+
netsuite_get_async_status: ['jobId', 'view', 'statusTaskId'],
59+
netsuite_get_async_result: ['jobId', 'resultTaskId'],
60+
netsuite_get_server_time: [],
61+
netsuite_get_governance_limits: [],
62+
}
63+
64+
function conditionIncludesOperation(condition: unknown, operation: string): boolean {
65+
if (!condition || typeof condition !== 'object') return true
66+
const candidate = condition as { field?: unknown; value?: unknown }
67+
if (candidate.field !== 'operation') return false
68+
return Array.isArray(candidate.value)
69+
? candidate.value.includes(operation)
70+
: candidate.value === operation
71+
}
72+
1373
describe('Oracle NetSuite block', () => {
1474
it('exposes exactly the agreed 27 operations and no arbitrary request or RESTlet', () => {
1575
const operation = getSubBlock('operation')
@@ -31,6 +91,29 @@ describe('Oracle NetSuite block', () => {
3191
}
3292
})
3393

94+
it('shows exactly the intended operation-specific inputs for every tool', () => {
95+
const commonFields = new Set([
96+
'operation',
97+
'accountId',
98+
'clientId',
99+
'certificateId',
100+
'privateKey',
101+
])
102+
expect(Object.keys(OPERATION_VISIBLE_INPUTS)).toEqual([...NETSUITE_TOOL_IDS])
103+
104+
for (const operation of NETSUITE_TOOL_IDS) {
105+
const visible = NetSuiteBlock.subBlocks
106+
.filter(
107+
(subBlock) =>
108+
!commonFields.has(subBlock.id) &&
109+
conditionIncludesOperation(subBlock.condition, operation)
110+
)
111+
.map((subBlock) => subBlock.id)
112+
.sort()
113+
expect(visible, operation).toEqual([...OPERATION_VISIBLE_INPUTS[operation]].sort())
114+
}
115+
})
116+
34117
it('covers every operation on canvas and preserves required action targets', () => {
35118
const byOperation = NetSuiteBlock.canvasPresentation?.sentences?.byOperation
36119
if (!byOperation) throw new Error('NetSuite block must define operation canvas sentences')
@@ -84,6 +167,7 @@ describe('Oracle NetSuite block', () => {
84167
clientId: { type: 'string' },
85168
certificateId: { type: 'string' },
86169
privateKey: { type: 'string' },
170+
items: { type: 'array' },
87171
})
88172
})
89173

@@ -215,6 +299,39 @@ describe('Oracle NetSuite block', () => {
215299
expect(
216300
mapParams({ operation: 'netsuite_get_async_result', resultTaskId: 'task-8' })
217301
).toMatchObject({ taskId: 'task-8' })
302+
expect(
303+
mapParams({
304+
operation: 'netsuite_get_async_result',
305+
taskId: 'legacy-task',
306+
statusTaskId: 'stale-status-task',
307+
resultTaskId: 'current-result-task',
308+
})
309+
).toMatchObject({ taskId: 'current-result-task' })
310+
expect(
311+
mapParams({
312+
operation: 'netsuite_get_async_status',
313+
taskId: 'legacy-task',
314+
statusTaskId: 'current-status-task',
315+
resultTaskId: 'stale-result-task',
316+
})
317+
).toMatchObject({ taskId: 'current-status-task' })
318+
expect(
319+
mapParams({
320+
operation: 'netsuite_get_async_result',
321+
taskId: 'legacy-task',
322+
resultTaskId: '',
323+
})
324+
).toMatchObject({ taskId: '' })
325+
expect(
326+
mapParams({
327+
operation: 'netsuite_get_async_status',
328+
taskId: 'legacy-task',
329+
statusTaskId: null,
330+
})
331+
).toMatchObject({ taskId: null })
332+
expect(
333+
mapParams({ operation: 'netsuite_get_async_status', taskId: 'legacy-task' })
334+
).toMatchObject({ taskId: 'legacy-task' })
218335
expect(
219336
mapParams({
220337
operation: 'netsuite_attach_record',
@@ -226,6 +343,15 @@ describe('Oracle NetSuite block', () => {
226343
expect(() => mapParams({ operation: 'netsuite_create_record', body: '{bad json' })).toThrow(
227344
'Record fields must be valid JSON'
228345
)
346+
for (const value of [true, false, [5], {}, Number.NaN, Number.POSITIVE_INFINITY]) {
347+
expect(
348+
() => mapParams({ operation: 'netsuite_list_records', limit: value }),
349+
String(value)
350+
).toThrow('Invalid number for Limit')
351+
}
352+
expect(
353+
mapParams({ operation: 'netsuite_list_records', limit: ' ', offset: null })
354+
).toMatchObject({ limit: undefined, offset: undefined })
229355
})
230356

231357
it('declares every tool parameter input and every common output once', () => {

apps/sim/blocks/blocks/netsuite.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NetSuiteIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
4+
import { parseOptionalNumberInput } from '@/blocks/utils'
45
import type { NetSuiteResponse } from '@/tools/netsuite/types'
56

67
export const NETSUITE_TOOL_IDS = [
@@ -127,11 +128,6 @@ function usesOperation(operation: unknown, operations: readonly string[]): boole
127128
return typeof operation === 'string' && operations.includes(operation)
128129
}
129130

130-
function optionalNumber(value: unknown): number | undefined {
131-
if (value === undefined || value === null || value === '') return undefined
132-
return Number(value)
133-
}
134-
135131
function optionalBoolean(value: unknown): boolean | undefined {
136132
if (value === true || value === 'true') return true
137133
if (value === false || value === 'false') return false
@@ -753,8 +749,17 @@ Return ONLY the comma-separated sublist IDs - no explanations, no extra text.`,
753749
config: {
754750
tool: (params) => params.operation,
755751
params: (params) => {
756-
const { operation, resultTaskId, statusTaskId, ...rest } = params
757-
const taskId = rest.taskId ?? statusTaskId ?? resultTaskId
752+
const { operation, resultTaskId, statusTaskId, taskId: legacyTaskId, ...rest } = params
753+
const taskId =
754+
operation === 'netsuite_get_async_status'
755+
? statusTaskId !== undefined
756+
? statusTaskId
757+
: legacyTaskId
758+
: operation === 'netsuite_get_async_result'
759+
? resultTaskId !== undefined
760+
? resultTaskId
761+
: legacyTaskId
762+
: undefined
758763
return {
759764
...rest,
760765
body: usesOperation(operation, BODY_OPERATIONS)
@@ -764,10 +769,14 @@ Return ONLY the comma-separated sublist IDs - no explanations, no extra text.`,
764769
? parseJson(rest.items, 'Records')
765770
: undefined,
766771
limit: usesOperation(operation, PAGING_OPERATIONS)
767-
? optionalNumber(rest.limit)
772+
? parseOptionalNumberInput(rest.limit, 'Limit', {
773+
integer: true,
774+
min: 1,
775+
max: 1_000,
776+
})
768777
: undefined,
769778
offset: usesOperation(operation, PAGING_OPERATIONS)
770-
? optionalNumber(rest.offset)
779+
? parseOptionalNumberInput(rest.offset, 'Offset', { integer: true, min: 0 })
771780
: undefined,
772781
expandSubResources: usesOperation(operation, RECORD_QUERY_OPERATIONS)
773782
? optionalBoolean(rest.expandSubResources)
@@ -780,10 +789,7 @@ Return ONLY the comma-separated sublist IDs - no explanations, no extra text.`,
780789
operation === 'netsuite_attach_record' && rest.relatedType === 'contact'
781790
? rest.roleExternalId
782791
: undefined,
783-
taskId:
784-
operation === 'netsuite_get_async_status' || operation === 'netsuite_get_async_result'
785-
? taskId
786-
: undefined,
792+
taskId,
787793
}
788794
},
789795
},
@@ -811,7 +817,7 @@ Return ONLY the comma-separated sublist IDs - no explanations, no extra text.`,
811817
action: { type: 'string', description: 'Record action name' },
812818
targetRecordType: { type: 'string', description: 'Record transformation target type' },
813819
ids: { type: 'string', description: 'Comma-separated batch record IDs' },
814-
items: { type: 'json', description: 'Homogeneous batch record collection' },
820+
items: { type: 'array', description: 'Homogeneous batch record collection' },
815821
query: { type: 'string', description: 'SuiteQL query' },
816822
datasetId: { type: 'string', description: 'SuiteAnalytics dataset script ID' },
817823
jobId: { type: 'string', description: 'Asynchronous job ID' },

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/netsuite/batch_create_records.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ export const netsuiteBatchCreateRecordsTool: ToolConfig<
4343
description: 'NetSuite REST record type script ID, such as customer or salesOrder',
4444
},
4545
items: {
46-
type: 'json',
46+
type: 'array',
4747
required: true,
4848
visibility: 'user-or-llm',
4949
description: 'Array of 1-100 records matching the account-specific metadata schema',
50+
items: { type: 'object', additionalProperties: true },
5051
},
5152
idempotencyKey: {
5253
type: 'string',
@@ -67,7 +68,7 @@ export const netsuiteBatchCreateRecordsTool: ToolConfig<
6768
},
6869
location: {
6970
type: 'string',
70-
description: 'Created resource or asynchronous job location returned by NetSuite',
71+
description: 'Asynchronous job URL from the Location response header',
7172
optional: true,
7273
},
7374
jobId: {

apps/sim/tools/netsuite/batch_delete_records.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { NetSuiteBatchDeleteParams, NetSuiteResponse } from '@/tools/netsuite/types'
2-
import { buildRecordPath, executeNetSuiteRequest, normalizeBatchIds } from '@/tools/netsuite/utils'
2+
import {
3+
buildRecordPath,
4+
executeNetSuiteRequest,
5+
normalizeBatchIds,
6+
optionalTrim,
7+
} from '@/tools/netsuite/utils'
38
import type { ToolConfig } from '@/tools/types'
49

510
export const netsuiteBatchDeleteRecordsTool: ToolConfig<
@@ -59,17 +64,19 @@ export const netsuiteBatchDeleteRecordsTool: ToolConfig<
5964
directExecution: (params, signal) =>
6065
executeNetSuiteRequest(
6166
params,
62-
() => ({
63-
method: 'DELETE',
64-
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
65-
query: { ids: normalizeBatchIds(params.ids) },
66-
headers: {
67-
Prefer: 'respond-async',
68-
...(params.idempotencyKey?.trim()
69-
? { 'X-NetSuite-idempotency-key': params.idempotencyKey.trim() }
70-
: {}),
71-
},
72-
}),
67+
() => {
68+
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
69+
return {
70+
method: 'DELETE',
71+
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
72+
responseLocation: 'async-job',
73+
query: { ids: normalizeBatchIds(params.ids) },
74+
headers: {
75+
Prefer: 'respond-async',
76+
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
77+
},
78+
}
79+
},
7380
signal
7481
),
7582
outputs: {
@@ -81,7 +88,7 @@ export const netsuiteBatchDeleteRecordsTool: ToolConfig<
8188
},
8289
location: {
8390
type: 'string',
84-
description: 'Created resource or asynchronous job location returned by NetSuite',
91+
description: 'Asynchronous job URL from the Location response header',
8592
optional: true,
8693
},
8794
jobId: {

0 commit comments

Comments
 (0)