From f309b3923c3fafd83a62b6ebf758983b68f91eb2 Mon Sep 17 00:00:00 2001 From: Akhilesh Arora Date: Sat, 9 May 2026 13:05:18 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=200.2.0-alpha.2=20=E2=80=94=20c?= =?UTF-8?q?hallenges=20resource=20+=20doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Added - ChallengesResource (`client.challenges.list / retrieve`) wrapping `/v1/challenges` and `/v1/challenges/:id`. Partners can discover available coding challenges in the customer's library before assigning them to candidates. - Challenge, ChallengeListParams, ChallengeStatus types re-exported. - challengeFromWire transform + unit tests (71/71 passing). ### Documentation - README intro broadened — SDK is for any hiring workflow integration, not just ATS partners. - README Resources table now includes client.account and client.challenges. - README error handling adds LangosConflictError and LangosSignatureVerificationError. - CHANGELOG: moved security/correctness entries to [0.2.0-alpha.1] where they actually shipped. Removed misleading 'Challenge resource' line from alpha.1. Corrected the retry list (409 not retried) and error class list (added LangosConflictError + LangosSignatureVerificationError). - CLAUDE.md: fixed broken `for await` examples (need `await` since list() returns Promise), corrected default baseUrl, dropped stale monorepo path references, documented the release.yml pipeline. - Genericized greenhouse-themed example identifiers in README. --- CHANGELOG.md | 26 ++++---- CLAUDE.md | 116 ++++++++++++++++++++++-------------- README.md | 12 ++-- package.json | 2 +- src/client.ts | 3 + src/core/transform.ts | 19 ++++++ src/index.ts | 3 + src/resources/challenges.ts | 36 +++++++++++ src/types.ts | 19 ++++++ test/unit/transform.test.ts | 43 +++++++++++++ 10 files changed, 217 insertions(+), 62 deletions(-) create mode 100644 src/resources/challenges.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d0a0b..aebda83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,29 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Security -- **Webhook empty / short signing secret rejected.** `Webhooks.constructEvent` now requires `secret` to be a non-empty string of at least 16 characters. Prevents a forgery path where a partner who forgot to set `LANGOS_WEBHOOK_SECRET` would silently HMAC events against the empty string, allowing an attacker who knows this default to forge events that pass verification. -- **Webhook timestamp parser hardened.** The `t=` value in the `Langos-Signature` header is now rejected if it is non-positive, NaN, or larger than `2**32` seconds (past the unix-epoch overflow boundary). Previously `t=0` and `t=-1` parsed as valid integers and only failed via the tolerance check, which is a fail-late posture for a malformed-input class. -- **Header injection guard on `appName` and `apiKey`.** The `Langos` constructor now rejects strings containing `\r`, `\n`, `\0`, or any C0 control character. These would otherwise let an attacker who controls the partner's `appName` env splice arbitrary headers into every outbound request via the `User-Agent` line. Same guard applies to `apiKey` for the `Authorization` header. +### Added +- **`client.challenges` resource.** `list({status, language, limit, cursor})` and `retrieve(id)` for the read-only `/v1/challenges` and `/v1/challenges/:id` endpoints. Lets partners discover available coding challenges in the customer's library before assigning them to candidates. +- **`Challenge`, `ChallengeListParams`, `ChallengeStatus` types.** Re-exported from the package root. +- **`challengeFromWire` transform** with unit-test coverage for both fully-populated and minimum-fields shapes. -### Fixed -- **`WebhookEventType` aligned with server-side publishers.** The union now matches the canonical set emitted by the server (`session.submitted`, `session.completed`, `candidate.cancelled`). The previous test fixture referenced a fictional `candidate.completed` event; that has been corrected. Also adds an `Event` discriminated union so partners can `switch (event.type)` and let the compiler enforce exhaustive handling. -- **`409 Conflict` no longer auto-retried.** 409 is non-idempotent (duplicate email, version conflict, race against a parallel mutation) — retrying just burns the partner's rate-limit budget and amplifies the conflict. Partners should surface 409 to their caller and resolve the conflict explicitly. -- **`WebhookEvent.created` matches the server's wire field.** The webhook envelope's timestamp field was typed as `createdAt`, but the server-side publisher emits `created`. Reading `event.createdAt` returned `undefined` at runtime while the type system claimed it was a string. Renamed to `created` on `WebhookEvent` and `BaseEvent` to remove the lie; partners using `event.created` get the real timestamp. +### Documentation +- **Broaden audience framing.** README intro now positions the SDK for any hiring workflow integration — not specifically ATS partners. +- **Add `client.account` and `client.challenges` to README Resources table.** +- **Expand error handling import list.** README now imports `LangosConflictError` and `LangosSignatureVerificationError` alongside the other typed errors. +- **Genericize example identifiers.** Removed `greenhouse-app-12345` / `GreenhouseConnector/2.1.0` placeholders from code examples — they implied a specific partner integration we don't ship. +- **Fix `for await` examples in CLAUDE.md.** Examples were missing the `await` on `client..list()` (which returns a `Promise`) and would have thrown at runtime. +- **Correct default `baseUrl` in CLAUDE.md.** Was documented as `https://api.langos.io/v1`; real default is `https://app.langos.io/api/v1`. +- **Drop stale monorepo references in CLAUDE.md.** Repo is standalone; no more `packages/sdk-node/` paths or pointers to monorepo-internal docs. +- **Document the release pipeline.** CLAUDE.md now describes the `v*`-tag release workflow (no manual `npm publish`). ## [0.2.0-alpha.1] - 2026-05-08 ### Added - **Customer Partner API SDK** — official Node.js / TypeScript client for the Langos Partner API (`@datacline/langos-sdk-node`) - **Assessment resource** — list published assessments, retrieve by id -- **Challenge resource** — list coding challenges, retrieve by id - **Candidate resource** — invite candidates to assessments, list, retrieve, cancel invitations - **Session resource** — retrieve scoring results, analytics, and recruiter reports - **Account resource** — retrieve workspace plan tier, session quota, feature flags, and webhook configuration - **Webhook support** — register webhook endpoints, set signing secrets, validate inbound webhook signatures with `Langos.webhooks.constructEvent` - **Pagination** — async iterable cursor-based pagination on all list endpoints -- **Error handling** — typed error classes: `LangosAPIError`, `LangosAuthenticationError`, `LangosForbiddenError`, `LangosNotFoundError`, `LangosBadRequestError`, `LangosRateLimitError`, `LangosServerError`, `LangosConnectionError`, `LangosTimeoutError` -- **Automatic retries** — configurable exponential backoff with jitter on 5xx and `408`/`409`/`429` (max 2 retries by default) +- **Error handling** — typed error classes: `LangosAPIError`, `LangosAuthenticationError`, `LangosForbiddenError`, `LangosNotFoundError`, `LangosBadRequestError`, `LangosConflictError`, `LangosRateLimitError`, `LangosServerError`, `LangosConnectionError`, `LangosTimeoutError`, `LangosSignatureVerificationError` +- **Automatic retries** — configurable exponential backoff with jitter on 5xx (except 501) and `408`/`429` (max 2 retries by default). `409 Conflict` is intentionally not retried (non-idempotent) - **Idempotency** — automatic `Idempotency-Key` header generation for safe POST/PATCH/DELETE/PUT, honors `Retry-After` headers - **Zero runtime dependencies** — uses only Node.js built-ins (`fetch`, `crypto`) - **Dual module distribution** — ESM (`.mjs`), CommonJS (`.cjs`), and TypeScript declaration files (`.d.ts`) diff --git a/CLAUDE.md b/CLAUDE.md index d79bf33..d703e52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ import { Langos } from '@datacline/langos-sdk-node'; const client = new Langos({ apiKey: process.env.LANGOS_API_KEY!, // langos_live_... or langos_test_... - // baseUrl optional — defaults to https://api.langos.io/v1 + // baseUrl optional — defaults to https://app.langos.io/api/v1 }); ``` @@ -70,14 +70,25 @@ console.log(wh.signingSecret); // store this — can't read it back later ### `client.assessments` ```ts -// List — async iterable, walks all pages -for await (const a of client.assessments.list()) { +// List — async iterable, walks all pages. +// Note: list() returns Promise, so the for-await needs `await`. +for await (const a of await client.assessments.list()) { console.log(a.id, a.name, a.challengeCount); } const a = await client.assessments.retrieve('asm_abc'); ``` +### `client.challenges` +```ts +// List published challenges available to assign +for await (const ch of await client.challenges.list({ status: 'published', language: 'python' })) { + console.log(ch.id, ch.title, ch.difficulty, ch.timeLimitMinutes); +} + +const ch = await client.challenges.retrieve('ch_abc'); +``` + ### `client.candidates` ```ts // Create @@ -86,7 +97,7 @@ const c = await client.candidates.create({ }); // List (filter by status, assessment, etc.) -for await (const c of client.candidates.list({ status: 'completed' })) { +for await (const c of await client.candidates.list({ status: 'completed' })) { ... } @@ -96,7 +107,7 @@ const c = await client.candidates.retrieve('cand_xyz'); await client.candidates.cancel('cand_xyz'); // All sessions for a candidate (re-attempts, etc.) -for await (const s of client.candidates.listSessions('cand_xyz')) { +for await (const s of await client.candidates.listSessions('cand_xyz')) { ... } ``` @@ -262,29 +273,33 @@ If you're an AI assistant helping a developer **work on this SDK** (not just use ## Layout +This repo is standalone (`github.com/datacline/langos-sdk-node`). Layout: + ``` -packages/sdk-node/ - src/ - index.ts # Public exports (Langos, error classes, types, webhooks) - client.ts # Langos class — constructor, baseUrl, auth, resources - types.ts # Public types (PlanCaps, Candidate, Session, webhook events) - core/ - request.ts # fetch wrapper: auth header, retry, idempotency - retry.ts # exponential backoff for 5xx/408/425/429 - errors.ts # LangosAPIError + subclasses (Forbidden, RateLimit, ...) - pagination.ts # AsyncIterable cursor walker - transform.ts # snake_case ↔ camelCase between wire and public API - idempotency.ts # auto Idempotency-Key for unsafe methods - resources/ - account.ts # client.account - assessments.ts # client.assessments - candidates.ts # client.candidates - sessions.ts # client.sessions - webhooks.ts # Langos.webhooks.constructEvent - test/ - unit/ # vitest, no network — 46 tests, ~1.5s - integration/ # vitest, live local stack — 36 tests - tsup.config.ts # dual ESM+CJS build +src/ + index.ts # Public exports (Langos, error classes, types, webhooks) + client.ts # Langos class — constructor, baseUrl, auth, resources + types.ts # Public types (PlanCaps, Candidate, Challenge, Session, webhook events) + core/ + request.ts # fetch wrapper: auth header, retry, idempotency + retry.ts # exponential backoff for 5xx/408/429 (409 not retried) + errors.ts # LangosAPIError + subclasses (Forbidden, RateLimit, ...) + pagination.ts # AsyncIterable cursor walker + transform.ts # snake_case ↔ camelCase between wire and public API + idempotency.ts # auto Idempotency-Key for unsafe methods + resources/ + account.ts # client.account + assessments.ts # client.assessments + candidates.ts # client.candidates + challenges.ts # client.challenges + sessions.ts # client.sessions + webhooks.ts # Langos.webhooks.constructEvent +test/ + unit/ # vitest, no network — runs in CI on Node 18.17/20/22 + integration/ # vitest, live monorepo stack — gated behind LANGOS_TEST_LIVE +openapi/ + v1-openapi.yaml # vendored copy of the server's OpenAPI spec +tsup.config.ts # dual ESM+CJS build ``` ## Common dev tasks @@ -300,35 +315,44 @@ pnpm build # tsup → dist/{esm,cjs,types} 1. `src/resources/.ts` — extend the resource base, define methods 2. Wire into `src/client.ts` as `this. = new Resource(this)` 3. Public types into `src/types.ts` (camelCase, even if wire is snake_case) -4. Unit test in `test/unit/.test.ts` -5. Integration test in `test/integration/.test.ts` if it hits a new endpoint -6. Update server-side OpenAPI: `codestream-app/server/docs/v1-openapi.yaml` -7. Update prose docs: `docs/api/{getting-started,lifecycle,...}.md` -8. Bump alpha version in `package.json` +4. `FromWire` transform in `src/core/transform.ts` +5. Re-export type from `src/index.ts` +6. Unit test in `test/unit/transform.test.ts` (transform mapping) — and a resource-level test if methods do something non-trivial +7. Bump version in `package.json` (`0.2.0-alpha.X` → `0.2.0-alpha.X+1`) +8. Add an `sdk-contract` scenario in the monorepo (`examples/demo-customer/scenarios.sh` + `codestream-app/server/tests/sdk/scenariosPerTier.runner.js`) — separate PR after the new SDK version publishes +9. Re-vendor the OpenAPI spec: `pnpm run vendor:openapi` (assumes a sibling `langos-ide` checkout) ### Adding a webhook event -Update three places in lockstep: -- `src/types.ts` — add to the event union -- `docs/api/lifecycle.md` AND `docs/api/webhooks.md` — payload + when it fires -- Server-side publisher in `codestream-app/server/services/customer/webhookPublisher.js` +Update both: +- `src/types.ts` — add to `WebhookEventType` union AND extend the `Event` discriminated union with a new `BaseEvent<'foo.bar', FooData>` +- The server-side publisher in the monorepo (separate PR) is the source of truth for which events actually fire — keep this union in lockstep with `services/customer/webhookPublisher.js` -### Regenerating prose docs -The OpenAPI spec at `codestream-app/server/docs/v1-openapi.yaml` is the source of truth — the Scalar viewer at `docs/api/index.html` consumes it directly, no codegen step. For prose pages, regenerate by hand from the current SDK + OpenAPI surface. +### Regenerating types from OpenAPI +`src/generated/paths.d.ts` is generated from the vendored `openapi/v1-openapi.yaml`. To refresh after a server schema change: -### Publishing (deferred — needs npm scope claim) ```bash -npm publish --access restricted +pnpm run vendor:openapi # copies from sibling langos-ide checkout +pnpm run generate # runs openapi-typescript +``` + +Committed types are the source of truth — CI does not regenerate them. + +### Publishing +Triggered by pushing a `v*` tag — `.github/workflows/release.yml` runs lint+build+test, verifies the tag matches `package.json` version, validates the tarball contents, then publishes to npm with provenance and creates a GitHub Release. No manual `npm publish`. + +```bash +# After your PR is merged to main: +git checkout main && git pull +git tag v0.2.0-alpha.X +git push origin v0.2.0-alpha.X +# release.yml runs automatically; watch via gh run watch ``` ## Conventions worth preserving - **Zero runtime deps.** Anything beyond Node built-ins needs justification. - **camelCase in public API, snake_case on wire.** `core/transform.ts` handles both directions; never leak snake_case into public types. -- **AsyncIterable for lists.** All list endpoints walk pages automatically — no `nextPage()` callbacks. +- **AsyncIterable for lists.** All list endpoints walk pages automatically — `list()` returns `Promise`, so consumers `for await (... of await client.x.list())`. - **Auto idempotency for unsafe methods.** Generated if caller didn't pass one explicitly. - **Typed errors.** Don't return raw `Error`; classify into `LangosAPIError` subclasses. -- **No silent retries on 4xx (except 408/425/429).** 5xx retries with exponential backoff. - -## Where this is heading - -Will extract to `github.com/datacline/langos-sdk-node` once npm scope is claimed. The server-side stays in `langos-ide`. After extraction, this file is the SDK repo's onboarding doc — both AI consumers and AI contributors read it. +- **No silent retries on 4xx (except 408/429).** 409 is intentionally not retried (non-idempotent). 5xx (except 501) retries with exponential backoff. diff --git a/README.md b/README.md index ca2c0b4..57c13ac 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Official Node.js / TypeScript SDK for the Langos Partner API. -This SDK is for ATS partners (Greenhouse, Lever, Ashby, custom in-house ATSs) embedding Langos coding assessments into their hiring workflow. It wraps the public Partner API at `app.langos.io/api/v1`. +Use this SDK to wire Langos coding assessments into any hiring workflow. Wraps the public Partner API at `app.langos.io/api/v1`. > **Status:** alpha. Surface is small (assessments, candidates, sessions, webhook signature verification) and may change before `1.0.0`. @@ -35,7 +35,7 @@ const candidate = await client.candidates.create({ email: 'jane@example.com', name: 'Jane Doe', assessmentId: 'asm_abc', - externalId: 'greenhouse-app-12345', + externalId: 'your-app-12345', }); console.log('Invitation URL:', candidate.invitationUrl); @@ -63,7 +63,9 @@ The `account.integration.provider` field tells you which path your key is using: | Resource | Methods | |---|---| +| `client.account` | `retrieve`, `setWebhookEndpoint` | | `client.assessments` | `list`, `retrieve` | +| `client.challenges` | `list`, `retrieve` | | `client.candidates` | `list`, `retrieve`, `create`, `cancel` | | `client.sessions` | `retrieve`, `listForCandidate` | | `Langos.webhooks` | `constructEvent` (signature verification) | @@ -97,10 +99,12 @@ import { LangosForbiddenError, LangosNotFoundError, LangosBadRequestError, + LangosConflictError, LangosRateLimitError, LangosServerError, LangosConnectionError, LangosTimeoutError, + LangosSignatureVerificationError, } from '@datacline/langos-sdk-node'; try { @@ -127,7 +131,7 @@ For unsafe methods (`POST`, `PATCH`, `DELETE`, `PUT`), an `Idempotency-Key` head ```ts await client.candidates.create( { email: 'x@y.com', assessmentId: 'asm_abc' }, - { idempotencyKey: 'greenhouse-app-12345-v1' }, + { idempotencyKey: 'your-app-12345-v1' }, ); ``` @@ -172,7 +176,7 @@ const client = new Langos({ baseUrl: 'https://app.langos.io/api/v1', // override for self-host / staging timeout: 30_000, // ms maxRetries: 2, - appName: 'GreenhouseConnector/2.1.0', // appended to User-Agent + appName: 'YourApp/1.0.0', // appended to User-Agent logger: pino(), // optional Pino-shaped logger telemetry: false, // opt out of X-Langos-Client-Telemetry fetch: customFetch, // inject custom fetch diff --git a/package.json b/package.json index 214c78b..0487d21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datacline/langos-sdk-node", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "description": "Official Node.js / TypeScript SDK for the Langos API.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/client.ts b/src/client.ts index 9728fe6..6bdf988 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { AccountResource } from './resources/account.js'; import { AssessmentsResource } from './resources/assessments.js'; import { CandidatesResource } from './resources/candidates.js'; +import { ChallengesResource } from './resources/challenges.js'; import { SessionsResource } from './resources/sessions.js'; import { Webhooks } from './resources/webhooks.js'; import { noopLogger } from './core/logger.js'; @@ -15,6 +16,7 @@ export class Langos { readonly account: AccountResource; readonly assessments: AssessmentsResource; readonly candidates: CandidatesResource; + readonly challenges: ChallengesResource; readonly sessions: SessionsResource; /** @@ -63,6 +65,7 @@ export class Langos { this.account = new AccountResource(this.cfg); this.assessments = new AssessmentsResource(this.cfg); this.candidates = new CandidatesResource(this.cfg); + this.challenges = new ChallengesResource(this.cfg); this.sessions = new SessionsResource(this.cfg); } } diff --git a/src/core/transform.ts b/src/core/transform.ts index 1cb722c..dfa286f 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -5,6 +5,8 @@ import type { Account, Assessment, Candidate, + Challenge, + ChallengeStatus, Session, SessionAnalytics, SessionSubmission, @@ -28,6 +30,23 @@ export function assessmentFromWire(w: any): Assessment { }; } +/* ------------------------- challenge ------------------------- */ + +export function challengeFromWire(w: any): Challenge { + return { + id: String(w.id), + object: 'challenge', + title: String(w.title), + description: w.description ?? null, + language: String(w.language), + difficulty: w.difficulty ?? null, + category: w.category ?? null, + timeLimitMinutes: w.time_limit_minutes ?? null, + status: w.status as ChallengeStatus, + createdAt: String(w.created_at), + }; +} + /* ------------------------- candidate ------------------------- */ export function candidateFromWire(w: any): Candidate { diff --git a/src/index.ts b/src/index.ts index ce4ab23..27de4da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,9 @@ export type { CandidateCreateParams, CandidateListParams, CandidateStatus, + Challenge, + ChallengeListParams, + ChallengeStatus, Session, SessionAnalytics, SessionFeedback, diff --git a/src/resources/challenges.ts b/src/resources/challenges.ts new file mode 100644 index 0000000..d0a2bb1 --- /dev/null +++ b/src/resources/challenges.ts @@ -0,0 +1,36 @@ +import { APIResource } from './base.js'; +import { fetchPage } from '../core/pagination.js'; +import { challengeFromWire } from '../core/transform.js'; +import type { + Challenge, + ChallengeListParams, + AsyncIterablePage, + RequestOptions, +} from '../types.js'; + +export class ChallengesResource extends APIResource { + list( + params: ChallengeListParams = {}, + options?: RequestOptions, + ): Promise> { + return fetchPage( + cursor => + this.get<{ object: 'list'; data: any[]; has_more: boolean; next_cursor: string | null }>( + '/challenges', + { + limit: params.limit, + cursor: cursor ?? params.cursor, + status: params.status, + language: params.language, + }, + options, + ), + challengeFromWire, + ); + } + + async retrieve(id: string, options?: RequestOptions): Promise { + const w = await this.get(`/challenges/${encodeURIComponent(id)}`, undefined, options); + return challengeFromWire(w); + } +} diff --git a/src/types.ts b/src/types.ts index d8a8ddf..85e5334 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,21 @@ export interface Assessment { updatedAt: string; } +export type ChallengeStatus = 'draft' | 'published' | 'archived'; + +export interface Challenge { + id: string; + object: 'challenge'; + title: string; + description: string | null; + language: string; + difficulty: string | null; + category: string | null; + timeLimitMinutes: number | null; + status: ChallengeStatus; + createdAt: string; +} + export interface Candidate { id: string; object: 'candidate'; @@ -273,6 +288,10 @@ export interface CandidateListParams extends ListParams { export interface AssessmentListParams extends ListParams {} export interface SessionListParams extends ListParams {} +export interface ChallengeListParams extends ListParams { + status?: ChallengeStatus; + language?: string; +} /** * Per-call request options. Override SDK defaults (timeout, retries) for a diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index 0d119da..97897d6 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { accountFromWire, assessmentFromWire, + challengeFromWire, candidateFromWire, candidateCreateToWire, sessionFromWire, @@ -73,6 +74,48 @@ describe('transform: assessment', () => { }); }); +describe('transform: challenge', () => { + it('maps snake_case to camelCase and preserves nullable fields', () => { + const c = challengeFromWire({ + id: 'ch_1', + object: 'challenge', + title: 'Two Sum', + description: 'Find two numbers that sum to a target.', + language: 'python', + difficulty: 'easy', + category: 'algorithms', + time_limit_minutes: 30, + status: 'published', + created_at: '2026-05-03T00:00:00Z', + }); + expect(c.id).toBe('ch_1'); + expect(c.object).toBe('challenge'); + expect(c.title).toBe('Two Sum'); + expect(c.timeLimitMinutes).toBe(30); + expect(c.status).toBe('published'); + expect(c.createdAt).toBe('2026-05-03T00:00:00Z'); + }); + + it('coerces missing optional fields to null', () => { + const c = challengeFromWire({ + id: 'ch_2', + object: 'challenge', + title: 'Bare-bones', + description: null, + language: 'go', + difficulty: null, + category: null, + time_limit_minutes: null, + status: 'draft', + created_at: '2026-05-03T00:00:00Z', + }); + expect(c.description).toBeNull(); + expect(c.difficulty).toBeNull(); + expect(c.category).toBeNull(); + expect(c.timeLimitMinutes).toBeNull(); + }); +}); + describe('transform: candidate', () => { it('preserves metadata blob untouched', () => { const meta = { greenhouse_app_id: 'abc', tags: ['urgent', 'follow-up'] };