Skip to content

Commit 2195f25

Browse files
feat: Add Breathe HR plugin (#1330)
1 parent 3137475 commit 2195f25

17 files changed

Lines changed: 2294 additions & 0 deletions

packages/breathehr/api.test.ts

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import { AuthMissingError } from 'corsair/core';
2+
import { request } from 'corsair/http';
3+
import {
4+
BREATHE_HR_API_BASE,
5+
BREATHE_HR_SANDBOX_API_BASE,
6+
BreatheHrAPIError,
7+
BreatheHrRateLimitError,
8+
breatheHrBaseUrl,
9+
makeBreatheHrRequest,
10+
} from './client';
11+
import * as handlers from './endpoints/handlers';
12+
import { BreatheHrEndpointInputSchemas } from './endpoints/types';
13+
import { errorHandlers } from './error-handlers';
14+
import { breathehr } from './index';
15+
16+
jest.mock('corsair/core', () => {
17+
class AuthMissingError extends Error {
18+
constructor(plugin: string, authType: string) {
19+
super(`Missing ${authType} for ${plugin}`);
20+
this.name = 'AuthMissingError';
21+
}
22+
}
23+
return {
24+
AuthMissingError,
25+
logEventFromContext: jest.fn(),
26+
};
27+
});
28+
29+
jest.mock('corsair/http', () => {
30+
const actual = jest.requireActual('corsair/http');
31+
return {
32+
...actual,
33+
request: jest.fn(),
34+
};
35+
});
36+
37+
const mockRequest = request as jest.MockedFunction<typeof request>;
38+
39+
beforeEach(() => {
40+
mockRequest.mockReset();
41+
mockRequest.mockResolvedValue({ ok: true } as never);
42+
});
43+
44+
const ctx = { key: 'prod-test-key' } as never;
45+
46+
function lastCall() {
47+
expect(mockRequest).toHaveBeenCalled();
48+
return mockRequest.mock.calls[0]?.[1];
49+
}
50+
51+
const cases: Array<{
52+
name: keyof typeof handlers;
53+
input: Record<string, unknown>;
54+
url: string;
55+
method?: string;
56+
}> = [
57+
{ name: 'accountGet', input: {}, url: '/account' },
58+
{ name: 'employeesList', input: { page: 1 }, url: '/employees' },
59+
{ name: 'employeesGet', input: { id: 1 }, url: '/employees/1' },
60+
{
61+
name: 'employeesCreate',
62+
input: {
63+
first_name: 'A',
64+
last_name: 'B',
65+
email: 'a@b.com',
66+
company_join_date: '2024-01-01',
67+
},
68+
url: '/employees',
69+
method: 'POST',
70+
},
71+
{
72+
name: 'employeesCreateChangeRequest',
73+
input: { id: 1, field: 'job_title', value: 'Eng' },
74+
url: '/employees/1/change_requests',
75+
method: 'POST',
76+
},
77+
{
78+
name: 'employeesCreateExpense',
79+
input: {
80+
employee_id: 1,
81+
amount: 10,
82+
description: 'x',
83+
expense_date: '2024-01-01',
84+
payable_to_employee: true,
85+
company_expense_type_id: 1,
86+
},
87+
url: '/employees/1/employee_expenses',
88+
method: 'POST',
89+
},
90+
{
91+
name: 'employeesCreateExpenseClaim',
92+
input: { employee_id: 1, employee_expense_ids: [2] },
93+
url: '/employees/1/employee_expense_claims',
94+
method: 'POST',
95+
},
96+
{
97+
name: 'employeesCreateSickness',
98+
input: { id: 1, start_date: '2024-01-01', company_sicknesstype_id: 1 },
99+
url: '/employees/1/sicknesses',
100+
method: 'POST',
101+
},
102+
{
103+
name: 'employeeExpensesDelete',
104+
input: { id: 9 },
105+
url: '/employee_expenses/9',
106+
method: 'DELETE',
107+
},
108+
{
109+
name: 'employeeTrainingCoursesDelete',
110+
input: { id: '9' },
111+
url: '/employee_training_courses/9',
112+
method: 'DELETE',
113+
},
114+
{
115+
name: 'employeeExpensesGet',
116+
input: { id: 9 },
117+
url: '/employee_expenses/9',
118+
},
119+
{ name: 'leaveRequestsGet', input: { id: 3 }, url: '/leave_requests/3' },
120+
{
121+
name: 'leaveRequestsGetCancelling',
122+
input: { id: 3 },
123+
url: '/leave_requests/3/cancelling',
124+
},
125+
{
126+
name: 'leaveRequestsApprove',
127+
input: { id: 3 },
128+
url: '/leave_requests/3/approve',
129+
method: 'POST',
130+
},
131+
{
132+
name: 'leaveRequestsReject',
133+
input: { id: 3, rejection_reason: 'busy' },
134+
url: '/leave_requests/3/reject',
135+
method: 'POST',
136+
},
137+
{ name: 'absencesList', input: { page: 1 }, url: '/absences' },
138+
{ name: 'benefitsList', input: { page: 1 }, url: '/employee_benefits' },
139+
{ name: 'bonusesList', input: { page: 1 }, url: '/employee_bonuses' },
140+
{ name: 'changeRequestsList', input: { page: 1 }, url: '/change_requests' },
141+
{
142+
name: 'companyDocumentsList',
143+
input: { page: 1 },
144+
url: '/company_documents',
145+
},
146+
{ name: 'companyProjectsList', input: { page: 1 }, url: '/company_projects' },
147+
{
148+
name: 'companyTrainingTypesList',
149+
input: { page: 1 },
150+
url: '/company_training_types',
151+
},
152+
{ name: 'departmentsList', input: { page: 1 }, url: '/departments' },
153+
{
154+
name: 'departmentsListAbsences',
155+
input: { id: 4 },
156+
url: '/departments/4/absences',
157+
},
158+
{
159+
name: 'departmentsListBenefits',
160+
input: { id: 4 },
161+
url: '/departments/4/benefits',
162+
},
163+
{
164+
name: 'departmentsListBonuses',
165+
input: { id: 4 },
166+
url: '/departments/4/bonuses',
167+
},
168+
{
169+
name: 'departmentsListLeaveRequests',
170+
input: { id: 4 },
171+
url: '/departments/4/leave_requests',
172+
},
173+
{
174+
name: 'departmentsListSalaries',
175+
input: { id: 4 },
176+
url: '/departments/4/salaries',
177+
},
178+
{ name: 'divisionsList', input: {}, url: '/divisions' },
179+
{
180+
name: 'employeesListAbsences',
181+
input: { id: 1 },
182+
url: '/employees/1/absences',
183+
},
184+
{
185+
name: 'employeesListBenefits',
186+
input: { id: 1 },
187+
url: '/employees/1/benefits',
188+
},
189+
{
190+
name: 'employeesListBonuses',
191+
input: { id: 1 },
192+
url: '/employees/1/bonuses',
193+
},
194+
{
195+
name: 'employeesListChangeRequests',
196+
input: { id: 1 },
197+
url: '/employees/1/change_requests',
198+
},
199+
{
200+
name: 'employeeExpenseClaimsList',
201+
input: { page: 1 },
202+
url: '/employee_expense_claims',
203+
},
204+
{
205+
name: 'employeeExpensesList',
206+
input: { page: 1 },
207+
url: '/employee_expenses',
208+
},
209+
{
210+
name: 'employeesListHolidayYears',
211+
input: { id: 1 },
212+
url: '/employees/1/holiday_years',
213+
},
214+
{ name: 'employeeJobsList', input: { page: 1 }, url: '/employee_jobs' },
215+
{
216+
name: 'employeesListLeaveRequests',
217+
input: { id: 1 },
218+
url: '/employees/1/leave_requests',
219+
},
220+
{
221+
name: 'employeesListSalaries',
222+
input: { id: 1 },
223+
url: '/employees/1/salaries',
224+
},
225+
{
226+
name: 'employeeTrainingCoursesList',
227+
input: { page: 1 },
228+
url: '/employee_training_courses',
229+
},
230+
{ name: 'holidayAllowancesList', input: {}, url: '/holiday_allowances' },
231+
{ name: 'leaveRequestsList', input: { page: 1 }, url: '/leave_requests' },
232+
{ name: 'locationsList', input: { page: 1 }, url: '/locations' },
233+
{ name: 'otherLeaveReasonsList', input: {}, url: '/other_leave_reasons' },
234+
{ name: 'salariesList', input: { page: 1 }, url: '/salaries' },
235+
{ name: 'sicknessesList', input: { page: 1 }, url: '/sicknesses' },
236+
{ name: 'workingPatternsList', input: { page: 1 }, url: '/working_patterns' },
237+
{
238+
name: 'employeeExpenseClaimsUpdate',
239+
input: { id: 8, approve: true, approver_rejector_id: 1 },
240+
url: '/employee_expense_claims/8',
241+
method: 'PUT',
242+
},
243+
{
244+
name: 'employeeTrainingCoursesUpdate',
245+
input: { id: '8', name: 'Course' },
246+
url: '/employee_training_courses/8',
247+
method: 'PUT',
248+
},
249+
{
250+
name: 'sicknessesUpdate',
251+
input: { id: 8, status: 'returned' },
252+
url: '/sicknesses/8',
253+
method: 'PUT',
254+
},
255+
];
256+
257+
describe('Breathe HR plugin', () => {
258+
it('registers 50 endpoints and api_key auth', () => {
259+
const plugin = breathehr();
260+
expect(plugin.id).toBe('breathehr');
261+
expect(plugin.authConfig?.api_key?.account).toEqual(['one']);
262+
expect(Object.keys(plugin.endpointSchemas ?? {})).toHaveLength(50);
263+
});
264+
265+
it('returns an explicit key from keyBuilder', async () => {
266+
const plugin = breathehr({ key: 'explicit-key' });
267+
await expect(
268+
plugin.keyBuilder?.({ authType: 'api_key' } as never, 'endpoint'),
269+
).resolves.toBe('explicit-key');
270+
});
271+
272+
it('throws AuthMissingError without a key', async () => {
273+
const plugin = breathehr();
274+
await expect(
275+
plugin.keyBuilder?.(
276+
{
277+
authType: 'api_key',
278+
keys: { get_api_key: async () => undefined },
279+
} as never,
280+
'endpoint',
281+
),
282+
).rejects.toBeInstanceOf(AuthMissingError);
283+
});
284+
285+
it('routes sandbox keys to the official sandbox host', () => {
286+
expect(breatheHrBaseUrl('sandbox-abc')).toBe(BREATHE_HR_SANDBOX_API_BASE);
287+
expect(breatheHrBaseUrl('prod-abc')).toBe(BREATHE_HR_API_BASE);
288+
});
289+
290+
it.each(cases)('$name hits $url', async ({ name, input, url, method }) => {
291+
const fn = handlers[name] as (
292+
c: typeof ctx,
293+
i: Record<string, unknown>,
294+
) => Promise<unknown>;
295+
await fn(ctx, input);
296+
const call = lastCall();
297+
expect(call?.url).toBe(url);
298+
expect(call?.method ?? 'GET').toBe(method ?? 'GET');
299+
});
300+
301+
it('maps company_join_date to official join_date', async () => {
302+
await handlers.employeesCreate(ctx, {
303+
first_name: 'A',
304+
last_name: 'B',
305+
email: 'a@b.com',
306+
company_join_date: '2024-01-01',
307+
});
308+
const body = lastCall()?.body as { employee: { join_date?: string } };
309+
expect(body.employee.join_date).toBe('2024-01-01');
310+
});
311+
312+
it('sends X-API-KEY on requests', async () => {
313+
await makeBreatheHrRequest('/account', 'prod-key');
314+
const config = mockRequest.mock.calls[0]?.[0];
315+
const headers = config?.HEADERS as Record<string, string> | undefined;
316+
expect(headers?.['X-API-KEY']).toBe('prod-key');
317+
expect(config?.BASE).toBe(BREATHE_HR_API_BASE);
318+
});
319+
320+
it('classifies 429 as rate limit', async () => {
321+
const err = new BreatheHrRateLimitError('Rate Limit Reached', 1000);
322+
expect(errorHandlers.RATE_LIMIT_ERROR.match(err)).toBe(true);
323+
await expect(errorHandlers.RATE_LIMIT_ERROR.handler(err)).resolves.toEqual({
324+
maxRetries: 5,
325+
headersRetryAfterMs: 1000,
326+
});
327+
});
328+
329+
it('classifies 401 as auth', () => {
330+
const err = new BreatheHrAPIError('unauthorized', 401, 401);
331+
expect(errorHandlers.AUTH_ERROR.match(err)).toBe(true);
332+
});
333+
334+
it('validates required create-employee fields', () => {
335+
expect(() =>
336+
BreatheHrEndpointInputSchemas.employeesCreate.parse({
337+
first_name: 'A',
338+
}),
339+
).toThrow();
340+
});
341+
});

0 commit comments

Comments
 (0)