@hanzo/workday: an embedded-app panel + read-only OAuth + API-proxy service
over Workday people data, built on @hanzo/ai + @hanzo/iam via api.hanzo.ai.
Mirrors packages/procore's pure-core shape.
- OAuth2 Authorization Code + refresh against the tenant token endpoint
(…/ccx/oauth2/{tenant}/token) with HTTP Basic client auth (secret in the
header, never the body); non-rotating refresh carried forward.
- Workday REST wrappers (staffing v6 workers/orgs/directReports, recruiting v4
requisitions, absenceManagement v1 absence types) with limit/offset + the
{ total, data } envelope, plus RaaS custom-report reads ({ Report_Entry }).
- Pure people-context assembly with honest budget/truncation windowing.
- Five HR-guardrailed AI actions: summarize worker, draft job description,
org & headcount, answer HR question, draft review feedback.
- Read-only by design: the proxy is GET-only (405 otherwise); write-back is
documented as a gated future addition.
- 113 vitest tests; tsc --noEmit clean; esbuild build green.
Registers packages/workday in pnpm-workspace.yaml.
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
MAX_LIMIT,
|
|
pageParams,
|
|
restGet,
|
|
raasGet,
|
|
listWorkers,
|
|
getWorker,
|
|
listDirectReports,
|
|
listSupervisoryOrganizations,
|
|
getSupervisoryOrganization,
|
|
listJobRequisitions,
|
|
getJobRequisition,
|
|
listEligibleAbsenceTypes,
|
|
timeOffReport,
|
|
type Scope,
|
|
} from '../src/workday-api.js';
|
|
import { STAFFING } from '../src/config.js';
|
|
|
|
const scope: Scope = {
|
|
host: 'https://wd2-impl-services1.workday.com',
|
|
tenant: 'acme',
|
|
accessToken: 'at-123',
|
|
};
|
|
|
|
function q(url: string): URLSearchParams {
|
|
return new URL(url).searchParams;
|
|
}
|
|
function path(url: string): string {
|
|
return new URL(url).pathname;
|
|
}
|
|
|
|
describe('api: pageParams', () => {
|
|
it('emits nothing for no page', () => {
|
|
expect(pageParams(undefined)).toEqual({});
|
|
});
|
|
it('emits limit + offset when set', () => {
|
|
expect(pageParams({ limit: 50, offset: 100 })).toEqual({ limit: '50', offset: '100' });
|
|
});
|
|
it('clamps limit to the documented max', () => {
|
|
expect(pageParams({ limit: 500 }).limit).toBe(String(MAX_LIMIT));
|
|
});
|
|
it('drops a zero/absent offset (0 is the default) and non-positive limit', () => {
|
|
expect(pageParams({ offset: 0 })).toEqual({});
|
|
expect(pageParams({ limit: 0, offset: -5 })).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('api: auth headers', () => {
|
|
it('every read carries the Bearer token and a JSON Accept', () => {
|
|
for (const req of [
|
|
listWorkers(scope),
|
|
getWorker(scope, 'w1'),
|
|
listDirectReports(scope, 'w1'),
|
|
listSupervisoryOrganizations(scope),
|
|
getSupervisoryOrganization(scope, 'o1'),
|
|
listJobRequisitions(scope),
|
|
getJobRequisition(scope, 'r1'),
|
|
listEligibleAbsenceTypes(scope, 'w1'),
|
|
timeOffReport(scope, 'lmcneil', 'Team_Time_Off'),
|
|
]) {
|
|
expect(req.headers.Authorization).toBe('Bearer at-123');
|
|
expect(req.headers.Accept).toBe('application/json');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('api: restGet service routing', () => {
|
|
it('routes staffing v6 with tenant in the path', () => {
|
|
const req = restGet(scope, STAFFING, '/workers', { limit: '10' });
|
|
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
|
|
expect(q(req.url).get('limit')).toBe('10');
|
|
});
|
|
});
|
|
|
|
describe('api: workers (staffing)', () => {
|
|
it('lists workers with pagination', () => {
|
|
const req = listWorkers(scope, { limit: 100, offset: 200 });
|
|
expect(path(req.url)).toBe('/ccx/api/staffing/v6/acme/workers');
|
|
expect(q(req.url).get('limit')).toBe('100');
|
|
expect(q(req.url).get('offset')).toBe('200');
|
|
});
|
|
it('gets one worker by id', () => {
|
|
expect(path(getWorker(scope, 'abc123').url)).toBe('/ccx/api/staffing/v6/acme/workers/abc123');
|
|
});
|
|
it('lists direct reports under the worker path', () => {
|
|
expect(path(listDirectReports(scope, 'abc123').url)).toBe(
|
|
'/ccx/api/staffing/v6/acme/workers/abc123/directReports',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api: organizations (staffing)', () => {
|
|
it('lists supervisory organizations', () => {
|
|
expect(path(listSupervisoryOrganizations(scope).url)).toBe(
|
|
'/ccx/api/staffing/v6/acme/supervisoryOrganizations',
|
|
);
|
|
});
|
|
it('gets one organization by id', () => {
|
|
expect(path(getSupervisoryOrganization(scope, 'o9').url)).toBe(
|
|
'/ccx/api/staffing/v6/acme/supervisoryOrganizations/o9',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api: job requisitions (recruiting)', () => {
|
|
it('lists requisitions on the recruiting v4 service', () => {
|
|
expect(path(listJobRequisitions(scope).url)).toBe('/ccx/api/recruiting/v4/acme/jobRequisitions');
|
|
});
|
|
it('gets one requisition by id', () => {
|
|
expect(path(getJobRequisition(scope, 'R-42').url)).toBe(
|
|
'/ccx/api/recruiting/v4/acme/jobRequisitions/R-42',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api: absence (time off)', () => {
|
|
it('lists eligible absence types on the absenceManagement v1 service', () => {
|
|
expect(path(listEligibleAbsenceTypes(scope, 'w1').url)).toBe(
|
|
'/ccx/api/absenceManagement/v1/acme/workers/w1/eligibleAbsenceTypes',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('api: RaaS report', () => {
|
|
it('targets the customreport2 path and forces format=json', () => {
|
|
const req = timeOffReport(scope, 'lmcneil', 'Team_Time_Off', { asOf: '2026-07-01' });
|
|
expect(path(req.url)).toBe('/ccx/service/customreport2/acme/lmcneil/Team_Time_Off');
|
|
expect(q(req.url).get('format')).toBe('json');
|
|
expect(q(req.url).get('asOf')).toBe('2026-07-01');
|
|
});
|
|
it('defaults format=json even without extra params', () => {
|
|
expect(q(raasGet(scope, 'owner', 'Rep').url).get('format')).toBe('json');
|
|
});
|
|
});
|
|
|
|
describe('api: path segment encoding', () => {
|
|
it('encodes ids defensively so a stray value cannot traverse the path', () => {
|
|
expect(getWorker(scope, 'a/b c').url).toContain('/workers/a%2Fb%20c');
|
|
});
|
|
});
|