Skip to content

Commit 025df97

Browse files
authored
feat(plugins): add SapSuccessfactors plugin (#1158)
1 parent e9de0dc commit 025df97

18 files changed

Lines changed: 2532 additions & 0 deletions

packages/corsair/core/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export const BaseProviders = [
210210
'resend',
211211
'retailed',
212212
'salesforce',
213+
'sapsuccessfactors',
213214
'scrapegraphai',
214215
'securitytrails',
215216
'sendgrid',
@@ -465,6 +466,7 @@ export const ProviderDisplayNames = {
465466
resend: 'Resend',
466467
retailed: 'Retailed',
467468
salesforce: 'Salesforce',
469+
sapsuccessfactors: 'SAP SuccessFactors',
468470
scrapegraphai: 'ScrapeGraphAI',
469471
securitytrails: 'SecurityTrails',
470472
sendgrid: 'SendGrid',
@@ -727,6 +729,7 @@ export type AllProviders =
727729
| 'resend'
728730
| 'retailed'
729731
| 'salesforce'
732+
| 'sapsuccessfactors'
730733
| 'scrapegraphai'
731734
| 'securitytrails'
732735
| 'sendgrid'
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { request } from 'corsair/http';
2+
import { makeSapsuccessfactorsRequest } from './client';
3+
import { executeSapOperation } from './endpoints/factory';
4+
import { getSapRoute, sapRoutes } from './endpoints/routes';
5+
import { SapsuccessfactorsEndpointInputSchemas } from './endpoints/types';
6+
import { errorHandlers } from './error-handlers';
7+
import type { SapsuccessfactorsContext } from './index';
8+
import { sapsuccessfactors } from './index';
9+
10+
jest.mock('corsair/http', () => ({
11+
request: jest.fn().mockResolvedValue({
12+
d: {
13+
results: [
14+
{
15+
userId: 'cgrant',
16+
personIdExternal: 'p1',
17+
jobReqId: 1,
18+
candidateId: 1,
19+
applicationId: 1,
20+
code: 'POS-1',
21+
sessionId: 's1',
22+
subjectId: 'sub1',
23+
externalCode: '1000',
24+
id: '1',
25+
nominationTargetId: 'nt-1',
26+
picklistId: 'pk1',
27+
formContentId: 'fc1',
28+
},
29+
],
30+
},
31+
}),
32+
ApiError: class ApiError extends Error {
33+
constructor(
34+
public status: number,
35+
message: string,
36+
public retryAfter?: number,
37+
) {
38+
super(message);
39+
this.name = 'ApiError';
40+
}
41+
},
42+
}));
43+
44+
const mockedRequest = request as jest.MockedFunction<typeof request>;
45+
46+
const plugin = sapsuccessfactors({
47+
authType: 'api_key',
48+
key: 'test-token',
49+
host: 'api10.successfactors.com',
50+
companyId: 'ACME',
51+
});
52+
53+
const mockCtx = {
54+
key: 'test-token',
55+
options: { host: 'api10.successfactors.com' },
56+
$getAccountId: async () => 'acc_test',
57+
log: jest.fn(),
58+
} as unknown as SapsuccessfactorsContext;
59+
60+
function run(name: Parameters<typeof getSapRoute>[0], input: unknown) {
61+
return executeSapOperation(mockCtx, input as never, getSapRoute(name));
62+
}
63+
64+
function lastCall() {
65+
expect(mockedRequest).toHaveBeenCalled();
66+
const [, opts] = mockedRequest.mock.calls.at(-1) ?? [];
67+
return opts as {
68+
method?: string;
69+
url?: string;
70+
query?: Record<string, unknown>;
71+
};
72+
}
73+
74+
const fixtures: Record<string, Record<string, unknown>> = {
75+
approveCalibrationSession: { session_id: 's1' },
76+
getCalibrationSessionById: { session_id: 's1' },
77+
getCalibrationSessions: { top: 10 },
78+
getOdataMetadataCalibSessionService: {},
79+
getCalibrationSubjectById: { subject_id: 'sub1' },
80+
getCalibrationSubjectRatings: { session_id: 's1' },
81+
updateCalibrationSubjectRatings: { subject_id: 'sub1', body: { rating: 3 } },
82+
createOnboardee: { userId: 'nhire1', username: 'nhire1' },
83+
getOnb2Process: { top: 5 },
84+
getOdataMetadataOnboardingAddl: {},
85+
updateInternalUsernameNewHiresAfter: {
86+
userId: 'nhire1',
87+
newUsername: 'nhire1.int',
88+
},
89+
createAFeedbackRequest: {
90+
questions: [{ question: 'What should they start doing?' }],
91+
},
92+
getFeedbackRecordsServiceAvailable: { top: 5 },
93+
getPendingFeedbackRequestsFeedback: { top: 5 },
94+
giveFeedbackOrRespondToAFeedbackRequest: {
95+
questions: [{ question: 'Strengths', answer: 'Clear communicator' }],
96+
},
97+
refreshMetadataContFeedbackService: {},
98+
createUpdateSuccessorNomination: { userId: 'cgrant', positionCode: 'POS-1' },
99+
deleteNominationPositionTalentPool: {
100+
nominationTargetId: 'nt-1',
101+
userId: 'cgrant',
102+
isPoolNomination: true,
103+
},
104+
getOdataMetadataForNominationService: {},
105+
getTalentPool: { top: 5 },
106+
getApplicationInterview: { applicationId: '1001' },
107+
getInterviewOverallAssessment: { top: 5 },
108+
getJobApplication: { top: 5 },
109+
getJobRequisition: { top: 5 },
110+
getJobReqScreeningQuestion: { top: 5 },
111+
listCandidates: { top: 5 },
112+
getFoBusinessUnit: { top: 5 },
113+
getFoCompany: { top: 5 },
114+
getFoCostCenter: { top: 5 },
115+
getFoDepartment: { top: 5 },
116+
getFoJobCode: { top: 5 },
117+
getFoJobFunction: { top: 5 },
118+
getFoLocation: { top: 5 },
119+
getFoPayGroup: { top: 5 },
120+
getPosition: { top: 5 },
121+
getCustomMdfObject: { custom_object: 'cust_TeamGoal' },
122+
getPicklist: { top: 5 },
123+
getPicklistOption: { top: 5 },
124+
getCurrentUser: {},
125+
getOdataUserMetadata: {},
126+
listUsers: { top: 10, filter: "status eq 't'" },
127+
getPerPersonById: { person_id_external: 'p1' },
128+
listPerPerson: { top: 5 },
129+
getPerPersonal: { top: 5 },
130+
getBackgroundEducation: { top: 5 },
131+
getBackgroundMobility: { top: 5 },
132+
listEmpEmployment: { top: 5 },
133+
getEmpEmploymentTermination: { top: 5 },
134+
getWorkOrder: { top: 5 },
135+
getEmpPayCompRecurring: { top: 5 },
136+
getEmpPayCompNonRecurring: { top: 5 },
137+
getGoalPlanTemplate: { top: 5 },
138+
getGoalsByPlan: { goal_plan_id: '11' },
139+
getFormContent: { top: 5 },
140+
createLearningActivitiesBulk: { body: { activities: [] } },
141+
getCdpLearningMetadata: {},
142+
refreshCdpLearningMetadata: {},
143+
getEmployeeTime: { top: 5 },
144+
getEmployeeTimesheet: { top: 5 },
145+
getTemporaryTimeInformation: { top: 5 },
146+
getTimeAccountSnapshot: { top: 5 },
147+
getOdataMetadataClockInclockOut: {},
148+
queryAllAvailableClockClockOut: { top: 5 },
149+
queryClockClockOutGroupCodeTime: { code: 'CICO1' },
150+
};
151+
152+
describe('SAP SuccessFactors plugin', () => {
153+
beforeEach(() => {
154+
jest.clearAllMocks();
155+
});
156+
157+
it('registers oauth_2 and api_key auth', () => {
158+
expect(plugin.id).toBe('sapsuccessfactors');
159+
expect(plugin.authConfig).toEqual(
160+
expect.objectContaining({
161+
oauth_2: expect.anything(),
162+
api_key: expect.anything(),
163+
}),
164+
);
165+
expect(plugin.oauthConfig?.tokenUrl).toBe(
166+
'https://api10.successfactors.com/oauth/token',
167+
);
168+
expect(plugin.errorHandlers?.RATE_LIMIT_ERROR).toBeDefined();
169+
});
170+
171+
it('maps OData query keys and sends query on GET', async () => {
172+
await makeSapsuccessfactorsRequest('odata/v2/User', 'k', {
173+
method: 'GET',
174+
query: { top: 10, filter: "status eq 't'" },
175+
host: 'api10.successfactors.com',
176+
});
177+
expect(lastCall().query).toEqual(
178+
expect.objectContaining({
179+
$format: 'json',
180+
$top: 10,
181+
$filter: "status eq 't'",
182+
}),
183+
);
184+
});
185+
186+
it('uses APIKey header on SAP API Business Hub sandbox', async () => {
187+
await makeSapsuccessfactorsRequest('odata/v2/User', 'hub-key', {
188+
host: 'sandbox.api.sap.com',
189+
});
190+
const [config, opts] = mockedRequest.mock.calls.at(-1) ?? [];
191+
expect(config).toEqual(
192+
expect.objectContaining({
193+
BASE: 'https://sandbox.api.sap.com',
194+
HEADERS: expect.objectContaining({ APIKey: 'hub-key' }),
195+
}),
196+
);
197+
expect(opts).toEqual(expect.objectContaining({ url: '/odata/v2/User' }));
198+
});
199+
200+
it('rejects non-numeric paging before the HTTP call', async () => {
201+
await expect(run('listUsers', { top: 'nope' })).rejects.toThrow();
202+
expect(mockedRequest).not.toHaveBeenCalled();
203+
});
204+
205+
it('rejects a response that is not an OData envelope', async () => {
206+
mockedRequest.mockResolvedValueOnce({ garbage: true } as never);
207+
await expect(run('listUsers', { top: 1 })).rejects.toThrow();
208+
});
209+
210+
it('treats 204 writes as success', async () => {
211+
mockedRequest.mockResolvedValueOnce(undefined as never);
212+
await expect(
213+
run('updateCalibrationSubjectRatings', {
214+
subject_id: 'sub1',
215+
body: { rating: 3 },
216+
}),
217+
).resolves.toBeUndefined();
218+
});
219+
220+
it('rejects User as a custom MDF entity', async () => {
221+
await expect(
222+
run('getCustomMdfObject', { custom_object: 'User' }),
223+
).rejects.toThrow(/cust_/);
224+
expect(mockedRequest).not.toHaveBeenCalled();
225+
});
226+
227+
it('encodes cust_* MDF names into the OData path', async () => {
228+
await run('getCustomMdfObject', { custom_object: 'cust_TeamGoal' });
229+
expect(lastCall().url).toBe('/odata/v2/cust_TeamGoal');
230+
});
231+
232+
it('deletes NominationTarget with userId and isPoolNomination', async () => {
233+
mockedRequest.mockResolvedValueOnce(undefined as never);
234+
await run('deleteNominationPositionTalentPool', {
235+
nominationTargetId: 'nt-1',
236+
userId: 'cgrant',
237+
isPoolNomination: true,
238+
});
239+
expect(lastCall()).toEqual(
240+
expect.objectContaining({
241+
method: 'DELETE',
242+
url: "/odata/v4/NominationService.svc/NominationTarget('nt-1')",
243+
query: expect.objectContaining({
244+
userId: 'cgrant',
245+
isPoolNomination: true,
246+
}),
247+
}),
248+
);
249+
});
250+
251+
it('requires applicationId for Interview Central', async () => {
252+
await expect(run('getApplicationInterview', {})).rejects.toThrow(
253+
/applicationId/,
254+
);
255+
await run('getApplicationInterview', { applicationId: '1001' });
256+
expect(lastCall().query).toEqual(
257+
expect.objectContaining({
258+
$filter: "applicationId eq '1001'",
259+
}),
260+
);
261+
});
262+
263+
it('maps Goal_11 plan ids to the Goal_11 entity set', async () => {
264+
await run('getGoalsByPlan', { goal_plan_id: 'Goal_11' });
265+
expect(lastCall().url).toBe('/odata/v2/Goal_11');
266+
});
267+
268+
it('filters current user with $loggedInUser', async () => {
269+
await run('getCurrentUser', {});
270+
expect(lastCall()).toEqual(
271+
expect.objectContaining({
272+
method: 'GET',
273+
url: '/odata/v2/User',
274+
query: expect.objectContaining({
275+
$filter: "userId eq '$loggedInUser'",
276+
}),
277+
}),
278+
);
279+
});
280+
281+
it('matches rate-limit errors', async () => {
282+
expect(errorHandlers.RATE_LIMIT_ERROR.match(new Error('429'))).toBe(true);
283+
const res = await errorHandlers.RATE_LIMIT_ERROR.handler(new Error('429'));
284+
expect(res.maxRetries).toBe(3);
285+
});
286+
287+
it.each(sapRoutes.map((route) => [route.name, route.method] as const))(
288+
'%s sends %s',
289+
async (name, method) => {
290+
const input = fixtures[name];
291+
expect(input).toBeDefined();
292+
SapsuccessfactorsEndpointInputSchemas[name].parse(input);
293+
const route = getSapRoute(name);
294+
const record = {
295+
userId: 'cgrant',
296+
personIdExternal: 'p1',
297+
jobReqId: 1,
298+
candidateId: 1,
299+
applicationId: 1,
300+
code: 'CICO1',
301+
sessionId: 's1',
302+
subjectId: 'sub1',
303+
externalCode: '1000',
304+
id: '1',
305+
nominationTargetId: 'nt-1',
306+
picklistId: 'pk1',
307+
formContentId: 'fc1',
308+
};
309+
mockedRequest.mockResolvedValueOnce(
310+
(route.path.includes('$metadata')
311+
? '<?xml version="1.0"?><edmx:Edmx/>'
312+
: route.method === 'GET' && route.path.includes('({')
313+
? { d: record }
314+
: route.method === 'GET'
315+
? { d: { results: [record] } }
316+
: { d: record }) as never,
317+
);
318+
await run(name, input);
319+
expect(lastCall().method).toBe(method);
320+
expect(lastCall().url).toMatch(/^\//);
321+
},
322+
);
323+
});

0 commit comments

Comments
 (0)