Skip to content

Commit dc84b95

Browse files
committed
fix(integrations): clamp Workday page sizes, cover the Workday SOAP schema fixes with tests, and align SAP list output schemas
1 parent abe70f0 commit dc84b95

8 files changed

Lines changed: 348 additions & 161 deletions

File tree

‎apps/docs/content/docs/integrations/sap_s4hana.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,7 +1304,7 @@ List material stock quantities from SAP S/4HANA Cloud (API_MATERIAL_STOCK_SRV, A
13041304
| Parameter | Type | Description |
13051305
| --------- | ---- | ----------- |
13061306
| `status` | number | HTTP status code returned by SAP |
1307-
| `data` | json | OData payload containing the array of A_MatlStkInAcctMod stock entries |
1307+
| `data` | json | Array of A_MatlStkInAcctMod entities, or `\{ results, __count?, __next? \}` when pagination metadata is present \(the integration unwraps the OData v2 `d` envelope\). Properties below describe each item. |
13081308
| ↳ `Material` | string | Material number |
13091309
| ↳ `Plant` | string | Plant identifier |
13101310
| ↳ `StorageLocation` | string | Storage location identifier |
@@ -1353,7 +1353,7 @@ List material document headers (goods movements) from SAP S/4HANA Cloud (API_MAT
13531353
| Parameter | Type | Description |
13541354
| --------- | ---- | ----------- |
13551355
| `status` | number | HTTP status code returned by SAP |
1356-
| `data` | json | OData payload containing the array of A_MaterialDocumentHeader entities |
1356+
| `data` | json | Array of A_MaterialDocumentHeader entities, or `\{ results, __count?, __next? \}` when pagination metadata is present \(the integration unwraps the OData v2 `d` envelope\). Properties below describe each item. |
13571357
| ↳ `MaterialDocumentYear` | string | Material document year \(4-digit fiscal year\) |
13581358
| ↳ `MaterialDocument` | string | Material document number |
13591359
| ↳ `DocumentDate` | string | Document date \(OData /Date\(...\)/ string\) |

‎apps/sim/lib/internal/workday/client.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,36 @@ describe('Workday SOAP client', () => {
7373
expect(extractRefId(worker.Worker_Reference)).toBe('worker-1')
7474
expect(result.Response_Results?.Total_Results).toBe('1')
7575
})
76+
77+
it.each([
78+
['Hire_EmployeeAsync', 'Hire_Employee_Event_Response'],
79+
['Terminate_EmployeeAsync', 'Terminate_Employee_Event_Response'],
80+
] as const)('parses %s from its %s element', async (method, responseElement) => {
81+
fetchMock.mockResolvedValue(
82+
new Response(
83+
`<?xml version="1.0" encoding="UTF-8"?>
84+
<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wd="urn:com.workday/bsvc">
85+
<env:Body>
86+
<wd:${responseElement}>
87+
<wd:Event_Reference><wd:ID wd:type="WID">event-1</wd:ID></wd:Event_Reference>
88+
<wd:Employee_Reference><wd:ID wd:type="Employee_ID">04345</wd:ID></wd:Employee_Reference>
89+
</wd:${responseElement}>
90+
</env:Body>
91+
</env:Envelope>`,
92+
{ status: 200 }
93+
)
94+
)
95+
const client = await createWorkdaySoapClient(
96+
'https://wd2-impl-services1.workday.com',
97+
'example',
98+
'staffing',
99+
'user',
100+
'password'
101+
)
102+
103+
const [result] = await client[method]({})
104+
105+
expect(extractRefId(result.Event_Reference)).toBe('event-1')
106+
expect(extractRefId(result.Employee_Reference)).toBe('04345')
107+
})
76108
})

‎apps/sim/lib/internal/workday/operations.test.ts‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ vi.mock('@/lib/internal/workday/client', () => ({
3434
}),
3535
}))
3636

37-
import { executeWorkdayListWorkers } from '@/lib/internal/workday/operations'
37+
import {
38+
executeWorkdayCreatePrehire,
39+
executeWorkdayGetCompensation,
40+
executeWorkdayGetOrganizations,
41+
executeWorkdayHire,
42+
executeWorkdayListWorkers,
43+
executeWorkdayTerminate,
44+
executeWorkdayUpdateWorker,
45+
} from '@/lib/internal/workday/operations'
3846

3947
const CREDENTIALS = {
4048
tenantUrl: 'https://wd2-impl-services1.workday.com',
@@ -102,4 +110,131 @@ describe('Workday operations', () => {
102110
},
103111
})
104112
})
113+
114+
function firstCallArgs(mock: typeof clientMocks.Get_WorkersAsync): Record<string, unknown> {
115+
const call = mock.mock.calls[0]
116+
if (!call) throw new Error('Expected the SOAP operation to be called')
117+
return call[0] as Record<string, unknown>
118+
}
119+
120+
it('reads compensation plans from the Worker_Compensation_DataType element names', async () => {
121+
clientMocks.Get_WorkersAsync.mockResolvedValue([
122+
{
123+
Response_Data: {
124+
Worker: {
125+
Worker_Data: {
126+
Compensation_Data: {
127+
Salary_and_Hourly_Data: {
128+
Compensation_Plan_Reference: {
129+
ID: { $value: 'base' },
130+
attributes: { Descriptor: 'Base Pay' },
131+
},
132+
Amount: '120000',
133+
},
134+
Commission_Plan_Data: {
135+
Compensation_Plan_Reference: { ID: { $value: 'commission' } },
136+
Target_Amount: '15000',
137+
},
138+
},
139+
},
140+
},
141+
},
142+
},
143+
])
144+
145+
const result = await executeWorkdayGetCompensation({ ...CREDENTIALS, workerId: '04345' })
146+
147+
expect(result.output.compensationPlans).toEqual([
148+
expect.objectContaining({ id: 'base', planName: 'Base Pay', amount: 120000 }),
149+
expect.objectContaining({ id: 'commission', amount: 15000 }),
150+
])
151+
})
152+
153+
it('reads the organization descriptor attribute and clamps the page size', async () => {
154+
clientMocks.Get_OrganizationsAsync.mockResolvedValue([
155+
{
156+
Response_Data: {
157+
Organization: {
158+
Organization_Reference: {
159+
ID: { $value: 'org-1' },
160+
attributes: { Descriptor: 'Engineering' },
161+
},
162+
},
163+
},
164+
},
165+
])
166+
167+
const result = await executeWorkdayGetOrganizations({ ...CREDENTIALS, limit: 5000 })
168+
169+
expect(result.output.organizations[0]?.descriptor).toBe('Engineering')
170+
expect(firstCallArgs(clientMocks.Get_OrganizationsAsync).Response_Filter).toEqual({
171+
Page: 1,
172+
Count: 999,
173+
})
174+
})
175+
176+
it('sends personal information changes under Change_Personal_Information_Data', async () => {
177+
await executeWorkdayUpdateWorker({
178+
...CREDENTIALS,
179+
workerId: '04345',
180+
fields: { Marital_Status_Reference: 'Single' },
181+
})
182+
183+
expect(firstCallArgs(clientMocks.Change_Personal_InformationAsync)).toHaveProperty(
184+
'Change_Personal_Information_Data.Personal_Information_Data',
185+
{ Marital_Status_Reference: 'Single' }
186+
)
187+
})
188+
189+
it('sends pre-hire contact data in schema order with a qualified Public attribute', async () => {
190+
await executeWorkdayCreatePrehire({
191+
...CREDENTIALS,
192+
legalName: 'Ada Lovelace',
193+
email: 'ada@example.com',
194+
phoneNumber: '555-0100',
195+
address: '1 Main St',
196+
})
197+
198+
const args = firstCallArgs(clientMocks.Put_ApplicantAsync) as {
199+
Applicant_Data: { Personal_Data: { Contact_Data: Record<string, unknown[]> } }
200+
}
201+
const contactData = args.Applicant_Data.Personal_Data.Contact_Data
202+
expect(Object.keys(contactData)).toEqual(['Address_Data', 'Phone_Data', 'Email_Address_Data'])
203+
expect(contactData.Address_Data?.[0]).toMatchObject({
204+
Address_Line_Data: { attributes: { 'wd:Type': 'ADDRESS_LINE_1' }, $value: '1 Main St' },
205+
Usage_Data: { attributes: { 'wd:Public': 'true' } },
206+
})
207+
})
208+
209+
it('sends Last_Day_of_Work before the termination reason', async () => {
210+
await executeWorkdayTerminate({
211+
...CREDENTIALS,
212+
workerId: '04345',
213+
terminationDate: '2026-12-01',
214+
reason: 'Resignation',
215+
})
216+
217+
const args = firstCallArgs(clientMocks.Terminate_EmployeeAsync) as {
218+
Terminate_Employee_Data: { Terminate_Event_Data: Record<string, unknown> }
219+
}
220+
expect(Object.keys(args.Terminate_Employee_Data.Terminate_Event_Data)).toEqual([
221+
'Last_Day_of_Work',
222+
'Primary_Reason_Reference',
223+
'Notification_Date',
224+
])
225+
})
226+
227+
it('sends the Position_Details element the hire event requires', async () => {
228+
await executeWorkdayHire({
229+
...CREDENTIALS,
230+
preHireId: 'APP-1',
231+
positionId: 'P-1',
232+
hireDate: '2026-12-01',
233+
})
234+
235+
expect(firstCallArgs(clientMocks.Hire_EmployeeAsync)).toHaveProperty(
236+
'Hire_Employee_Data.Hire_Employee_Event_Data.Position_Details',
237+
{}
238+
)
239+
})
105240
})

‎apps/sim/lib/internal/workday/operations.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ function workerSummary(worker: WorkdayWorkerSoap) {
5656
}
5757
}
5858

59+
/**
60+
* Clamps a page size to the 1-999 range `Response_Filter.Count` accepts (Human_Resources
61+
* WSDL `Response_FilterType`), defaulting to 20 when the value is absent or not a number.
62+
*/
63+
function responseFilterCount(limit: number | undefined): number {
64+
if (limit === undefined || !Number.isFinite(limit)) return 20
65+
return Math.min(999, Math.max(1, Math.trunc(limit)))
66+
}
67+
5968
export async function executeWorkdayGetWorker(input: WorkdayGetWorkerBody, signal?: AbortSignal) {
6069
const client = await createClient(input, 'humanResources', signal)
6170
const [result] = await client.Get_WorkersAsync({
@@ -96,7 +105,7 @@ export async function executeWorkdayListWorkers(
96105
signal?: AbortSignal
97106
) {
98107
const client = await createClient(input, 'humanResources', signal)
99-
const limit = input.limit ?? 20
108+
const limit = responseFilterCount(input.limit)
100109
const offset = input.offset ?? 0
101110
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
102111
const [result] = await client.Get_WorkersAsync({
@@ -268,7 +277,7 @@ export async function executeWorkdayGetOrganizations(
268277
signal?: AbortSignal
269278
) {
270279
const client = await createClient(input, 'humanResources', signal)
271-
const limit = input.limit ?? 20
280+
const limit = responseFilterCount(input.limit)
272281
const offset = input.offset ?? 0
273282
const page = offset > 0 ? Math.floor(offset / limit) + 1 : 1
274283
const [result] = await client.Get_OrganizationsAsync({

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

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

‎apps/sim/tools/sap_s4hana/list_business_partners.ts‎

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -125,65 +125,68 @@ export const listBusinessPartnersTool: InternalToolConfig<
125125
type: 'json',
126126
description:
127127
'Array of A_BusinessPartner entities, or `{ results, __count?, __next? }` when pagination metadata is present (the integration unwraps the OData v2 `d` envelope). Properties below describe each item.',
128-
properties: {
129-
BusinessPartner: { type: 'string', description: 'Business partner key (up to 10 chars)' },
130-
BusinessPartnerFullName: {
131-
type: 'string',
132-
description: 'Full name (concatenated first/last or organization name)',
133-
optional: true,
134-
},
135-
BusinessPartnerCategory: {
136-
type: 'string',
137-
description: '"1" Person, "2" Organization, "3" Group',
138-
optional: true,
139-
},
140-
BusinessPartnerGrouping: {
141-
type: 'string',
142-
description: 'Grouping / number range (tenant-configured)',
143-
optional: true,
144-
},
145-
BusinessPartnerType: {
146-
type: 'string',
147-
description: 'Business partner type (tenant-configured)',
148-
optional: true,
149-
},
150-
BusinessPartnerUUID: {
151-
type: 'string',
152-
description: 'GUID identifier for the business partner',
153-
optional: true,
154-
},
155-
BusinessPartnerIsBlocked: {
156-
type: 'boolean',
157-
description: 'Whether the business partner is centrally blocked',
158-
optional: true,
159-
},
160-
FirstName: { type: 'string', description: 'First name (Person)', optional: true },
161-
LastName: { type: 'string', description: 'Last name (Person)', optional: true },
162-
OrganizationBPName1: {
163-
type: 'string',
164-
description: 'Organization name line 1',
165-
optional: true,
166-
},
167-
SearchTerm1: { type: 'string', description: 'Search term 1', optional: true },
168-
CreationDate: {
169-
type: 'string',
170-
description: 'Date the partner was created (OData /Date(...)/ literal)',
171-
optional: true,
172-
},
173-
CreatedByUser: {
174-
type: 'string',
175-
description: 'User who created the business partner',
176-
optional: true,
177-
},
178-
LastChangeDate: {
179-
type: 'string',
180-
description: 'Date of last change (OData /Date(...)/ literal)',
181-
optional: true,
182-
},
183-
LastChangedByUser: {
184-
type: 'string',
185-
description: 'User who last changed the business partner',
186-
optional: true,
128+
items: {
129+
type: 'object',
130+
properties: {
131+
BusinessPartner: { type: 'string', description: 'Business partner key (up to 10 chars)' },
132+
BusinessPartnerFullName: {
133+
type: 'string',
134+
description: 'Full name (concatenated first/last or organization name)',
135+
optional: true,
136+
},
137+
BusinessPartnerCategory: {
138+
type: 'string',
139+
description: '"1" Person, "2" Organization, "3" Group',
140+
optional: true,
141+
},
142+
BusinessPartnerGrouping: {
143+
type: 'string',
144+
description: 'Grouping / number range (tenant-configured)',
145+
optional: true,
146+
},
147+
BusinessPartnerType: {
148+
type: 'string',
149+
description: 'Business partner type (tenant-configured)',
150+
optional: true,
151+
},
152+
BusinessPartnerUUID: {
153+
type: 'string',
154+
description: 'GUID identifier for the business partner',
155+
optional: true,
156+
},
157+
BusinessPartnerIsBlocked: {
158+
type: 'boolean',
159+
description: 'Whether the business partner is centrally blocked',
160+
optional: true,
161+
},
162+
FirstName: { type: 'string', description: 'First name (Person)', optional: true },
163+
LastName: { type: 'string', description: 'Last name (Person)', optional: true },
164+
OrganizationBPName1: {
165+
type: 'string',
166+
description: 'Organization name line 1',
167+
optional: true,
168+
},
169+
SearchTerm1: { type: 'string', description: 'Search term 1', optional: true },
170+
CreationDate: {
171+
type: 'string',
172+
description: 'Date the partner was created (OData /Date(...)/ literal)',
173+
optional: true,
174+
},
175+
CreatedByUser: {
176+
type: 'string',
177+
description: 'User who created the business partner',
178+
optional: true,
179+
},
180+
LastChangeDate: {
181+
type: 'string',
182+
description: 'Date of last change (OData /Date(...)/ literal)',
183+
optional: true,
184+
},
185+
LastChangedByUser: {
186+
type: 'string',
187+
description: 'User who last changed the business partner',
188+
optional: true,
189+
},
187190
},
188191
},
189192
},

0 commit comments

Comments
 (0)