Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8599,6 +8599,17 @@ export function NewRelicIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function NetSuiteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 93.9 59.4' xmlns='http://www.w3.org/2000/svg'>
<path
fill='#C74634'
d='M30.5 59.4H65c16.4-.4 29.3-14.1 28.9-30.4C93.5 13.1 80.7.4 65 0H30.5C14.1-.4.4 12.5 0 28.9s12.5 30 28.9 30.4c.5.1 1 .1 1.6.1m33.7-10.5h-33c-10.6-.3-18.9-9.2-18.6-19.8C13 19 21.1 10.8 31.2 10.5h33c10.6-.3 19.5 8 19.8 18.6s-8 19.5-18.6 19.8z'
/>
</svg>
)
}

export function WizaIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 51 49' fill='none' xmlns='http://www.w3.org/2000/svg'>
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ import {
MongoDBIcon,
MySQLIcon,
Neo4jIcon,
NetSuiteIcon,
NeverBounceIcon,
NewRelicIcon,
NotionIcon,
Expand Down Expand Up @@ -438,6 +439,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
netsuite: NetSuiteIcon,
neverbounce: NeverBounceIcon,
new_relic: NewRelicIcon,
notion: NotionIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"mongodb",
"mysql",
"neo4j",
"netsuite",
"neverbounce",
"new_relic",
"notion",
Expand Down
735 changes: 735 additions & 0 deletions apps/docs/content/docs/en/integrations/netsuite.mdx

Large diffs are not rendered by default.

322 changes: 322 additions & 0 deletions apps/sim/blocks/blocks/netsuite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { NETSUITE_TOOL_IDS, NetSuiteBlock, NetSuiteBlockMeta } from '@/blocks/blocks/netsuite'

function getSubBlock(id: string) {
const subBlock = NetSuiteBlock.subBlocks.find((candidate) => candidate.id === id)
if (!subBlock) throw new Error(`Missing NetSuite sub-block ${id}`)
return subBlock
}

describe('Oracle NetSuite block', () => {
it('exposes exactly the agreed 27 operations and no arbitrary request or RESTlet', () => {
const operation = getSubBlock('operation')
const options =
typeof operation.options === 'function' ? operation.options() : operation.options
const optionIds = options?.map((option) => option.id) ?? []

expect(optionIds).toEqual([...NETSUITE_TOOL_IDS])
expect(NetSuiteBlock.tools.access).toEqual([...NETSUITE_TOOL_IDS])
expect(optionIds).toHaveLength(27)
expect(optionIds.some((id) => String(id).includes('restlet'))).toBe(false)
expect(optionIds.some((id) => String(id).includes('custom_request'))).toBe(false)
})

it('routes each dropdown option directly to its registered tool ID', () => {
const selectTool = NetSuiteBlock.tools.config.tool
for (const toolId of NETSUITE_TOOL_IDS) {
expect(selectTool({ operation: toolId })).toBe(toolId)
}
})

it('covers every operation on canvas and preserves required action targets', () => {
const byOperation = NetSuiteBlock.canvasPresentation?.sentences?.byOperation
if (!byOperation) throw new Error('NetSuite block must define operation canvas sentences')
expect(Object.keys(byOperation)).toEqual([...NETSUITE_TOOL_IDS])

const coreFields = (operation: keyof typeof byOperation) => {
const sentence = byOperation[operation]
if (!Array.isArray(sentence)) return []
return sentence.flatMap((part) =>
typeof part === 'object' && part.core && part.field ? [part.field] : []
)
}

expect(coreFields('netsuite_get_subresource')).toEqual([
'subresourcePath',
'recordType',
'recordId',
])
expect(coreFields('netsuite_get_select_options')).toEqual(['fields', 'recordType'])
expect(coreFields('netsuite_attach_record')).toEqual([
'relatedType',
'relatedId',
'recordType',
'recordId',
])
expect(coreFields('netsuite_detach_record')).toEqual([
'relatedType',
'relatedId',
'recordType',
'recordId',
])
expect(coreFields('netsuite_execute_action')).toEqual(['action', 'recordType', 'recordId'])
expect(coreFields('netsuite_transform_record')).toEqual([
'recordType',
'recordId',
'targetRecordType',
])
})

it('requires the four inline credential fields and masks the private key', () => {
for (const field of ['accountId', 'clientId', 'certificateId', 'privateKey']) {
expect(getSubBlock(field).required, field).toBe(true)
}
expect(getSubBlock('privateKey')).toMatchObject({
title: 'RSA or EC Private Key',
type: 'long-input',
password: true,
})
expect(NetSuiteBlock.inputs).toMatchObject({
accountId: { type: 'string' },
clientId: { type: 'string' },
certificateId: { type: 'string' },
privateKey: { type: 'string' },
})
})

it('keeps account-specific JSON dynamic while requiring only documented fixed inputs', () => {
const body = getSubBlock('body')
expect(body).toMatchObject({ type: 'code', language: 'json' })
expect(body.required).toEqual({
field: 'operation',
value: ['netsuite_create_record', 'netsuite_update_record', 'netsuite_upsert_record'],
})
expect(getSubBlock('recordId').required).toEqual({
field: 'operation',
value: [
'netsuite_get_record',
'netsuite_update_record',
'netsuite_delete_record',
'netsuite_get_subresource',
'netsuite_attach_record',
'netsuite_detach_record',
'netsuite_execute_action',
'netsuite_transform_record',
],
})
expect(getSubBlock('fields').required).toEqual({
field: 'operation',
value: 'netsuite_get_select_options',
})
expect(getSubBlock('fields').condition).toEqual({
field: 'operation',
value: [
'netsuite_get_record',
'netsuite_get_record_form',
'netsuite_batch_get_records',
'netsuite_get_select_options',
],
})
for (const field of ['roleId', 'roleExternalId']) {
expect(getSubBlock(field).condition).toEqual({
field: 'operation',
value: 'netsuite_attach_record',
and: { field: 'relatedType', value: 'contact' },
})
}
expect(getSubBlock('statusTaskId')).toMatchObject({
required: {
field: 'operation',
value: 'netsuite_get_async_status',
and: { field: 'view', value: 'task' },
},
})
expect(getSubBlock('resultTaskId')).toMatchObject({
required: {
field: 'operation',
value: 'netsuite_get_async_result',
},
})
})

it('places optional paging, expansion, media, replacement, and retry controls in advanced mode', () => {
for (const field of [
'limit',
'offset',
'expand',
'expandSubResources',
'replace',
'roleId',
'roleExternalId',
'idempotencyKey',
'format',
]) {
expect(getSubBlock(field).mode, field).toBe('advanced')
}
})

it('provides focused generation help for complex filters, queries, and comma lists', () => {
expect(getSubBlock('q').wandConfig?.prompt).toContain('Return ONLY the q filter expression')
expect(getSubBlock('query').wandConfig).toMatchObject({
enabled: true,
generationType: 'sql-query',
})
for (const field of ['fields', 'ids', 'replace']) {
expect(getSubBlock(field).wandConfig?.prompt, field).toContain(
'no explanations, no extra text'
)
}
})

it('coerces only parameters used by the selected operation and drops stale hidden values', () => {
const mapParams = NetSuiteBlock.tools.config.params
if (!mapParams) throw new Error('NetSuite block must map tool parameters')
const credentials = {
accountId: '1234567_SB1',
clientId: 'client-id',
certificateId: 'certificate-id',
privateKey: 'private-key',
}
const mappedList = mapParams({
operation: 'netsuite_list_records',
...credentials,
body: '{bad stale json',
items: '[bad stale json',
limit: '250',
offset: '500',
expandSubResources: 'false',
})

expect(mappedList).toMatchObject({
...credentials,
body: undefined,
items: undefined,
limit: 250,
offset: 500,
expandSubResources: undefined,
})
expect(mappedList).not.toHaveProperty('operation')

expect(
mapParams({ operation: 'netsuite_create_record', body: '{"companyName":"Acme"}' })
).toMatchObject({ body: { companyName: 'Acme' }, items: undefined })
expect(
mapParams({ operation: 'netsuite_batch_update_records', items: '[{"id":"7"}]' })
).toMatchObject({ body: undefined, items: [{ id: '7' }] })
expect(
mapParams({ operation: 'netsuite_get_record', expandSubResources: 'false' })
).toMatchObject({ expandSubResources: false })
expect(
mapParams({ operation: 'netsuite_get_async_status', statusTaskId: 'task-7' })
).toMatchObject({ taskId: 'task-7' })
expect(
mapParams({ operation: 'netsuite_get_async_result', resultTaskId: 'task-8' })
).toMatchObject({ taskId: 'task-8' })
expect(
mapParams({
operation: 'netsuite_attach_record',
relatedType: 'file',
roleId: '-10',
roleExternalId: 'family',
})
).toMatchObject({ roleId: undefined, roleExternalId: undefined })
expect(() => mapParams({ operation: 'netsuite_create_record', body: '{bad json' })).toThrow(
'Record fields must be valid JSON'
)
})

it('declares every tool parameter input and every common output once', () => {
const expectedInputs = [
'operation',
'accountId',
'clientId',
'certificateId',
'privateKey',
'recordType',
'recordId',
'externalId',
'q',
'fields',
'body',
'subresourcePath',
'relatedType',
'relatedId',
'roleId',
'roleExternalId',
'action',
'targetRecordType',
'ids',
'items',
'query',
'datasetId',
'jobId',
'view',
'statusTaskId',
'resultTaskId',
'limit',
'offset',
'expand',
'expandSubResources',
'replace',
'idempotencyKey',
'format',
]
expect(Object.keys(NetSuiteBlock.inputs)).toEqual(expectedInputs)
expect(Object.keys(NetSuiteBlock.outputs)).toEqual(['status', 'data', 'location', 'jobId'])
expect(NetSuiteBlock.outputs.location.condition).toEqual({
field: 'operation',
value: [
'netsuite_create_record',
'netsuite_update_record',
'netsuite_batch_get_records',
'netsuite_batch_create_records',
'netsuite_batch_update_records',
'netsuite_batch_upsert_records',
'netsuite_batch_delete_records',
],
})
expect(NetSuiteBlock.outputs.jobId.condition).toEqual({
field: 'operation',
value: [
'netsuite_batch_get_records',
'netsuite_batch_create_records',
'netsuite_batch_update_records',
'netsuite_batch_upsert_records',
'netsuite_batch_delete_records',
],
})
for (const input of expectedInputs) {
if (input !== 'operation') expect(getSubBlock(input), input).toBeDefined()
}
expect(getSubBlock('statusTaskId')).not.toHaveProperty('canonicalParamId')
expect(getSubBlock('resultTaskId')).not.toHaveProperty('canonicalParamId')
expect(getSubBlock('view')).toMatchObject({
type: 'dropdown',
condition: { field: 'operation', value: 'netsuite_get_async_status' },
})
})

it('ships the seven practical templates and five focused skills in the agreed scope', () => {
expect(NetSuiteBlockMeta.templates).toHaveLength(7)
expect(NetSuiteBlockMeta.skills).toHaveLength(5)
expect(NetSuiteBlockMeta.templates.map((template) => template.title)).toEqual(
expect.arrayContaining([
'Synchronize NetSuite customers',
'Process NetSuite sales orders',
'Reconcile vendors and purchase orders',
'Monitor NetSuite inventory',
'Generate SuiteQL finance reports',
'Export SuiteAnalytics datasets',
'Import records with async batches',
])
)
})

it('does not declare a trigger surface', () => {
expect(NetSuiteBlock.triggerAllowed).not.toBe(true)
expect(NetSuiteBlock.category).toBe('tools')
})
})
Loading
Loading