Skip to content

Commit 97fc445

Browse files
committed
fix(netsuite): validate SuiteQL pages against their documented shape
The shared collection-page validator required links, items, count, hasMore, offset, and totalResults on every 200, and a missing field turns a successful call into a reported failure. Oracle documents all six for record collections and SuiteAnalytics dataset pages, but its SuiteQL reference lists only links, count, offset, totalResults, and items. A documented SuiteQL response that omits hasMore would therefore have been rejected. Split out a suiteql-page validator that requires the five documented SuiteQL fields and type-checks hasMore only when the account returns it. Record collections and dataset pages keep requiring all six.
1 parent 8db4aa1 commit 97fc445

3 files changed

Lines changed: 70 additions & 16 deletions

File tree

apps/sim/tools/netsuite/execute_suiteql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const netsuiteExecuteSuiteQLTool: ToolConfig<NetSuiteSuiteQLParams, NetSu
4444
() => ({
4545
method: 'POST',
4646
path: '/services/rest/query/v1/suiteql',
47-
success: { status: 200, body: 'object', validator: 'collection-page' },
47+
success: { status: 200, body: 'object', validator: 'suiteql-page' },
4848
query: normalizePagination(params.limit, params.offset),
4949
headers: { Prefer: 'transient' },
5050
body: { q: requiredTrim(params.query, 'SuiteQL query') },

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,42 @@ describe('NetSuite operation contracts', () => {
753753
)
754754
})
755755

756+
it('accepts a SuiteQL page without hasMore but still requires it on record collections', async () => {
757+
// Oracle's SuiteQL reference lists links, count, offset, totalResults, and
758+
// items — but not hasMore — while record collections document all six.
759+
const suiteqlPage = { links: [], items: [], count: 0, offset: 0, totalResults: 0 }
760+
const responses = [
761+
new Response(JSON.stringify(suiteqlPage), {
762+
status: 200,
763+
headers: { 'Content-Type': 'application/json' },
764+
}),
765+
new Response(JSON.stringify({ ...suiteqlPage, hasMore: 'nope' }), {
766+
status: 200,
767+
headers: { 'Content-Type': 'application/json' },
768+
}),
769+
new Response(JSON.stringify(suiteqlPage), {
770+
status: 200,
771+
headers: { 'Content-Type': 'application/json' },
772+
}),
773+
]
774+
vi.stubGlobal(
775+
'fetch',
776+
vi.fn(async () => responses.shift() ?? new Response('{}'))
777+
)
778+
779+
const suiteql = await invoke(netsuiteExecuteSuiteQLTool, { query: 'SELECT id FROM customer' })()
780+
const badHasMore = await invoke(netsuiteExecuteSuiteQLTool, {
781+
query: 'SELECT id FROM customer',
782+
})()
783+
const records = await invoke(netsuiteListRecordsTool, { recordType: 'customer' })()
784+
785+
expect(suiteql.success).toBe(true)
786+
expect(badHasMore.success).toBe(false)
787+
expect(badHasMore.error).toContain('hasMore')
788+
expect(records.success).toBe(false)
789+
expect(records.error).toContain('hasMore')
790+
})
791+
756792
it('treats the upsert and transform Location header as optional', async () => {
757793
vi.stubGlobal(
758794
'fetch',

apps/sim/tools/netsuite/utils.ts

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const MAX_JSON_NESTING_DEPTH = 100
2424
const MAX_JSON_NODE_COUNT = 100_000
2525
type NetSuiteSuccessValidator =
2626
| 'collection-page'
27+
| 'suiteql-page'
2728
| 'record-action'
2829
| 'metadata-catalog'
2930
| 'async-job'
@@ -675,7 +676,9 @@ function validateSuccessBody(
675676

676677
switch (successCase.validator) {
677678
case 'collection-page':
678-
return validateCollectionPage(data)
679+
return validateCollectionPage(data, { label: 'collection page', requireHasMore: true })
680+
case 'suiteql-page':
681+
return validateCollectionPage(data, { label: 'SuiteQL page', requireHasMore: false })
679682
case 'record-action':
680683
return data.result === true
681684
? null
@@ -748,24 +751,39 @@ function validateMetadataCatalog(data: Record<string, unknown>): string | null {
748751
return null
749752
}
750753

751-
function validateCollectionPage(data: Record<string, unknown>): string | null {
752-
const error = validateRequiredProperties(
753-
data,
754-
{
755-
links: 'array',
756-
items: 'array',
757-
count: 'number',
758-
hasMore: 'boolean',
759-
offset: 'number',
760-
totalResults: 'number',
761-
},
762-
'collection page'
763-
)
754+
/**
755+
* Validates a documented NetSuite collection page.
756+
*
757+
* Oracle documents `hasMore` on record collections and SuiteAnalytics dataset
758+
* pages, but its SuiteQL reference lists only `links`, `count`, `offset`,
759+
* `totalResults`, and `items`. SuiteQL therefore validates `hasMore` only when
760+
* the account actually returns it, so a documented SuiteQL page is never
761+
* reported as a failed request.
762+
* @see https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156414087576.html
763+
* @see https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157909186990.html
764+
*/
765+
function validateCollectionPage(
766+
data: Record<string, unknown>,
767+
{ label, requireHasMore }: { label: string; requireHasMore: boolean }
768+
): string | null {
769+
const properties: Record<string, 'array' | 'boolean' | 'number' | 'object' | 'string'> = {
770+
links: 'array',
771+
items: 'array',
772+
count: 'number',
773+
offset: 'number',
774+
totalResults: 'number',
775+
}
776+
if (requireHasMore) properties.hasMore = 'boolean'
777+
778+
const error = validateRequiredProperties(data, properties, label)
764779
if (error) return error
780+
if (!requireHasMore && data.hasMore !== undefined && typeof data.hasMore !== 'boolean') {
781+
return `NetSuite ${label} response did not include a valid hasMore`
782+
}
765783
for (const key of ['count', 'offset', 'totalResults'] as const) {
766784
const value = data[key]
767785
if (!Number.isInteger(value) || (value as number) < 0) {
768-
return `NetSuite collection page response included an invalid ${key}`
786+
return `NetSuite ${label} response included an invalid ${key}`
769787
}
770788
}
771789
return null

0 commit comments

Comments
 (0)