592 lines
33 KiB
JavaScript
592 lines
33 KiB
JavaScript
// tests/providers/workday.test.mjs — moved verbatim from test-all.mjs (#1440).
|
|
import { pass, fail, warn, run, ROOT, captureConsoleErrors } from '../helpers.mjs';
|
|
import { join } from 'path';
|
|
import { pathToFileURL } from 'url';
|
|
|
|
console.log('\nProvider — workday');
|
|
|
|
|
|
try {
|
|
const workdayModule = await import(pathToFileURL(join(ROOT, 'providers/workday.mjs')).href);
|
|
const workday = workdayModule.default;
|
|
const { parseWorkdayResponse } = workdayModule;
|
|
|
|
// Shared mock ctx shape for workday.fetch() calls below — only fetchJson varies per test.
|
|
// sleep is a no-op so retry-backoff delays don't slow the test suite down.
|
|
const mkWorkdayCtx = (fetchJson, extra = {}) => ({
|
|
transport: 'http',
|
|
fetchText: async () => { throw new Error('fetchText should not be called'); },
|
|
fetchJson,
|
|
sleep: async () => {},
|
|
...extra,
|
|
});
|
|
|
|
if (workday.id === 'workday') pass('workday.id is "workday"');
|
|
else fail(`workday.id is ${JSON.stringify(workday.id)}`);
|
|
|
|
// detect() — valid Workday URLs
|
|
const hitUs = workday.detect({ name: 'Acme', careers_url: 'https://acme.wd12.myworkdayjobs.com/en-US/acme-jobs' });
|
|
if (hitUs && hitUs.url === 'https://acme.wd12.myworkdayjobs.com/wday/cxs/acme/acme-jobs/jobs') {
|
|
pass('workday.detect() resolves wd12 URL to CXS API endpoint');
|
|
} else {
|
|
fail(`workday.detect(wd12) returned ${JSON.stringify(hitUs)}`);
|
|
}
|
|
|
|
const hitNoLocale = workday.detect({ name: 'Test', careers_url: 'https://test.wd5.myworkdayjobs.com/TestBoard' });
|
|
if (hitNoLocale && hitNoLocale.url === 'https://test.wd5.myworkdayjobs.com/wday/cxs/test/TestBoard/jobs') {
|
|
pass('workday.detect() works without locale segment in path');
|
|
} else {
|
|
fail(`workday.detect(no-locale) returned ${JSON.stringify(hitNoLocale)}`);
|
|
}
|
|
|
|
// detect() — null cases
|
|
if (workday.detect({ name: 'X', careers_url: 'https://example.com/careers' }) === null) {
|
|
pass('workday.detect() returns null for non-Workday URL');
|
|
} else {
|
|
fail('workday.detect() should return null for non-Workday URL');
|
|
}
|
|
|
|
// entry.api precedence: a branded careers_url is kept while the Workday tenant
|
|
// is pinned via api: (mirrors greenhouse/ashby).
|
|
const hitApiWd = workday.detect({
|
|
name: 'PTC',
|
|
careers_url: 'https://www.ptc.com/en/careers',
|
|
api: 'https://ptc.wd1.myworkdayjobs.com/PTC',
|
|
});
|
|
if (hitApiWd && hitApiWd.url === 'https://ptc.wd1.myworkdayjobs.com/wday/cxs/ptc/PTC/jobs') {
|
|
pass('workday.detect() honors api: over a branded careers_url');
|
|
} else {
|
|
fail(`workday.detect(api-pinned) returned ${JSON.stringify(hitApiWd)}`);
|
|
}
|
|
|
|
// A non-Workday api: must not shadow a valid Workday careers_url — resolution
|
|
// falls through to the next candidate instead of returning null.
|
|
const hitFallthrough = workday.detect({
|
|
name: 'Acme',
|
|
api: 'https://acme.com/careers',
|
|
careers_url: 'https://acme.wd12.myworkdayjobs.com/en-US/acme-jobs',
|
|
});
|
|
if (hitFallthrough && hitFallthrough.url === 'https://acme.wd12.myworkdayjobs.com/wday/cxs/acme/acme-jobs/jobs') {
|
|
pass('workday.detect() falls through a non-Workday api: to a valid careers_url');
|
|
} else {
|
|
fail(`workday.detect(fallthrough) returned ${JSON.stringify(hitFallthrough)}`);
|
|
}
|
|
|
|
// Path-spoofed URL: myworkdayjobs.com in path, not hostname
|
|
if (workday.detect({ name: 'Spoof', careers_url: 'https://evil.example/test.wd5.myworkdayjobs.com/en-US/board' }) === null) {
|
|
pass('workday.detect() rejects path-spoofed URL');
|
|
} else {
|
|
fail('workday.detect() must NOT detect path-spoofed URLs');
|
|
}
|
|
|
|
// Non-string careers_url
|
|
if (workday.detect({ name: 'X', careers_url: null }) === null && workday.detect({ name: 'X' }) === null) {
|
|
pass('workday.detect() returns null for null / missing careers_url');
|
|
} else {
|
|
fail('workday.detect() should return null for non-string careers_url');
|
|
}
|
|
|
|
// parseWorkdayResponse — normalization
|
|
const sampleJson = {
|
|
jobPostings: [
|
|
{ title: 'Senior QA Engineer', externalPath: '/job/board/Senior-QA-Engineer_JR001', locationsText: 'Berlin, Germany', postedOn: 'Posted 2 Days Ago' },
|
|
{ title: 'Lead Developer', externalPath: '/job/board/Lead-Developer_JR002', locationsText: 'Remote' },
|
|
{ title: '', externalPath: '/job/board/No-Title_JR003' }, // no title — skip
|
|
{ title: 'No Path Role', externalPath: '' }, // no externalPath — skip
|
|
{ title: 'Also No Path' }, // undefined externalPath — skip
|
|
],
|
|
};
|
|
const entry = { name: 'Acme', careers_url: 'https://acme.wd12.myworkdayjobs.com/en-US/acme-jobs' };
|
|
const parsed = parseWorkdayResponse(sampleJson, entry);
|
|
|
|
if (parsed.length === 2) pass('parseWorkdayResponse extracts 2 valid jobs (skips missing title/path)');
|
|
else fail(`parseWorkdayResponse returned ${parsed.length} jobs, expected 2`);
|
|
|
|
if (parsed[0].title === 'Senior QA Engineer' && parsed[0].location === 'Berlin, Germany') {
|
|
pass('parseWorkdayResponse maps title and location');
|
|
} else {
|
|
fail(`row 0 = ${JSON.stringify(parsed[0])}`);
|
|
}
|
|
|
|
if (parsed[0].url.includes('acme-jobs') && parsed[0].url.includes('/job/board/Senior-QA-Engineer_JR001')) {
|
|
pass('parseWorkdayResponse builds URL from jobBase + externalPath');
|
|
} else {
|
|
fail(`row 0 url = ${JSON.stringify(parsed[0].url)}`);
|
|
}
|
|
|
|
if (parsed[0].company === 'Acme') pass('parseWorkdayResponse sets company from entry.name');
|
|
else fail(`parseWorkdayResponse company = ${JSON.stringify(parsed[0].company)}`);
|
|
|
|
// parseWorkdayResponse — location fallback from URL path
|
|
const noLocEntry = { name: 'Globex', careers_url: 'https://globex.wd103.myworkdayjobs.com/globexcareers' };
|
|
const noLocJson = {
|
|
jobPostings: [
|
|
{ title: 'Quality Engineer', externalPath: '/job/Mumbai/Quality-Engineer_ATCI-123' }, // no locationsText
|
|
{ title: 'Test Lead', externalPath: '/job/Remote-Poland/Test-Lead_ATCI-456', locationsText: '' }, // empty locationsText
|
|
{ title: 'QA Analyst', externalPath: '/job/Remote-Hungary/QA-Analyst_ATCI-789', locationsText: 'Remote, Hungary' }, // has locationsText — use it
|
|
],
|
|
};
|
|
const noLocParsed = parseWorkdayResponse(noLocJson, noLocEntry);
|
|
if (noLocParsed[0]?.location === 'Mumbai') pass('parseWorkdayResponse falls back to URL path location when locationsText absent');
|
|
else fail(`parseWorkdayResponse path fallback: expected "Mumbai", got ${JSON.stringify(noLocParsed[0]?.location)}`);
|
|
if (noLocParsed[1]?.location === 'Remote Poland') pass('parseWorkdayResponse falls back to URL path location when locationsText empty');
|
|
else fail(`parseWorkdayResponse path fallback empty: expected "Remote Poland", got ${JSON.stringify(noLocParsed[1]?.location)}`);
|
|
if (noLocParsed[2]?.location === 'Remote, Hungary') pass('parseWorkdayResponse prefers locationsText over URL path when present');
|
|
else fail(`parseWorkdayResponse locationsText priority: expected "Remote, Hungary", got ${JSON.stringify(noLocParsed[2]?.location)}`);
|
|
|
|
// parseWorkdayResponse — malformed percent-encoding in the URL path segment
|
|
// must not throw (decodeURIComponent) and must not abort processing of
|
|
// other job records in the same response.
|
|
const malformedPathJson = {
|
|
jobPostings: [
|
|
{ title: 'Broken Encoding', externalPath: '/job/%E0%A4%A/Broken-Encoding_JR1' },
|
|
{ title: 'Fine Job', externalPath: '/job/Berlin/Fine-Job_JR2' },
|
|
],
|
|
};
|
|
try {
|
|
const malformedParsed = parseWorkdayResponse(malformedPathJson, entry);
|
|
if (malformedParsed.length === 2 && malformedParsed[1].location === 'Berlin') {
|
|
pass('parseWorkdayResponse tolerates malformed percent-encoding without dropping other records');
|
|
} else {
|
|
fail(`parseWorkdayResponse malformed encoding result = ${JSON.stringify(malformedParsed)}`);
|
|
}
|
|
} catch (e4) {
|
|
fail(`parseWorkdayResponse should not throw on malformed percent-encoding: ${e4.message}`);
|
|
}
|
|
|
|
// parseWorkdayResponse — empty / malformed input
|
|
if (parseWorkdayResponse({}, entry).length === 0) pass('parseWorkdayResponse({}) → empty result');
|
|
else fail('parseWorkdayResponse({}) should be empty');
|
|
|
|
if (parseWorkdayResponse({ jobPostings: null }, entry).length === 0) {
|
|
pass('parseWorkdayResponse handles null jobPostings');
|
|
} else {
|
|
fail('parseWorkdayResponse null jobPostings should be empty');
|
|
}
|
|
|
|
// fetch() with mock ctx — uses total field to bound sequential pagination
|
|
let postRequests = 0;
|
|
const capturedRedirects = [];
|
|
const fetchedJobs = await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
postRequests++;
|
|
capturedRedirects.push(opts?.redirect);
|
|
const body = JSON.parse(opts.body);
|
|
if (body.offset === 0) {
|
|
return { total: 30, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job P1-${i}`, externalPath: `/job/board/p1-${i}` })) };
|
|
}
|
|
return { total: 30, jobPostings: Array.from({ length: 10 }, (_, i) => ({ title: `Job P2-${i}`, externalPath: `/job/board/p2-${i}` })) };
|
|
}));
|
|
if (postRequests === 2 && fetchedJobs.length === 30) {
|
|
pass('workday.fetch() uses total field to fetch exact pages sequentially (20+10=30)');
|
|
} else {
|
|
fail(`fetch pagination: requests=${postRequests}, total=${fetchedJobs.length} (expected 2 requests / 30 jobs)`);
|
|
}
|
|
|
|
if (capturedRedirects.length === 2 && capturedRedirects.every(r => r === 'error')) {
|
|
pass('workday.fetch() passes redirect:"error" on every page (SSRF guard)');
|
|
} else {
|
|
fail(`workday.fetch() redirect opts across pages = ${JSON.stringify(capturedRedirects)}`);
|
|
}
|
|
|
|
// parseWorkdayResponse — null/undefined entries in jobPostings must be
|
|
// skipped, not crash
|
|
const sparseWorkday = { jobPostings: [null, undefined, { title: 'Real Job', externalPath: '/job/board/real-job' }] };
|
|
try {
|
|
const parsedSparseWorkday = parseWorkdayResponse(sparseWorkday, entry);
|
|
if (parsedSparseWorkday.length === 1 && parsedSparseWorkday[0].title === 'Real Job') {
|
|
pass('parseWorkdayResponse skips null/undefined entries without crashing');
|
|
} else {
|
|
fail(`parseWorkdayResponse sparse result = ${JSON.stringify(parsedSparseWorkday)}`);
|
|
}
|
|
} catch (e2) {
|
|
fail(`parseWorkdayResponse should not throw on null/undefined entries: ${e2.message}`);
|
|
}
|
|
|
|
// fetch() pagination cap — an inflated `total` must not trigger unbounded
|
|
// requests (DEFAULT_MAX_PAGES = 100 in providers/workday.mjs), and hitting
|
|
// the cap must be visible (console.error), not silent — real tenants
|
|
// (Dollar Tree, total=23,609; CVS Health, total=16,974) already exceed the
|
|
// 100-page/2000-job default.
|
|
let hugeWorkdayRequests = 0;
|
|
const { result: fetchedHugeWorkday, errors: capturedWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async () => {
|
|
hugeWorkdayRequests++;
|
|
return { total: 1_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
})));
|
|
if (hugeWorkdayRequests === 100 && fetchedHugeWorkday.length === 2000) {
|
|
pass('workday.fetch() caps pagination at DEFAULT_MAX_PAGES despite an inflated total');
|
|
} else {
|
|
fail(`workday fetch pagination cap: requests=${hugeWorkdayRequests}, total=${fetchedHugeWorkday.length} (expected 100/2000)`);
|
|
}
|
|
if (capturedWarnings.some(w => /truncated at max_pages=\d+/.test(w))) {
|
|
pass('workday.fetch() warns (console.error) when the cap truncates real results');
|
|
} else {
|
|
fail(`workday fetch cap: expected a truncation warning, got ${JSON.stringify(capturedWarnings)}`);
|
|
}
|
|
|
|
// fetch() pagination cap — entry.max_pages raises the cap for a genuinely
|
|
// large tenant (e.g. Deutsche Bank-scale postings)
|
|
let overriddenWorkdayRequests = 0;
|
|
const bigWorkdayEntry = { name: 'BigCo', careers_url: 'https://bigco.wd5.myworkdayjobs.com/careers', max_pages: 80 };
|
|
const fetchedOverriddenWorkday = await workday.fetch(bigWorkdayEntry, mkWorkdayCtx(async () => {
|
|
overriddenWorkdayRequests++;
|
|
return { total: 1_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
}));
|
|
if (overriddenWorkdayRequests === 80 && fetchedOverriddenWorkday.length === 1600) {
|
|
pass('workday.fetch() honors entry.max_pages to raise the cap above the default');
|
|
} else {
|
|
fail(`workday fetch max_pages override: requests=${overriddenWorkdayRequests}, total=${fetchedOverriddenWorkday.length} (expected 80/1600)`);
|
|
}
|
|
|
|
// entry.max_pages is itself capped (MAX_PAGES_CAP = 1500) — an absurd
|
|
// override can't turn this into an unbounded scan either.
|
|
let absurdWorkdayRequests = 0;
|
|
const absurdWorkdayEntry = { name: 'AbsurdCo', careers_url: 'https://absurdco.wd5.myworkdayjobs.com/careers', max_pages: 100_000 };
|
|
const fetchedAbsurdWorkday = await workday.fetch(absurdWorkdayEntry, mkWorkdayCtx(async () => {
|
|
absurdWorkdayRequests++;
|
|
return { total: 10_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
}));
|
|
if (absurdWorkdayRequests === 1500 && fetchedAbsurdWorkday.length === 30_000) {
|
|
pass('workday.fetch() caps an absurd entry.max_pages at MAX_PAGES_CAP');
|
|
} else {
|
|
fail(`workday fetch max_pages hard cap: requests=${absurdWorkdayRequests}, total=${fetchedAbsurdWorkday.length} (expected 1500/30000)`);
|
|
}
|
|
|
|
// Invalid max_pages values (negative, zero, non-numeric) fall back to
|
|
// DEFAULT_MAX_PAGES, same as omitting max_pages entirely.
|
|
for (const invalidMaxPages of [-5, 0, 'abc', NaN, null]) {
|
|
let invalidRequests = 0;
|
|
const invalidEntry = { name: 'InvalidCo', careers_url: 'https://invalidco.wd5.myworkdayjobs.com/careers', max_pages: invalidMaxPages };
|
|
const fetchedInvalid = await workday.fetch(invalidEntry, mkWorkdayCtx(async () => {
|
|
invalidRequests++;
|
|
return { total: 1_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
}));
|
|
// Number.isNaN(NaN) is true but JSON.stringify(NaN) === 'null', which would
|
|
// be indistinguishable from the literal `null` case in the log below.
|
|
const label = Number.isNaN(invalidMaxPages) ? 'NaN' : JSON.stringify(invalidMaxPages);
|
|
if (invalidRequests === 100 && fetchedInvalid.length === 2000) {
|
|
pass(`workday.fetch() falls back to DEFAULT_MAX_PAGES for invalid max_pages=${label}`);
|
|
} else {
|
|
fail(`workday fetch invalid max_pages=${label}: requests=${invalidRequests}, total=${fetchedInvalid.length} (expected 100/2000)`);
|
|
}
|
|
}
|
|
|
|
// fetch() pagination — a failure that persists across every retry attempt
|
|
// on a later page returns the jobs gathered so far instead of discarding
|
|
// everything (sequential, not Promise.all), retries MAX_RETRIES+1=4 times
|
|
// on that page before giving up, and the failure itself is visible
|
|
// (console.error), not silent. The truncation ("raise max_pages") warning
|
|
// must NOT also fire — that knob does nothing for a rate-limited tenant.
|
|
let flakyWorkdayRequests = 0;
|
|
const { result: flakyWorkdayJobs, errors: flakyWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
flakyWorkdayRequests++;
|
|
const body = JSON.parse(opts.body);
|
|
const page = body.offset / 20; // PAGE_SIZE in providers/workday.mjs
|
|
if (page === 2) { const err = new Error('HTTP 503'); err.status = 503; throw err; }
|
|
return { total: 80, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job p${page}-${i}`, externalPath: `/job/board/p${page}-${i}` })) };
|
|
})));
|
|
if (flakyWorkdayRequests === 6 && flakyWorkdayJobs.length === 40) {
|
|
pass('workday.fetch() retries a failing page 4x then returns partial results');
|
|
} else {
|
|
fail(`workday fetch partial failure: requests=${flakyWorkdayRequests}, total=${flakyWorkdayJobs.length} (expected 6/40)`);
|
|
}
|
|
if (flakyWarnings.some(w => /truncated at \d+ of \d+ pages after 4 attempts/.test(w))) {
|
|
pass('workday.fetch() warns (console.error) when a page fetch fails after exhausting retries, with the attempt count');
|
|
} else {
|
|
fail(`workday fetch page failure: expected a fetch-failed warning, got ${JSON.stringify(flakyWarnings)}`);
|
|
}
|
|
// The failure message must show scale — which page out of how many were
|
|
// planned, and how many jobs came back out of the tenant's real total —
|
|
// not just "page 3, 40 jobs" with no sense of how much was actually lost.
|
|
// Same "truncated at ... (N of M jobs)" shape as the cap-hit warning below,
|
|
// so the two read consistently.
|
|
if (flakyWarnings.some(w => /truncated at 3 of 4 pages after 4 attempts \(40 of 80 jobs\): HTTP 503/.test(w))) {
|
|
pass('workday.fetch() fetch-failure warning reports scale (page X of Y, N of total jobs)');
|
|
} else {
|
|
fail(`workday fetch-failure warning missing scale context: ${JSON.stringify(flakyWarnings)}`);
|
|
}
|
|
if (!flakyWarnings.some(w => /raise max_pages/.test(w))) {
|
|
pass('workday.fetch() does NOT fire the "raise max_pages" warning on a fetch-error stop');
|
|
} else {
|
|
fail(`workday fetch-error stop should not also warn about max_pages: ${JSON.stringify(flakyWarnings)}`);
|
|
}
|
|
|
|
// fetch() retry — a 429 that succeeds on a later attempt is transparent to
|
|
// the caller (no jobs lost, no error surfaced) and respects Retry-After.
|
|
let retrySleepCalls = [];
|
|
let retryAttempts = 0;
|
|
const retryEntry = { name: 'RetryCo', careers_url: 'https://retryco.wd5.myworkdayjobs.com/careers' };
|
|
const retryJobs = await workday.fetch(retryEntry, mkWorkdayCtx(async () => {
|
|
retryAttempts++;
|
|
if (retryAttempts === 1) { const err = new Error('HTTP 429'); err.status = 429; err.retryAfter = '1'; throw err; }
|
|
return { total: 1, jobPostings: [{ title: 'Recovered Job', externalPath: '/job/board/recovered' }] };
|
|
}, { sleep: async (ms) => { retrySleepCalls.push(ms); } }));
|
|
if (retryAttempts === 2 && retryJobs.length === 1 && retryJobs[0].title === 'Recovered Job') {
|
|
pass('workday.fetch() retries a 429 and recovers transparently');
|
|
} else {
|
|
fail(`workday 429 retry: attempts=${retryAttempts}, jobs=${JSON.stringify(retryJobs)}`);
|
|
}
|
|
if (retrySleepCalls[0] === 1000) {
|
|
pass('workday.fetch() honors Retry-After header for backoff delay');
|
|
} else {
|
|
fail(`workday retry-after: expected first backoff delay 1000ms, got ${JSON.stringify(retrySleepCalls)}`);
|
|
}
|
|
|
|
// fetch() retry — a hostile or misconfigured Retry-After (e.g. 86400s = a
|
|
// full day) must not be honored verbatim: it's clamped to
|
|
// RETRY_MAX_DELAY_MS * 4 (32s) so a single bad header can't stall a
|
|
// tenant's fetch indefinitely, defeating the point of a bounded backoff.
|
|
let hostileRetrySleepCalls = [];
|
|
let hostileRetryAttempts = 0;
|
|
const hostileRetryEntry = { name: 'HostileRetryCo', careers_url: 'https://hostileretryco.wd5.myworkdayjobs.com/careers' };
|
|
await workday.fetch(hostileRetryEntry, mkWorkdayCtx(async () => {
|
|
hostileRetryAttempts++;
|
|
if (hostileRetryAttempts === 1) { const err = new Error('HTTP 429'); err.status = 429; err.retryAfter = '86400'; throw err; }
|
|
return { total: 0, jobPostings: [] };
|
|
}, { sleep: async (ms) => { hostileRetrySleepCalls.push(ms); } }));
|
|
if (hostileRetrySleepCalls[0] === 32_000) {
|
|
pass('workday.fetch() clamps an oversized Retry-After to RETRY_MAX_DELAY_MS * 4');
|
|
} else {
|
|
fail(`workday retry-after clamp: expected 32000ms, got ${JSON.stringify(hostileRetrySleepCalls)}`);
|
|
}
|
|
|
|
// fetch() retry — a non-retryable 4xx (e.g. malformed request) breaks
|
|
// immediately, without wasting retry attempts.
|
|
let non429Attempts = 0;
|
|
const { result: non429Jobs } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
non429Attempts++;
|
|
const body = JSON.parse(opts.body);
|
|
if (body.offset === 0) return { total: 40, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
const err = new Error('HTTP 400: bad request'); err.status = 400; throw err;
|
|
})));
|
|
if (non429Attempts === 2 && non429Jobs.length === 20) {
|
|
pass('workday.fetch() does not retry a non-retryable 4xx error');
|
|
} else {
|
|
fail(`workday non-retryable 4xx: attempts=${non429Attempts}, jobs=${non429Jobs.length} (expected 2/20)`);
|
|
}
|
|
|
|
// fetch() early-stop — once a page's postings are all clearly past
|
|
// ctx.sinceMs, pagination stops without hitting max_pages, and the
|
|
// "raise max_pages" warning does NOT fire (this isn't a cap hit).
|
|
const SINCE_DAYS = 3; // mirrors scan-ats-full.mjs's --since default
|
|
const nowMs = Date.now();
|
|
const sinceMs = nowMs - SINCE_DAYS * 86_400_000;
|
|
let earlyStopRequests = 0;
|
|
const { result: earlyStopJobs, errors: earlyStopWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
earlyStopRequests++;
|
|
const body = JSON.parse(opts.body);
|
|
const page = body.offset / 20;
|
|
if (page === 0) {
|
|
// Page 0: fresh postings, well within the window.
|
|
return { total: 1_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Fresh ${i}`, externalPath: `/job/board/fresh-${i}`, postedOn: 'Posted Today' })) };
|
|
}
|
|
// Every later page: clearly stale (well past sinceMs - margin) — if
|
|
// early-stop didn't work, this mock would be asked for 100 pages.
|
|
return { total: 1_000_000, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Stale ${i}`, externalPath: `/job/board/stale-${i}`, postedOn: 'Posted 20 Days Ago' })) };
|
|
}, { sinceMs })));
|
|
if (earlyStopRequests === 2 && earlyStopJobs.length === 40) {
|
|
pass('workday.fetch() stops paginating once a page is past --since (early-stop)');
|
|
} else {
|
|
fail(`workday early-stop: requests=${earlyStopRequests}, jobs=${earlyStopJobs.length} (expected 2/40)`);
|
|
}
|
|
if (!earlyStopWarnings.some(w => /truncated at/.test(w))) {
|
|
pass('workday.fetch() does NOT warn about max_pages when it stopped early on --since');
|
|
} else {
|
|
fail(`workday early-stop should not warn about max_pages: ${JSON.stringify(earlyStopWarnings)}`);
|
|
}
|
|
|
|
// fetch() early-stop — a wide --since window (>= 30 days) never triggers
|
|
// early-stop off the unbounded "30+ Days Ago" bucket; pagination still
|
|
// proceeds until max_pages/total, as before. includeUndated: true isolates
|
|
// this from the no-date-skip optimization tested separately below — every
|
|
// posting here is undated ("30+"), which would otherwise short-circuit
|
|
// after page 1 regardless of --since width.
|
|
const WIDE_SINCE_DAYS = 90; // >= 30 — past the "30+ Days Ago" bucket's ambiguity threshold
|
|
const wideSinceMs = nowMs - WIDE_SINCE_DAYS * 86_400_000;
|
|
let wideRequests = 0;
|
|
const wideJobs = await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
wideRequests++;
|
|
const body = JSON.parse(opts.body);
|
|
if (body.offset === 0) {
|
|
return { total: 40, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Old ${i}`, externalPath: `/job/board/old-${i}`, postedOn: 'Posted 30+ Days Ago' })) };
|
|
}
|
|
return { total: 40, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Old2 ${i}`, externalPath: `/job/board/old2-${i}`, postedOn: 'Posted 30+ Days Ago' })) };
|
|
}, { sinceMs: wideSinceMs, includeUndated: true }));
|
|
if (wideRequests === 2 && wideJobs.length === 40) {
|
|
pass('workday.fetch() never early-stops off the unbounded "30+ Days Ago" bucket');
|
|
} else {
|
|
fail(`workday wide-since: requests=${wideRequests}, jobs=${wideJobs.length} (expected 2/40)`);
|
|
}
|
|
|
|
// fetch() cap-hit warning — reverse-scan context (ctx.sinceMs set, as
|
|
// scan-ats-full.mjs always does) where entries are synthesized from an
|
|
// external dataset, not portals.yml: there's no portal entry to edit, and
|
|
// — per the "no fixed cap can guarantee full coverage" conclusion — no
|
|
// fix to advise at all, so the message is just the short fact, with
|
|
// neither "raise max_pages" nor a portals.yml edit suggested.
|
|
// includeUndated: true forces this past the no-date-skip short-circuit
|
|
// (tested separately below) so the fetch actually reaches the cap.
|
|
const noDateSinceMs = nowMs - SINCE_DAYS * 86_400_000;
|
|
const { errors: noDateWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async () => ({
|
|
total: 1_000_000,
|
|
jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `NoDate ${i}`, externalPath: `/job/board/nodate-${i}` })), // no postedOn
|
|
}), { sinceMs: noDateSinceMs, includeUndated: true })));
|
|
if (noDateWarnings.some(w => /truncated at \d+ pages/.test(w))) {
|
|
pass('workday.fetch() cap-hit warning fires in reverse-scan context (tenant has no dates, includeUndated on)');
|
|
} else {
|
|
fail(`workday no-date cap warning missing: ${JSON.stringify(noDateWarnings)}`);
|
|
}
|
|
if (!noDateWarnings.some(w => /raise max_pages|portal entry|portals\.yml/.test(w))) {
|
|
pass('workday.fetch() reverse-scan cap warning gives no inactionable advice (no portal entry to edit)');
|
|
} else {
|
|
fail(`workday reverse-scan cap warning should not suggest editing a portal entry: ${JSON.stringify(noDateWarnings)}`);
|
|
}
|
|
|
|
// fetch() no-date-skip — the default case (includeUndated NOT set, as
|
|
// scan-ats-full.mjs leaves it by default): a tenant whose first page has
|
|
// zero dated postings stops right there instead of grinding through up to
|
|
// maxPages requests whose results would all be dropped as 'undated'
|
|
// downstream anyway. Only 1 request should fire, not maxPages (100).
|
|
const skipSinceMs = nowMs - SINCE_DAYS * 86_400_000;
|
|
let skipRequests = 0;
|
|
const { result: skipJobs, errors: skipWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async () => {
|
|
skipRequests++;
|
|
return {
|
|
total: 1_000_000,
|
|
jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `NoDate ${i}`, externalPath: `/job/board/skip-${i}` })), // no postedOn
|
|
};
|
|
}, { sinceMs: skipSinceMs }))); // includeUndated intentionally omitted — the default, falsy case
|
|
if (skipRequests === 1 && skipJobs.length === 20) {
|
|
pass('workday.fetch() skips pagination after page 1 when includeUndated is off and the tenant has no dated postings');
|
|
} else {
|
|
fail(`workday no-date-skip: requests=${skipRequests}, jobs=${skipJobs.length} (expected 1/20)`);
|
|
}
|
|
// A full-directory scan hits this on a large fraction of tenants — a
|
|
// console.error per occurrence would just be the same line thousands of
|
|
// times, so the signal is a tag on the returned array instead (aggregated
|
|
// by scan-ats-full.mjs into one summary line at the end of the run).
|
|
if (skipJobs.workdayNoDateSkip === true) {
|
|
pass('workday.fetch() tags the returned jobs array for no-date-skip aggregation');
|
|
} else {
|
|
fail(`workday no-date-skip tag missing: jobs.workdayNoDateSkip = ${JSON.stringify(skipJobs.workdayNoDateSkip)}`);
|
|
}
|
|
if (skipWarnings.length === 0) {
|
|
pass('workday.fetch() does not console.error per-company on a no-date-skip (aggregated instead)');
|
|
} else {
|
|
fail(`workday no-date-skip should not log anything directly: ${JSON.stringify(skipWarnings)}`);
|
|
}
|
|
|
|
// fetch() no-date-skip — does NOT trigger when includeUndated is true (the
|
|
// "hit the cap while genuinely undated" scenario above already covers that
|
|
// the fetch continues in that case); this just re-confirms the gate.
|
|
let noSkipWithIncludeUndatedRequests = 0;
|
|
const noSkipJobs = await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
noSkipWithIncludeUndatedRequests++;
|
|
const body = JSON.parse(opts.body);
|
|
if (body.offset === 0) return { total: 40, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `ND ${i}`, externalPath: `/job/board/nd-${i}` })) };
|
|
return { total: 40, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `ND2 ${i}`, externalPath: `/job/board/nd2-${i}` })) };
|
|
}, { sinceMs: skipSinceMs, includeUndated: true }));
|
|
if (noSkipWithIncludeUndatedRequests === 2 && noSkipJobs.length === 40) {
|
|
pass('workday.fetch() does not no-date-skip when includeUndated is true');
|
|
} else {
|
|
fail(`workday no-date-skip gate: requests=${noSkipWithIncludeUndatedRequests}, jobs=${noSkipJobs.length} (expected 2/40)`);
|
|
}
|
|
|
|
// fetch() cap-hit warning — reverse-scan context, tenant genuinely has
|
|
// more within --since than the cap allows (total far above the window,
|
|
// e.g. cvshealth-scale): short line, no suspect-cap tag.
|
|
const datedCapSinceMs = nowMs - SINCE_DAYS * 86_400_000;
|
|
const { errors: datedCapWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async () => ({
|
|
total: 1_000_000,
|
|
jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Fresh ${i}`, externalPath: `/job/board/fresh-${i}`, postedOn: 'Posted Today' })),
|
|
}), { sinceMs: datedCapSinceMs })));
|
|
if (datedCapWarnings.some(w => /truncated at \d+ pages \(2000 of 1000000 jobs\)/.test(w))) {
|
|
pass('workday.fetch() cap-hit warning reports the short "truncated at N pages" form');
|
|
} else {
|
|
fail(`workday dated cap-hit warning mismatch: ${JSON.stringify(datedCapWarnings)}`);
|
|
}
|
|
if (!datedCapWarnings.some(w => /Workday-capped/.test(w))) {
|
|
pass('workday.fetch() cap-hit warning omits the suspected-Workday-cap tag when total is far above the window');
|
|
} else {
|
|
fail(`workday cap-hit warning should not suspect a Workday-side cap here (total=1,000,000, window=2,000): ${JSON.stringify(datedCapWarnings)}`);
|
|
}
|
|
|
|
// fetch() cap-hit warning — Workday's own CXS backend has been observed
|
|
// reporting `total` as exactly maxPages*PAGE_SIZE even when the real count
|
|
// is far higher (verified live: dickssportinggoods reported total=2000 but
|
|
// its public careers page listed 7,120 openings, and offset=2000/4000
|
|
// requests returned the same first posting as offset=0 instead of new
|
|
// results). This exact-match case must carry a distinct, short tag.
|
|
const suspectCapSinceMs = nowMs - SINCE_DAYS * 86_400_000;
|
|
const { errors: suspectCapWarnings } = await captureConsoleErrors(() =>
|
|
workday.fetch(entry, mkWorkdayCtx(async () => ({
|
|
total: 2000, // === DEFAULT_MAX_PAGES (100) * PAGE_SIZE (20)
|
|
jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Suspect ${i}`, externalPath: `/job/board/suspect-${i}`, postedOn: 'Posted Today' })),
|
|
}), { sinceMs: suspectCapSinceMs })));
|
|
if (suspectCapWarnings.some(w => /\(total may be Workday-capped, not real\)/.test(w))) {
|
|
pass('workday.fetch() cap-hit warning flags a suspected Workday-side total cap when total === maxPages*PAGE_SIZE');
|
|
} else {
|
|
fail(`workday suspected-cap tag missing: ${JSON.stringify(suspectCapWarnings)}`);
|
|
}
|
|
|
|
// Verify POST method was used
|
|
let capturedMethod = null;
|
|
await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => { capturedMethod = opts?.method; return { total: 0, jobPostings: [] }; }));
|
|
if (capturedMethod === 'POST') pass('workday.fetch() uses POST method');
|
|
else fail(`workday.fetch() method is ${JSON.stringify(capturedMethod)}, expected POST`);
|
|
|
|
// Fallback: no total field — paginate sequentially until short page
|
|
let fallbackRequests = 0;
|
|
const fallbackJobs = await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
fallbackRequests++;
|
|
const body = JSON.parse(opts.body);
|
|
if (body.offset === 0) return { jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `FB P1-${i}`, externalPath: `/job/board/fb-${i}` })) };
|
|
return { jobPostings: Array.from({ length: 5 }, (_, i) => ({ title: `FB P2-${i}`, externalPath: `/job/board/fb2-${i}` })) };
|
|
}));
|
|
if (fallbackRequests === 2 && fallbackJobs.length === 25) {
|
|
pass('workday.fetch() fallback (no total): paginates sequentially, stops on short page (20+5=25)');
|
|
} else {
|
|
fail(`fallback pagination: requests=${fallbackRequests}, jobs=${fallbackJobs.length} (expected 2/25)`);
|
|
}
|
|
|
|
// fetch() honors ctx.maxPages — verify-portals' liveness probe sets maxPages:1.
|
|
// It must stop after the first page and NOT request page 2, which would trip
|
|
// the probe's second-request sentinel; fetchPageWithRetry treats that abort as
|
|
// transient and retries it MAX_RETRIES times (4 requests) plus a truncation
|
|
// warning. Even though total=173 implies 9 pages, the cap must win at 1.
|
|
let probeRequests = 0;
|
|
const probeWarnings = [];
|
|
const probeErr = console.error;
|
|
console.error = (m) => probeWarnings.push(m);
|
|
let probeJobs;
|
|
try {
|
|
probeJobs = await workday.fetch(entry, mkWorkdayCtx(async (_url, opts) => {
|
|
probeRequests++;
|
|
// Simulate the probe sentinel: any request past the first throws.
|
|
if (JSON.parse(opts.body).offset > 0) throw new Error('probe budget: no second page');
|
|
return { total: 173, jobPostings: Array.from({ length: 20 }, (_, i) => ({ title: `Job ${i}`, externalPath: `/job/board/${i}` })) };
|
|
}, { maxPages: 1 }));
|
|
} finally {
|
|
console.error = probeErr;
|
|
}
|
|
if (probeRequests === 1 && probeJobs.length === 20) {
|
|
pass('workday.fetch() honors ctx.maxPages=1 — one request, no second-page retry storm');
|
|
} else {
|
|
fail(`workday probe cap: requests=${probeRequests} (expected 1), jobs=${probeJobs?.length} (expected 20)`);
|
|
}
|
|
if (!probeWarnings.some((w) => /truncated/.test(String(w)))) {
|
|
pass('workday.fetch() stays silent (no truncation warning) under the liveness probe cap');
|
|
} else {
|
|
fail(`workday probe should emit no warning, got: ${JSON.stringify(probeWarnings)}`);
|
|
}
|
|
|
|
} catch (e) {
|
|
fail(`workday provider tests crashed: ${e.message}`);
|
|
}
|