Skip to content

Commit ac544fd

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oracle-fusion-sales): add Oracle Fusion Sales integration
1 parent 29d3eee commit ac544fd

87 files changed

Lines changed: 12009 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
480480
onedrive: MicrosoftOneDriveIcon,
481481
onepassword: OnePasswordIcon,
482482
openai: OpenAIIcon,
483+
oracle_fusion_sales: NetSuiteIcon,
483484
outlook: OutlookIcon,
484485
pagerduty: PagerDutyIcon,
485486
parallel_ai: ParallelIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@
187187
"onedrive",
188188
"onepassword",
189189
"openai",
190+
"oracle_fusion_sales",
190191
"outlook",
191192
"pagerduty",
192193
"parallel_ai",

apps/docs/content/docs/integrations/oracle_fusion_sales.mdx

Lines changed: 2094 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oracle_fusion_sales.ts

Lines changed: 2629 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
251251
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
252252
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
253253
import { OpenAIBlock, OpenAIBlockMeta } from '@/blocks/blocks/openai'
254+
import { OracleFusionSalesBlock, OracleFusionSalesBlockMeta } from '@/blocks/blocks/oracle_fusion_sales'
254255
import { OutlookBlock, OutlookBlockMeta } from '@/blocks/blocks/outlook'
255256
import { PagerDutyBlock, PagerDutyBlockMeta } from '@/blocks/blocks/pagerduty'
256257
import { ParallelBlock, ParallelBlockMeta } from '@/blocks/blocks/parallel'
@@ -584,6 +585,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
584585
mysql: MySQLBlock,
585586
neo4j: Neo4jBlock,
586587
netsuite: NetSuiteBlock,
588+
oracle_fusion_sales: OracleFusionSalesBlock,
587589
new_relic: NewRelicBlock,
588590
note: NoteBlock,
589591
notion: NotionBlock,
@@ -905,6 +907,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
905907
mysql: MySQLBlockMeta,
906908
neo4j: Neo4jBlockMeta,
907909
netsuite: NetSuiteBlockMeta,
910+
oracle_fusion_sales: OracleFusionSalesBlockMeta,
908911
neverbounce: NeverBounceBlockMeta,
909912
new_relic: NewRelicBlockMeta,
910913
notion: NotionBlockMeta,

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DOCS_MANIFEST: readonly string[] = [
243243
'integrations/onedrive.mdx',
244244
'integrations/onepassword.mdx',
245245
'integrations/openai.mdx',
246+
'integrations/oracle_fusion_sales.mdx',
246247
'integrations/outlook.mdx',
247248
'integrations/pagerduty.mdx',
248249
'integrations/parallel_ai.mdx',

apps/sim/lib/integrations/credential-display.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
6868
// NetSuite remains an API-key catalog integration, like Snowflake, while its
6969
// block uses the shared reusable-credential selector.
7070
'netsuite-service-account': [],
71+
'oracle-fusion-service-account': [],
7172
'pipedrive-service-account': ['pipedrive'],
7273
'salesforce-service-account': ['salesforce'],
7374
'shopify-service-account': ['shopify'],
@@ -100,6 +101,20 @@ const serviceAccount = (providerId: string) => ({
100101
})
101102

102103
describe('service-account coverage', () => {
104+
it('offers Fusion Sales reusable credentials with API-key catalog classification', () => {
105+
const integration = INTEGRATIONS.find((entry) => entry.type === 'oracle_fusion_sales')
106+
expect(integration?.authType).toBe('api-key')
107+
expect(OAUTH_PROVIDERS.oracle_fusion_sales.services.oracle_fusion_sales).toMatchObject({
108+
providerId: 'oracle_fusion_sales',
109+
serviceAccountProviderId: 'oracle-fusion-service-account',
110+
authType: 'service_account',
111+
})
112+
expect(credentialProviderMatchesService(
113+
'oracle-fusion-service-account',
114+
OAUTH_PROVIDERS.oracle_fusion_sales.services.oracle_fusion_sales
115+
)).toBe(true)
116+
})
117+
103118
it('exposes NetSuite reusable credentials without changing its API-key catalog class', () => {
104119
const netSuiteIntegration = INTEGRATIONS.find((integration) => integration.type === 'netsuite')
105120
expect(netSuiteIntegration?.authType).toBe('api-key')

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
462462
okta: OktaIcon,
463463
onedrive: MicrosoftOneDriveIcon,
464464
onepassword: OnePasswordIcon,
465+
oracle_fusion_sales: NetSuiteIcon,
465466
outlook: OutlookIcon,
466467
pagerduty: PagerDutyIcon,
467468
parallel_ai: ParallelIcon,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({ json: vi.fn(), empty: vi.fn() }))
7+
vi.mock('@/lib/internal/oracle-fusion/client', () => ({
8+
requestOracleFusionJson: mocks.json,
9+
requestOracleFusionEmpty: mocks.empty,
10+
}))
11+
12+
import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors'
13+
import { executeOracleFusionSalesTool } from '@/lib/internal/oracle-fusion-sales/execute-tool'
14+
15+
const AUTH = {
16+
oauthCredential: 'credential-1',
17+
accessToken: 'dXNlcjpwYXNzd29yZA==',
18+
instanceUrl: 'https://vision.fa.us2.oraclecloud.com',
19+
}
20+
21+
function call(toolId: string, params: Record<string, unknown> = {}, signal?: AbortSignal) {
22+
return executeOracleFusionSalesTool({
23+
toolId,
24+
input: { ...AUTH, ...params },
25+
headers: new Headers(),
26+
context: { workflowId: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1' },
27+
requestId: 'request-1',
28+
signal,
29+
})
30+
}
31+
32+
describe('Oracle Fusion Sales internal handler', () => {
33+
beforeEach(() => vi.clearAllMocks())
34+
35+
it('rejects invalid required inputs before provider requests', async () => {
36+
const response = await call('oracle_fusion_sales_create_account')
37+
expect(response.status).toBe(400)
38+
expect(await response.json()).toMatchObject({ success: false, retryable: false })
39+
expect(mocks.json).not.toHaveBeenCalled()
40+
expect(mocks.empty).not.toHaveBeenCalled()
41+
})
42+
43+
it('returns documented action results without asserting business success', async () => {
44+
mocks.json.mockResolvedValue({ result: 'Failure' })
45+
const response = await call('oracle_fusion_sales_accept_lead', { leadId: '123' })
46+
expect(response.status).toBe(200)
47+
expect(await response.json()).toEqual({ success: true, output: { result: 'Failure' } })
48+
})
49+
50+
it('uses the empty-body contract for deletes', async () => {
51+
mocks.empty.mockResolvedValue(undefined)
52+
const response = await call('oracle_fusion_sales_delete_account', { accountNumber: 'A001' })
53+
expect(await response.json()).toEqual({ success: true, output: { deleted: true } })
54+
expect(mocks.json).not.toHaveBeenCalled()
55+
})
56+
57+
it.each([401, 403, 404, 429, 503])('preserves safe provider status %s', async (status) => {
58+
mocks.json.mockRejectedValue(new OracleFusionProviderError('Oracle Fusion denied this request', status))
59+
const response = await call('oracle_fusion_sales_accept_lead', { leadId: '123' })
60+
expect(response.status).toBe(status)
61+
expect(await response.json()).toEqual({
62+
success: false, error: 'Oracle Fusion denied this request', retryable: false,
63+
})
64+
})
65+
66+
it('does not disclose unexpected provider or credential errors', async () => {
67+
mocks.json.mockRejectedValue(new Error('private-provider-canary'))
68+
const response = await call('oracle_fusion_sales_accept_lead', { leadId: '123' })
69+
expect(response.status).toBe(500)
70+
expect(await response.text()).not.toContain('private-provider-canary')
71+
})
72+
73+
it('rejects malformed action output', async () => {
74+
mocks.json.mockResolvedValue({ result: { OptyId: 123 } })
75+
const response = await call('oracle_fusion_sales_convert_lead', { leadId: '123' })
76+
expect(response.status).toBe(502)
77+
expect(await response.json()).toMatchObject({ success: false })
78+
})
79+
80+
it('preserves cancellation before and during provider execution', async () => {
81+
const controller = new AbortController()
82+
const reason = new DOMException('Cancelled', 'AbortError')
83+
controller.abort(reason)
84+
await expect(call('oracle_fusion_sales_list_accounts', {}, controller.signal)).rejects.toBe(reason)
85+
expect(mocks.json).not.toHaveBeenCalled()
86+
87+
const pending = new AbortController()
88+
mocks.json.mockImplementation(async () => {
89+
pending.abort(reason)
90+
return { result: 'Successful' }
91+
})
92+
await expect(call('oracle_fusion_sales_accept_lead', { leadId: '123' }, pending.signal))
93+
.rejects.toBe(reason)
94+
expect(mocks.json.mock.calls[0][2]).toBe(pending.signal)
95+
})
96+
97+
it('does not dispatch unrelated or inherited object property names', async () => {
98+
for (const toolId of ['oracle_fusion_service_list_accounts', 'oracle_fusion_sales_constructor']) {
99+
expect((await call(toolId)).status).toBe(500)
100+
}
101+
expect(mocks.json).not.toHaveBeenCalled()
102+
})
103+
})
104+
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { z } from 'zod'
2+
import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors'
3+
import { executeOracleFusionSalesOperation } from '@/lib/internal/oracle-fusion-sales/operations'
4+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
5+
import {
6+
getOracleFusionSalesOperation,
7+
ORACLE_FUSION_SALES_OPERATIONS,
8+
} from '@/tools/oracle_fusion_sales/shared'
9+
10+
export const executeOracleFusionSalesTool: InternalToolOperationHandler = async ({
11+
toolId,
12+
input,
13+
signal,
14+
}) => {
15+
signal?.throwIfAborted()
16+
const prefix = 'oracle_fusion_sales_'
17+
const name = toolId.startsWith(prefix) ? toolId.slice(prefix.length) : ''
18+
if (!Object.hasOwn(ORACLE_FUSION_SALES_OPERATIONS, name)) {
19+
return Response.json(
20+
{ success: false, error: 'Unsupported Oracle Fusion Sales tool' },
21+
{ status: 500 }
22+
)
23+
}
24+
const operation = getOracleFusionSalesOperation(name)
25+
const retryable = operation.kind === 'list' || operation.kind === 'get'
26+
try {
27+
const result = await executeOracleFusionSalesOperation(name, input, signal)
28+
signal?.throwIfAborted()
29+
return Response.json(result)
30+
} catch (error) {
31+
signal?.throwIfAborted()
32+
if (error instanceof z.ZodError) {
33+
return Response.json(
34+
{
35+
success: false,
36+
error: error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '),
37+
retryable: false,
38+
},
39+
{ status: 400 }
40+
)
41+
}
42+
if (error instanceof OracleFusionProviderError) {
43+
return Response.json(
44+
{ success: false, error: error.message, retryable },
45+
{ status: error.status }
46+
)
47+
}
48+
return Response.json(
49+
{ success: false, error: 'Oracle Fusion Sales request failed', retryable },
50+
{ status: 500 }
51+
)
52+
}
53+
}
54+

0 commit comments

Comments
 (0)