Skip to content

Commit 8db4aa1

Browse files
committed
fix(netsuite): surface upsert/transform Location, relax task link check
Oracle documents the Location response header for create and update, and both tools already require it. Upsert and transform also produce a record but Oracle documents no response headers for either, so they dropped the header entirely and the new record's ID was unreachable. Add a `resource-optional` location mode that captures Location when NetSuite sends it and never fails when it is absent, and wire it to upsert and transform along with their tool and block outputs. Async task discovery rejected the whole response if any task link carried a rel other than `self`, collapsing the picker into a 502. Oracle documents a `self` link per task but never guarantees it is the only one, so skip other relationships and fail only when no self link exists. Also use the shared `truncate` helper in the error sanitizer per the repo convention instead of an inline slice.
1 parent a380c44 commit 8db4aa1

9 files changed

Lines changed: 118 additions & 10 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Create or update a NetSuite record by external ID with PUT.
159159
| --------- | ---- | ----------- |
160160
| `status` | number | HTTP status returned by NetSuite |
161161
| `data` | json | Empty for the documented HTTP 204 No Content response |
162+
| `location` | string | URL of the created or updated record, when NetSuite returns a Location header |
162163

163164
### NetSuite Delete Record
164165

@@ -327,6 +328,7 @@ Transform a supported source record into another NetSuite record type.
327328
| --------- | ---- | ----------- |
328329
| `status` | number | HTTP status returned by NetSuite |
329330
| `data` | json | Empty for the documented HTTP 204 No Content response |
331+
| `location` | string | URL of the transformed record, when NetSuite returns a Location header |
330332

331333
### NetSuite Batch Get Records
332334

apps/sim/app/api/tools/netsuite/objects/route.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,49 @@ describe('POST /api/tools/netsuite/objects', () => {
234234
})
235235
})
236236

237+
it('skips non-self task link relationships instead of failing discovery', async () => {
238+
const href = `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1`
239+
mockGetAsyncStatus.mockResolvedValueOnce(
240+
success({
241+
items: [
242+
{
243+
links: [
244+
{ rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` },
245+
{ rel: 'self', href },
246+
],
247+
},
248+
],
249+
})
250+
)
251+
252+
const response = await POST(
253+
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
254+
{}
255+
)
256+
257+
expect(response.status).toBe(200)
258+
expect(await json(response)).toEqual({
259+
objects: [{ id: 'task-1', label: 'task-1', detail: null }],
260+
})
261+
})
262+
263+
it('fails discovery when a task entry has no self link', async () => {
264+
mockGetAsyncStatus.mockResolvedValueOnce(
265+
success({
266+
items: [
267+
{ links: [{ rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` }] },
268+
],
269+
})
270+
)
271+
272+
const response = await POST(
273+
request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }),
274+
{}
275+
)
276+
277+
expect(response.status).toBe(502)
278+
})
279+
237280
it('normalizes, deduplicates, and sorts up to 1,000 unique record types', async () => {
238281
const items = Array.from({ length: 1_000 }, (_, index) => ({
239282
name: `record_${String(999 - index).padStart(4, '0')}`,

apps/sim/app/api/tools/netsuite/objects/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,14 @@ function normalizeAsyncTasks(
152152
if (!Array.isArray(item.links) || item.links.length === 0 || !item.links.every(isPlainRecord)) {
153153
throw new Error('NetSuite returned malformed asynchronous task links')
154154
}
155-
for (const link of item.links) {
156-
if (link.rel !== 'self') {
157-
throw new Error('NetSuite returned an unexpected asynchronous task link relationship')
158-
}
155+
// Oracle documents a `self` link per task but never guarantees it is the
156+
// only relationship on the entry, so additional rels are skipped rather
157+
// than failing the whole picker.
158+
const selfLinks = item.links.filter((link) => link.rel === 'self')
159+
if (selfLinks.length === 0) {
160+
throw new Error('NetSuite returned an asynchronous task without a self link')
161+
}
162+
for (const link of selfLinks) {
159163
const id = taskIdFromHref(link.href, origin, jobId)
160164
if (!objects.has(id)) {
161165
if (objects.size >= MAX_ASYNC_TASKS) {

apps/sim/blocks/blocks/netsuite.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ const BATCH_OPERATIONS = [
8585
const LOCATION_OPERATIONS = [
8686
'netsuite_create_record',
8787
'netsuite_update_record',
88+
'netsuite_upsert_record',
89+
'netsuite_transform_record',
8890
...BATCH_OPERATIONS,
8991
]
9092

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

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

apps/sim/tools/netsuite/netsuite.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const SOURCE_MATRIX: SourceMatrixEntry[] = [
286286
path: '/services/rest/record/v1/customer/eid:EXT-7',
287287
body: { companyName: 'Acme' },
288288
successStatus: 204,
289+
locationPath: '/services/rest/record/v1/customer/647',
289290
execute: invoke(netsuiteUpsertRecordTool, {
290291
recordType: 'customer',
291292
externalId: 'EXT-7',
@@ -391,6 +392,7 @@ const SOURCE_MATRIX: SourceMatrixEntry[] = [
391392
path: '/services/rest/record/v1/salesOrder/3/!transform/invoice',
392393
body: { memo: 'Automated' },
393394
successStatus: 204,
395+
locationPath: '/services/rest/record/v1/invoice/912',
394396
execute: invoke(netsuiteTransformRecordTool, {
395397
recordType: 'salesOrder',
396398
recordId: '3',
@@ -751,6 +753,33 @@ describe('NetSuite operation contracts', () => {
751753
)
752754
})
753755

756+
it('treats the upsert and transform Location header as optional', async () => {
757+
vi.stubGlobal(
758+
'fetch',
759+
vi.fn(async () => new Response(null, { status: 204 }))
760+
)
761+
762+
const upserted = await invoke(netsuiteUpsertRecordTool, {
763+
recordType: 'customer',
764+
externalId: 'EXT-7',
765+
body: { companyName: 'Acme' },
766+
})()
767+
const transformed = await invoke(netsuiteTransformRecordTool, {
768+
recordType: 'salesOrder',
769+
recordId: '3',
770+
targetRecordType: 'invoice',
771+
body: { memo: 'Automated' },
772+
})()
773+
774+
// Oracle documents the Location header for create and update but not for
775+
// upsert or transform, so a response without it must still succeed.
776+
for (const result of [upserted, transformed]) {
777+
expect(result.success).toBe(true)
778+
expect(result.output?.status).toBe(204)
779+
expect(result.output?.location).toBeUndefined()
780+
}
781+
})
782+
754783
it('validates required metadata items and optional catalog and record-type links', async () => {
755784
const cases = [
756785
{ body: { items: [{ name: 'customer' }] }, success: true },
@@ -897,7 +926,13 @@ describe('NetSuite operation contracts', () => {
897926
]
898927
expect(NetSuiteBlock.outputs.location.condition).toEqual({
899928
field: 'operation',
900-
value: ['netsuite_create_record', 'netsuite_update_record', ...batchIds],
929+
value: [
930+
'netsuite_create_record',
931+
'netsuite_update_record',
932+
'netsuite_upsert_record',
933+
'netsuite_transform_record',
934+
...batchIds,
935+
],
901936
})
902937
expect(NetSuiteBlock.outputs.jobId.condition).toEqual({
903938
field: 'operation',
@@ -1046,6 +1081,8 @@ describe('NetSuite operation contracts', () => {
10461081
if (
10471082
tool.id === 'netsuite_create_record' ||
10481083
tool.id === 'netsuite_update_record' ||
1084+
tool.id === 'netsuite_upsert_record' ||
1085+
tool.id === 'netsuite_transform_record' ||
10491086
batchToolIds.has(tool.id)
10501087
) {
10511088
expected.push('location')

apps/sim/tools/netsuite/transform_record.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const netsuiteTransformRecordTool: ToolConfig<
5454
{ value: params.targetRecordType, label: 'Target record type' }
5555
),
5656
success: { status: 204, body: 'none' },
57+
responseLocation: 'resource-optional',
5758
body: params.body ?? {},
5859
}),
5960
signal
@@ -65,5 +66,10 @@ export const netsuiteTransformRecordTool: ToolConfig<
6566
description: 'Empty for the documented HTTP 204 No Content response',
6667
nullable: true,
6768
},
69+
location: {
70+
type: 'string',
71+
description: 'URL of the transformed record, when NetSuite returns a Location header',
72+
optional: true,
73+
},
6874
},
6975
}

apps/sim/tools/netsuite/upsert_record.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const netsuiteUpsertRecordTool: ToolConfig<NetSuiteUpsertRecordParams, Ne
4444
{ value: `eid:${requiredTrim(params.externalId, 'External ID')}`, label: 'External ID' }
4545
),
4646
success: { status: 204, body: 'none' },
47+
responseLocation: 'resource-optional',
4748
body: params.body,
4849
}),
4950
signal
@@ -55,5 +56,10 @@ export const netsuiteUpsertRecordTool: ToolConfig<NetSuiteUpsertRecordParams, Ne
5556
description: 'Empty for the documented HTTP 204 No Content response',
5657
nullable: true,
5758
},
59+
location: {
60+
type: 'string',
61+
description: 'URL of the created or updated record, when NetSuite returns a Location header',
62+
optional: true,
63+
},
5864
},
5965
}

apps/sim/tools/netsuite/utils.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getErrorMessage } from '@sim/utils/errors'
2+
import { truncate } from '@sim/utils/string'
23
import {
34
DEFAULT_MAX_ERROR_BODY_BYTES,
45
readResponseTextWithLimit,
@@ -44,7 +45,15 @@ interface NetSuiteRequest {
4445
query?: Record<string, string | number | boolean | undefined>
4546
headers?: Record<string, string>
4647
body?: unknown
47-
responseLocation?: 'resource' | 'async-job'
48+
/**
49+
* How a `Location` response header is treated. Oracle documents the header
50+
* for {@link https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html create}
51+
* and {@link https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545142173.html update},
52+
* so `resource` requires it. Upsert and transform also produce a record but
53+
* Oracle documents no response headers for them, so `resource-optional`
54+
* surfaces the header when NetSuite sends it without failing when it does not.
55+
*/
56+
responseLocation?: 'resource' | 'resource-optional' | 'async-job'
4857
}
4958

5059
/**
@@ -813,7 +822,7 @@ function validateResponseLocation(
813822
) {
814823
return {}
815824
}
816-
if (mode === 'resource') {
825+
if (mode === 'resource' || mode === 'resource-optional') {
817826
return parsed.pathname.startsWith('/services/rest/record/v1/') ? { location } : {}
818827
}
819828
if (parsed.search) return {}
@@ -891,8 +900,7 @@ function sanitizeErrorText(value: string, auth?: NetSuiteAuthParams): string {
891900
const secret = credential.trim()
892901
if (secret.length >= 3) sanitized = sanitized.split(secret).join('[REDACTED]')
893902
}
894-
const trimmed = sanitized.replace(/\s+/g, ' ').trim()
895-
return trimmed.slice(0, 900) || 'Unknown error'
903+
return truncate(sanitized.replace(/\s+/g, ' ').trim(), 900) || 'Unknown error'
896904
}
897905

898906
function isJsonObject(value: unknown): value is Record<string, unknown> {

0 commit comments

Comments
 (0)