Compare commits

...

1 Commits

Author SHA1 Message Date
DrJKL
6a4cf6023b refactor(website): harden ashby fetcher and tests
Follow-ups to the merged Ashby careers integration (PR #11590).

Type safety
- Add FailureCode discriminant ('missing-env' | 'auth' | 'schema' |
  'network') to FetchOutcome stale/failed variants. Replaces fragile
  reason.startsWith() pattern matching in staleAnnotation() with an
  exhaustive switch.
- Harden isRolesSnapshot() to validate departments[].roles is an array.
- Fix capitalize() so multi-word department names retain title case
  (e.g. "Product Engineering" no longer becomes "Product engineering").
- Simplify extractTitle() to drop redundant 'in' check and one as cast.
- Extract formatZodIssues() helper, used by both envelope and per-job
  parse error paths.

CI reporter
- escapeAnnotation() now escapes % first per GitHub Actions spec.
- describeSnapshotAge() returns 'unknown' for future dates instead of
  collapsing to 'today'.
- Extract MS_PER_DAY constant.

Tests
- Replace 13 inline `as unknown as typeof fetch` casts with a single
  mockFetch() helper.
- Track temp dirs in a Set, clean in afterEach so failed assertions no
  longer leak directories (was 9 inline rmSync calls).
- Add reasonCode assertions across stale/failed cases.
- Add network-error retry test, multi-department grouping test,
  capitalize multi-word test, describeSnapshotAge edge cases (today,
  1 day, n days, NaN, future).
- Add schema and network stale-annotation tests.
- Strengthen 403 test to check status, reason, and reasonCode.
- Fix freshOutcome() helper to derive droppedRoles from droppedCount.
- Tighten tautological careers.spec.ts filter assertion
  (toBeLessThanOrEqual -> toBeLessThan).

Schema
- Document why jobs uses z.unknown() (per-job validation in parseRoles
  for error isolation).

Tests: 30 passing (was 20).
Amp-Thread-ID: https://ampcode.com/threads/T-019dc0d6-da09-7654-b22d-16903e2fee71
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 12:24:17 -07:00
6 changed files with 285 additions and 86 deletions

View File

@@ -40,7 +40,7 @@ test.describe('Careers page @smoke', () => {
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeLessThan(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})

View File

@@ -34,10 +34,10 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedRoles:
droppedCount === 0
? []
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
droppedRoles: Array.from({ length: droppedCount }, (_, i) => ({
title: `Bad Role ${i + 1}`,
reason: 'jobUrl: Invalid url'
})),
snapshot: {
fetchedAt: new Date().toISOString(),
departments: [
@@ -100,6 +100,7 @@ describe('reportAshbyOutcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
reasonCode: 'auth',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
@@ -110,14 +111,41 @@ describe('reportAshbyOutcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
reasonCode: 'missing-env',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby integration')
})
it('emits ::error for schema mismatch in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'envelope schema validation failed: apiVersion: Expected "1"',
reasonCode: 'schema',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby schema mismatch')
})
it('emits ::warning for network errors in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby API unavailable')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
reportAshbyOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error',
reasonCode: 'network'
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
@@ -127,4 +155,57 @@ describe('reportAshbyOutcome', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
})
it('renders snapshot age as "today" for a stale outcome fetched moments ago', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: { ...baseSnapshot(), fetchedAt: new Date().toISOString() }
})
expect(readFileSync(summaryPath, 'utf8')).toContain('| today |')
})
it('renders snapshot age as "unknown" when fetchedAt is unparseable', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: { ...baseSnapshot(), fetchedAt: 'not-a-date' }
})
expect(readFileSync(summaryPath, 'utf8')).toContain('| unknown |')
})
it('renders snapshot age as "unknown" when fetchedAt is in the future', () => {
const future = new Date(Date.now() + 7 * 86_400_000).toISOString()
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: { ...baseSnapshot(), fetchedAt: future }
})
expect(readFileSync(summaryPath, 'utf8')).toContain('| unknown |')
})
it('renders snapshot age as "1 day" when exactly one day old', () => {
const oneDayAgo = new Date(Date.now() - 86_400_000).toISOString()
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: { ...baseSnapshot(), fetchedAt: oneDayAgo }
})
expect(readFileSync(summaryPath, 'utf8')).toContain('| 1 day |')
})
it('renders snapshot age in days when older than one day', () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString()
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 503 Service Unavailable',
reasonCode: 'network',
snapshot: { ...baseSnapshot(), fetchedAt: fiveDaysAgo }
})
expect(readFileSync(summaryPath, 'utf8')).toContain('| 5 days |')
})
})

View File

@@ -1,6 +1,8 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
import type { FailureCode, FetchOutcome } from './ashby'
const MS_PER_DAY = 86_400_000
let hasReported = false
@@ -41,7 +43,7 @@ function buildAnnotations(outcome: FetchOutcome): string[] {
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
return [staleAnnotation(outcome.reason, outcome.reasonCode)]
}
return [
@@ -49,22 +51,29 @@ function buildAnnotations(outcome: FetchOutcome): string[] {
]
}
function staleAnnotation(reason: string): string {
function staleAnnotation(reason: string, code: FailureCode): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
switch (code) {
case 'missing-env':
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
case 'auth':
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
case 'schema':
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
case 'network':
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
default: {
const _exhaustive: never = code
return _exhaustive
}
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
}
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
return value
.replace(/%/g, '%25')
.replace(/\r?\n/g, '%0A')
.replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
@@ -106,8 +115,9 @@ function buildStepSummary(outcome: FetchOutcome): string {
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
const days = Math.floor((Date.now() - fetched) / MS_PER_DAY)
if (days < 0) return 'unknown'
if (days === 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -11,6 +11,9 @@ export const AshbyJobPostingSchema = z.object({
export const AshbyJobBoardResponseSchema = z.object({
apiVersion: z.literal('1'),
// Deliberately z.unknown() — each job is validated individually in
// parseRoles() so that invalid postings are dropped with per-job error
// messages rather than rejecting the entire response.
jobs: z.array(z.unknown())
})

View File

@@ -14,6 +14,8 @@ const BASE_URL = 'https://ashby.test'
const BOARD = 'comfy-org'
const KEY = 'abc-123-secret'
const tempDirs = new Set<URL>()
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
return {
title: 'Design Engineer',
@@ -52,7 +54,15 @@ function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
const file = join(dir, 'ashby-roles.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
const url = pathToFileURL(file)
tempDirs.add(url)
return url
}
function mockFetch(
impl: (...args: Parameters<typeof fetch>) => Promise<Response>
): typeof fetch {
return vi.fn(impl) as unknown as typeof fetch
}
describe('fetchRolesForBuild', () => {
@@ -69,17 +79,21 @@ describe('fetchRolesForBuild', () => {
vi.restoreAllMocks()
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
for (const url of tempDirs) {
rmSync(new URL('.', url), { recursive: true, force: true })
}
tempDirs.clear()
})
it('returns fresh when the API succeeds', async () => {
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
@@ -90,17 +104,37 @@ describe('fetchRolesForBuild', () => {
)
})
it('title-cases multi-word department names on the role', async () => {
const fetchImpl = mockFetch(async () =>
response({
apiVersion: '1',
jobs: [validJob({ department: 'Product Engineering' })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.department).toBe(
'Product Engineering'
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
@@ -111,7 +145,7 @@ describe('fetchRolesForBuild', () => {
it('drops invalid roles individually and keeps the valid ones', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
@@ -122,31 +156,31 @@ describe('fetchRolesForBuild', () => {
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toEqual([])
expect(outcome.droppedCount).toBe(0)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('normalizes missing department and location to safe defaults', async () => {
@@ -154,7 +188,7 @@ describe('fetchRolesForBuild', () => {
const job = validJob()
delete (job as Record<string, unknown>).department
delete (job as Record<string, unknown>).location
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
@@ -162,19 +196,18 @@ describe('fetchRolesForBuild', () => {
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const [department] = outcome.snapshot.departments
expect(department?.name).toBe('OTHER')
expect(department?.roles[0]?.location).toBe('Remote')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('filters out roles with isListed=false', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
@@ -185,7 +218,7 @@ describe('fetchRolesForBuild', () => {
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
@@ -193,54 +226,54 @@ describe('fetchRolesForBuild', () => {
d.roles.map((r) => r.title)
)
expect(titles).not.toContain('Hidden')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns stale with missing env when the snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const fetchImpl = mockFetch(async () => response({}))
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(outcome.reasonCode).toBe('missing-env')
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
fetchImpl: mockFetch(async () => response({}))
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
if (outcome.status !== 'failed') return
expect(outcome.reasonCode).toBe('missing-env')
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const fetchImpl = mockFetch(async () => response({}, { status: 401 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(outcome.reasonCode).toBe('auth')
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const fetchImpl = mockFetch(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
@@ -249,39 +282,42 @@ describe('fetchRolesForBuild', () => {
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reasonCode).toBe('network')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '2', jobs: [] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
expect(outcome.reasonCode).toBe('schema')
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const opts = {
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
}
const [a, b] = await Promise.all([
fetchRolesForBuild(opts),
@@ -297,7 +333,7 @@ describe('fetchRolesForBuild', () => {
const before = new URL(snapshotUrl.href)
const fs = await import('node:fs')
const initial = fs.readFileSync(before).toString()
const fetchImpl = vi.fn(async () =>
const fetchImpl = mockFetch(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
await fetchRolesForBuild({
@@ -305,24 +341,75 @@ describe('fetchRolesForBuild', () => {
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
const after = fs.readFileSync(before).toString()
expect(after).toBe(initial)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on 4xx auth failures for 403', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
await fetchRolesForBuild({
const fetchImpl = mockFetch(async () => response({}, { status: 403 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 403/)
expect(outcome.reasonCode).toBe('auth')
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries on network errors and falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = mockFetch(async () => {
throw new Error('fetch failed')
})
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1],
sleep,
fetchImpl
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^network error:/)
expect(outcome.reasonCode).toBe('network')
expect(fetchImpl).toHaveBeenCalledTimes(3)
expect(sleep).toHaveBeenCalledTimes(2)
})
it('groups jobs by department and sorts alphabetically', async () => {
const fetchImpl = mockFetch(async () =>
response({
apiVersion: '1',
jobs: [
validJob({ title: 'Role Z', department: 'Zzz Dept' }),
validJob({ title: 'Role A', department: 'Aaa Dept' }),
validJob({ title: 'Role A2', department: 'Aaa Dept' })
]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toHaveLength(2)
expect(outcome.snapshot.departments[0]!.name).toBe('AAA DEPT')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(2)
expect(outcome.snapshot.departments[1]!.name).toBe('ZZZ DEPT')
expect(outcome.snapshot.departments[1]!.roles).toHaveLength(1)
})
})

View File

@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { z } from 'zod'
import type { AshbyJobPosting } from './ashby.schema'
import type { Department, Role, RolesSnapshot } from '../data/roles'
@@ -19,6 +21,8 @@ export interface DroppedRole {
reason: string
}
export type FailureCode = 'missing-env' | 'auth' | 'schema' | 'network'
export type FetchOutcome =
| {
status: 'fresh'
@@ -26,8 +30,13 @@ export type FetchOutcome =
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
| {
status: 'stale'
snapshot: RolesSnapshot
reason: string
reasonCode: FailureCode
}
| { status: 'failed'; reason: string; reasonCode: FailureCode }
interface FetchRolesOptions {
apiKey?: string
@@ -63,6 +72,7 @@ async function doFetchRolesForBuild(
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
'missing-env',
options.snapshotUrl
)
}
@@ -80,16 +90,17 @@ async function doFetchRolesForBuild(
}
}
return fallback(result.reason, options.snapshotUrl)
return fallback(result.reason, result.reasonCode, options.snapshotUrl)
}
async function fallback(
reason: string,
reasonCode: FailureCode,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
if (snapshot) return { status: 'stale', snapshot, reason, reasonCode }
return { status: 'failed', reason, reasonCode }
}
interface FetchOk {
@@ -101,6 +112,7 @@ interface FetchOk {
interface FetchErr {
kind: 'err'
reason: string
reasonCode: FailureCode
}
async function tryFetchAndParse(
@@ -134,21 +146,20 @@ async function tryFetchAndParse(
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
reasonCode: 'schema',
reason: `envelope schema validation failed: ${formatZodIssues(envelope.error.issues)}`
}
}
return parseRoles(envelope.data.jobs)
}
return { kind: 'err', reason: lastReason }
return { kind: 'err', reason: lastReason, reasonCode: 'network' }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
| { kind: 'err'; reason: string; reasonCode: FailureCode; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
@@ -172,9 +183,12 @@ async function callOnce(
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
const reasonCode: FailureCode =
res.status === 401 || res.status === 403 ? 'auth' : 'network'
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
reasonCode,
retryable
}
} catch (error) {
@@ -182,7 +196,7 @@ async function callOnce(
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
return { kind: 'err', reason, reasonCode: 'network', retryable: true }
} finally {
clearTimeout(timer)
}
@@ -197,9 +211,7 @@ function parseRoles(jobs: readonly unknown[]): FetchOk {
if (!parsed.success) {
droppedRoles.push({
title: extractTitle(raw),
reason: parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
reason: formatZodIssues(parsed.error.issues)
})
continue
}
@@ -211,15 +223,9 @@ function parseRoles(jobs: readonly unknown[]): FetchOk {
}
function extractTitle(raw: unknown): string {
if (
raw !== null &&
typeof raw === 'object' &&
'title' in raw &&
typeof (raw as { title: unknown }).title === 'string'
) {
return (raw as { title: string }).title
}
return ''
if (typeof raw !== 'object' || raw === null) return ''
const title = (raw as Record<string, unknown>).title
return typeof title === 'string' ? title : ''
}
const DEFAULT_DEPARTMENT = 'Other'
@@ -266,7 +272,7 @@ function slugify(value: string): string {
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
return value.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
async function readSnapshot(
@@ -290,10 +296,22 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
return (
typeof candidate.fetchedAt === 'string' &&
Array.isArray(candidate.departments)
Array.isArray(candidate.departments) &&
candidate.departments.every(
(d) =>
typeof d === 'object' &&
d !== null &&
Array.isArray((d as Department).roles)
)
)
}
function formatZodIssues(issues: z.ZodIssue[]): string {
return issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}