From 68e97d4d5ce17ceb6928f9eca2d46ddc442170cf Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sun, 20 Sep 2026 13:03:24 -0500 Subject: [PATCH] feat(contract-appeals): add the contract appeals resource `listContractAppeals` and `getContractAppeal` cover `/api/contract_appeals/`, the Contract Disputes Act decisions of the civilian and defense boards of contract appeals. `ContractAppealRecord` types the 21 response fields as optional, so a `decision_text` withheld below Enterprise stays absent rather than null. The vendored contract already carries this resource, so wrapping it maps `contract_appeals` in the conformance gate and removes it from both coverage baselines. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +- README.md | 3 +- contracts/conformance_baseline.json | 1 - contracts/shape_coverage_baseline.json | 3 +- docs/API_REFERENCE.md | 45 +++++ scripts/check-filter-shape-conformance.ts | 4 +- scripts/check-shape-coverage.ts | 1 + src/client.ts | 67 +++++++- src/index.ts | 1 + src/shapes/explicitSchemas.ts | 152 +++++++++++++++++ src/types.ts | 39 +++++ tests/unit/client.contract-appeals.test.ts | 190 +++++++++++++++++++++ 12 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 tests/unit/client.contract-appeals.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8acd3..ab9cb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **Boards-of-contract-appeals decisions** (Tango API 4.26.0). `listContractAppeals(options)` and `getContractAppeal(uuid, options)` over `/api/contract_appeals/`, with every filter the API accepts declared as a typed option on `ListContractAppealsOptions` (`search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, the `decision_date_after` / `_before` pair, `listed`, `document_id`, `ordering`), the new `ContractAppealRecord` return type, and a registered `ContractAppeal` shape schema so the typed shape API resolves the resource's fields. + + These are Contract Disputes Act decisions from the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests remain the separate `listProtests()` resource, and the two do not overlap. + + Two properties are documented on the record type and in `docs/API_REFERENCE.md`. An unshaped list returns only a **core subset** of the decision, so every property on `ContractAppealRecord` is optional and anything past the subset has to be named in a `shape`. And `decision_text` is **Enterprise-only, with the key absent rather than null** below that tier — a caller checks for presence, not for a nullish value. + - **`attachments(extracted_text)` — SLED document bodies on the Small plan and above** (Tango API 4.25.1; parity with tango-python). The leaf joins `SledAttachmentPayload` and the explicit shape schema, and the contract was re-vendored so it validates instead of being rejected client-side. Three properties are documented on the interface and in `docs/API_REFERENCE.md`: the leaf must be **named** (neither `ShapeConfig` default includes it, and `attachments(*)` does not carry it, because the API only resolves the body for a caller who asked); the **key is absent rather than null** when the text is not being served; and a **contested document never returns text at any plan**. Searching document text stays ungated on every plan and returns no fragment of it. - **State, local and education (SLED) procurement support** (Tango API 4.25.0; parity with tango-python). Six methods over the new `/api/sled/` namespace: `listSledOpportunities`/`getSledOpportunity`, `listSledOpportunityRevisions`, `getSledCoverage`, `listSledForecasts`/`getSledForecast`, plus `iterateSledOpportunities` / `iterateSledForecasts` and their `IterableListMethod` entries. New model interfaces `SledOpportunity`, `SledOpportunityRevision`, `SledForecast` and the six nested payload types; explicit shape schemas for all of them; five `ShapeConfig` defaults. Every one of the API's 27 solicitation filters and 13 forecast filters is a typed option. @@ -23,7 +29,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Changed - Re-vendored `contracts/filter_shape_contract.json` (schema_version 2, 48 resources) and regenerated `src/shapes/generatedOverlay.ts` from it — 359 fields across 25 containers, 73 nested schemas. -- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. Contract appeals and eBuy requests are in the contract without SDK methods yet, so both are baselined as tracked gaps. +- Re-vendored the contract for Tango API 5.1.0 and regenerated the overlay, which now merges a model's expand when two resources embed it instead of letting the narrower copy win. eBuy requests are in the contract without an SDK method yet, so it is baselined as a tracked gap. - Baselined 14 reverse-shape-coverage gaps in `contracts/shape_coverage_baseline.json`, matching tango-python. All 14 are the nested sub-resource routes above, which reuse the parent resource's model rather than carrying one of their own; none is SLED, and none is a regression — they became visible only with the re-vendored contract. ### Fixed @@ -35,6 +41,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Documentation +- New **Contract Appeals** section in `docs/API_REFERENCE.md` covering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-null `decision_text`). `README.md`'s method list gained both methods. - New **State & Local (SLED) — Beta** section in `docs/API_REFERENCE.md` covering all six methods, both defaults that surprise people, and the new `ShapeConfig` constants. - `docs/WEBHOOKS.md` troubleshooting gained the date-lapse rule and its one exception. An exclusion or a DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — but `alerts.sled_opportunity.match` **does** fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes. diff --git a/README.md b/README.md index 665d4de..e526bcf 100644 --- a/README.md +++ b/README.md @@ -203,10 +203,11 @@ The Node.js client mirrors the Python SDK's high-level API. Selected highlights: - `getForecast(id, options)` / `getOpportunity(opportunityId, options)` / `getNotice(noticeId, options)` / `getGrant(grantId, options)` - `searchOpportunityAttachments(options)` -**GSA eLibrary / Protests / IT Dashboard / LCATs** +**GSA eLibrary / Protests / Contract Appeals / IT Dashboard / LCATs** - `listGsaElibraryContracts(options)` / `getGsaElibraryContract(uuid, options)` - `listProtests(options)` / `getProtest(caseId)` +- `listContractAppeals(options)` / `getContractAppeal(uuid, options)` - `listItDashboard(options)` / `getItDashboard(uii)` - `listLcats(options)` / `listIdvLcats(key, options)` diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index f5c22f7..a63dbeb 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -2,7 +2,6 @@ "_comment": "Accepted SDK coverage gaps vs the API contract. Gaps listed here downgrade from error to warning in scripts/check-filter-shape-conformance.ts. Each entry is tracked backlog: remove it in the same PR that closes the gap in the SDK. `missing_filters` maps a resource to filter params the mapped method does not expose; `unimplemented_resources` lists contract resources with no SDK method at all. events and news are content endpoints with no list method and stay baselined permanently (tango-python does the same).", "missing_filters": {}, "unimplemented_resources": [ - "contract_appeals", "ebuy/requests", "events", "news" diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index d5cc037..0d20bc4 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,10 +1,9 @@ { "description": "Known reverse shape-coverage gaps (Tango exposes, SDK schema lacks), accepted as a tracked backlog. check-shape-coverage.ts fails only on gaps NOT listed here. Burn down and regenerate with --update-baseline.", - "count": 16, + "count": 15, "known_gaps": [ "unmapped_resource|agencies_contracts_awarding|(root)|(no model mapped)", "unmapped_resource|agencies_contracts_funding|(root)|(no model mapped)", - "unmapped_resource|contract_appeals|(root)|(no model mapped)", "unmapped_resource|contracts_subawards|(root)|(no model mapped)", "unmapped_resource|ebuy/requests|(root)|(no model mapped)", "unmapped_resource|entities_contracts|(root)|(no model mapped)", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index f734c68..ddbb4e7 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -405,6 +405,51 @@ Takes the case's `case_id` UUID, not a case number. To look a case up by number, --- +## Contract Appeals + +Contract Disputes Act decisions from the two boards of contract appeals — the CBCA (civilian) and the ASBCA (defense). These are disputes under an existing contract; a challenge to an award is a bid protest, which is the separate [Protests](#protests) resource. + +### `listContractAppeals(options?)` + +```ts +const appeals = await client.listContractAppeals({ + board: "asbca", + decision_date_after: "2026-01-01", + limit: 25, +}); +``` + +#### Parameters (Contract Appeals) + +| Name | Type | Description | +| --------------------------------- | --------- | -------------------------------------------------------------------------------------- | +| `search` | `string` | Ranked full-text search across the decision. | +| `board` | `string` | `cbca` or `asbca`. | +| `docket` | `string` | Docket number, as the board publishes it. | +| `appellant` | `string` | Appellant name. | +| `judge` | `string` | Deciding judge. | +| `decision_type` | `string` | Normalized decision type. | +| `decision_date_after` / `_before` | `string` | ISO date bounds on the decision date. | +| `listed` | `boolean` | Whether the decision is currently on the board's published listing. | +| `document_id` | `string` | The board's own document identifier. | +| `ordering` | `string` | `decision_date` (default `-decision_date`), `appellant`, `first_listed_at`, or `rank`. | + +`rank` ordering is only meaningful with a non-empty `search`. The standard `page` / `limit` / `shape` / `flat` / `flatLists` / `joiner` options apply. + +Without a `shape` the API returns a core subset of the decision, so name the rest explicitly when you need it. `decision_text` is served on the Enterprise tier only, and below it the key is **absent rather than null** — test for presence, not for a nullish value. + +### `getContractAppeal(uuid, options?)` + +```ts +const appeal = await client.getContractAppeal("00000000-0000-0000-0000-000000000001", { + shape: "uuid,board,docket_numbers,decision_date,appellant,judge,decision_text", +}); +``` + +Returns a `ContractAppealRecord`. Every property on it is optional, for the same two reasons: an unshaped list carries only the core subset, and `decision_text` is tier-gated. + +--- + ## IT Dashboard ### `listItDashboard(options?)` diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index c1ddcc9..b5bf2a5 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -68,8 +68,8 @@ export const RESOURCE_TO_METHOD: Record = { budget_accounts: "listBudgetAccounts", offices: "listOffices", protests: "listProtests", - // Contract appeals and eBuy requests are published by the API but not yet ported — baselined as tracked gaps. - contract_appeals: null, + contract_appeals: "listContractAppeals", + // eBuy requests are published by the API but not yet ported — baselined as a tracked gap. "ebuy/requests": null, psc: "listPsc", mas_sins: "listMasSins", diff --git a/scripts/check-shape-coverage.ts b/scripts/check-shape-coverage.ts index 15f9d8c..aea38e4 100644 --- a/scripts/check-shape-coverage.ts +++ b/scripts/check-shape-coverage.ts @@ -66,6 +66,7 @@ export const RESOURCE_TO_MODEL: Record = { "budget/accounts": "BudgetAccount", budget_accounts: "BudgetAccount", protests: "Protest", + contract_appeals: "ContractAppeal", offices: "Office", assistance_listings: "AssistanceListing", business_types: "BusinessType", diff --git a/src/client.ts b/src/client.ts index 1d8eabe..59fad05 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,7 +6,16 @@ import type { ShapeSpec } from "./shapes/types.js"; import { isRecord } from "./utils/guards.js"; import { HttpClient } from "./utils/http.js"; import { unflattenResponse } from "./utils/unflatten.js"; -import { AgencyRecord, PaginatedResponse, ProtestRecord, RateLimitInfo, ResolveResult, TangoClientOptions, ValidateResult } from "./types.js"; +import { + AgencyRecord, + ContractAppealRecord, + PaginatedResponse, + ProtestRecord, + RateLimitInfo, + ResolveResult, + TangoClientOptions, + ValidateResult, +} from "./types.js"; import type { WebhookAlert, WebhookAlertCreateInput, @@ -951,6 +960,34 @@ export interface ListProtestsOptions { [key: string]: unknown; } +/** + * Contract-appeal list options. + * + * Contract appeals are Contract Disputes Act decisions from the civilian (CBCA) and defense (ASBCA) boards of contract appeals — disputes under an existing contract. A challenge to an award is a bid protest, which is the separate `listProtests()` resource. + */ +export interface ListContractAppealsOptions extends ListOptionsBase { + /** Separator for flattened keys. Only meaningful alongside `flat`. */ + joiner?: string; + /** Ranked full-text search across the decision. */ + search?: string; + /** Deciding board — `cbca` (civilian) or `asbca` (defense). */ + board?: string; + /** Docket number, as the board publishes it. */ + docket?: string; + appellant?: string; + judge?: string; + decision_type?: string; + decision_date_after?: string; + decision_date_before?: string; + /** Whether the decision is currently on the board's published listing. */ + listed?: boolean; + /** The board's own document identifier. */ + document_id?: string; + /** Sort field (decision_date, appellant, first_listed_at, rank). Defaults to `-decision_date`; `rank` is only meaningful with a non-empty `search`. */ + ordering?: string; + [key: string]: unknown; +} + export interface ListItDashboardOptions { page?: number; limit?: number; @@ -2646,6 +2683,34 @@ export class TangoClient { return await this.http.get(`/api/protests/${encodeURIComponent(caseId)}/`); } + /** + * List boards-of-contract-appeals decisions (`/api/contract_appeals/`). + * + * These are Contract Disputes Act appeals decided by the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests are `listProtests()`. + * + * Without a `shape` the API returns a core subset of the decision, so shape explicitly for the rest. `decision_text` is Enterprise-only and its key is absent, not null, below that tier. + */ + async listContractAppeals(options: ListContractAppealsOptions = {}): Promise> { + return this._genericPaginatedList("/api/contract_appeals/", options); + } + + /** Get a single contract-appeal decision by uuid (`/api/contract_appeals/{uuid}/`). */ + async getContractAppeal( + uuid: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise { + if (!uuid) throw new TangoValidationError("Contract appeal uuid is required"); + const { shape, flat, flatLists, joiner } = options; + const params: AnyRecord = {}; + if (shape) params.shape = shape; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + return await this.http.get(`/api/contract_appeals/${encodeURIComponent(uuid)}/`, params); + } + /** List IT Dashboard investments. */ async listItDashboard(options: ListItDashboardOptions = {}): Promise> { return this._genericPaginatedList("/api/itdashboard/", options); diff --git a/src/index.ts b/src/index.ts index 95456f3..3aa20a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export type { ListSledOpportunityRevisionsOptions, ListSledForecastsOptions, ListProtestsOptions, + ListContractAppealsOptions, ListItDashboardOptions, ListMetricsOptions, ResolveInput, diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index b1e7959..bb7b71d 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -3467,6 +3467,157 @@ export const PROTEST_SCHEMA: FieldSchemaMap = { }, }; +// Boards-of-contract-appeals decision (CBCA + ASBCA). `decision_text` is Enterprise-only, so below that tier the key is absent rather than null. +export const CONTRACT_APPEAL_SCHEMA: FieldSchemaMap = { + uuid: { + name: "uuid", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + board: { + name: "board", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + docket_numbers: { + name: "docket_numbers", + type: "str", + isOptional: false, + isList: true, + nestedModel: null, + }, + docket_source: { + name: "docket_source", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + docket_raw: { + name: "docket_raw", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_date: { + name: "decision_date", + type: "date", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_date_raw: { + name: "decision_date_raw", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_date_repaired: { + name: "decision_date_repaired", + type: "bool", + isOptional: false, + isList: false, + nestedModel: null, + }, + appellant: { + name: "appellant", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + judge: { + name: "judge", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_type: { + name: "decision_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_type_raw: { + name: "decision_type_raw", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + url: { + name: "url", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + document_id: { + name: "document_id", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + listing_url: { + name: "listing_url", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + listing_year: { + name: "listing_year", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + first_listed_at: { + name: "first_listed_at", + type: "datetime", + isOptional: false, + isList: false, + nestedModel: null, + }, + listed: { + name: "listed", + type: "bool", + isOptional: false, + isList: false, + nestedModel: null, + }, + text_status: { + name: "text_status", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + text_char_count: { + name: "text_char_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + decision_text: { + name: "decision_text", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + // GSA eLibrary IDV reference (the linked IDV summary on a GSA eLibrary contract) export const GSA_ELIBRARY_IDV_REF_SCHEMA: FieldSchemaMap = { key: { @@ -5613,6 +5764,7 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Notice: NOTICE_SCHEMA, Protest: PROTEST_SCHEMA, ProtestDocket: PROTEST_DOCKET_SCHEMA, + ContractAppeal: CONTRACT_APPEAL_SCHEMA, Agency: AGENCY_SCHEMA, Grant: GRANT_SCHEMA, Vehicle: VEHICLE_SCHEMA, diff --git a/src/types.ts b/src/types.ts index 9a71abd..fddc861 100644 --- a/src/types.ts +++ b/src/types.ts @@ -182,3 +182,42 @@ export interface ProtestRecord { docket?: Array>; [key: string]: unknown; } + +/** + * Typed return model for `client.getContractAppeal()`. Mirrors the boards-of-contract-appeals decision schema (CBCA and ASBCA). + * + * Every property is optional: an unshaped list response carries only a core subset of them, and `decision_text` is absent entirely below the Enterprise tier. + */ +export interface ContractAppealRecord { + uuid?: string; + /** Deciding board — `cbca` (civilian) or `asbca` (defense). */ + board?: string; + /** Every docket number the decision covers; a consolidated appeal carries more than one. */ + docket_numbers?: string[]; + docket_source?: string | null; + /** The docket text as the board published it, before parsing into `docket_numbers`. */ + docket_raw?: string | null; + /** ISO date. */ + decision_date?: string | null; + /** The date as the board published it, before parsing. */ + decision_date_raw?: string | null; + /** Whether the published date needed repair to parse — `decision_date_raw` keeps the board's original string either way. */ + decision_date_repaired?: boolean; + appellant?: string | null; + judge?: string | null; + decision_type?: string | null; + /** The decision type as the board published it, before normalization. */ + decision_type_raw?: string | null; + url?: string; + document_id?: string; + listing_url?: string; + listing_year?: number | null; + /** ISO datetime of the first time Tango saw the decision on the board's listing. */ + first_listed_at?: string; + listed?: boolean; + text_status?: string | null; + text_char_count?: number | null; + /** Full decision text. Enterprise only — below that tier the key is absent rather than null, so check with `in` rather than for a nullish value. */ + decision_text?: string | null; + [key: string]: unknown; +} diff --git a/tests/unit/client.contract-appeals.test.ts b/tests/unit/client.contract-appeals.test.ts new file mode 100644 index 0000000..435d1fe --- /dev/null +++ b/tests/unit/client.contract-appeals.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for the boards-of-contract-appeals endpoint (`/api/contract_appeals/`). + * + * Covers the request contract — correct path, filters passed through under the + * API's own param names, the uuid detail route — via the injected fetchImpl + * mock, plus the shape schema the SDK registers for the resource. + */ + +import { TangoClient } from "../../src/client.js"; +import { SchemaRegistry } from "../../src/shapes/schema.js"; +import type { ContractAppealRecord } from "../../src/types.js"; + +type RecordedCall = { url: string; init?: RequestInit | undefined }; + +interface MockResponseBody { + count?: number; + next?: string | null; + previous?: string | null; + results?: unknown[]; + [key: string]: unknown; +} + +function recordingFetch(body: MockResponseBody | unknown = { count: 0, next: null, previous: null, results: [] }): { + fetchImpl: typeof fetch; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(body); + }, + }; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function makeClient(body?: MockResponseBody | unknown): { client: TangoClient; calls: RecordedCall[] } { + const { fetchImpl, calls } = recordingFetch(body); + const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); + return { client, calls }; +} + +function params(calls: RecordedCall[]): URLSearchParams { + return new URL(calls[0].url).searchParams; +} + +const APPEAL: ContractAppealRecord = { + uuid: "6f1c2e70-7a64-4a19-9b0e-0f9f2a1c3d45", + board: "cbca", + docket_numbers: ["CBCA 7890", "CBCA 7891"], + docket_source: "listing", + docket_raw: "CBCA 7890, 7891", + decision_date: "2026-04-17", + decision_date_raw: "April 17, 2026", + decision_date_repaired: false, + appellant: "Meridian Construction Group", + judge: "Sullivan", + decision_type: "decision", + decision_type_raw: "DECISION", + url: "https://example.gov/decisions/cbca-7890.pdf", + document_id: "cbca-7890-2026", + listing_url: "https://example.gov/decisions/2026", + listing_year: 2026, + first_listed_at: "2026-04-18T09:12:00Z", + listed: true, + text_status: "extracted", + text_char_count: 41822, +}; + +describe("TangoClient — contract appeals", () => { + it("listContractAppeals hits /api/contract_appeals/ with filters under API param names", async () => { + const { client, calls } = makeClient(); + await client.listContractAppeals({ + board: "asbca", + docket: "ASBCA 63210", + appellant: "Meridian Construction Group", + judge: "Sullivan", + decision_type: "decision", + decision_date_after: "2026-01-01", + decision_date_before: "2026-06-30", + document_id: "asbca-63210-2026", + search: "differing site conditions", + ordering: "-decision_date", + limit: 10, + }); + + expect(calls[0].url).toContain("/api/contract_appeals/"); + const p = params(calls); + expect(p.get("board")).toBe("asbca"); + expect(p.get("docket")).toBe("ASBCA 63210"); + expect(p.get("appellant")).toBe("Meridian Construction Group"); + expect(p.get("judge")).toBe("Sullivan"); + expect(p.get("decision_type")).toBe("decision"); + expect(p.get("decision_date_after")).toBe("2026-01-01"); + expect(p.get("decision_date_before")).toBe("2026-06-30"); + expect(p.get("document_id")).toBe("asbca-63210-2026"); + expect(p.get("search")).toBe("differing site conditions"); + expect(p.get("ordering")).toBe("-decision_date"); + expect(p.get("limit")).toBe("10"); + }); + + it("sends listed=false rather than dropping it", async () => { + const { client, calls } = makeClient(); + await client.listContractAppeals({ listed: false }); + expect(params(calls).get("listed")).toBe("false"); + }); + + it("sends no shape when the caller names none", async () => { + // The core-subset default belongs to the API; synthesizing a shape here would pin the SDK to a field list it does not own. + const { client, calls } = makeClient(); + await client.listContractAppeals(); + const p = params(calls); + expect(p.has("shape")).toBe(false); + expect(p.get("page")).toBe("1"); + }); + + it("passes an explicit shape through", async () => { + const { client, calls } = makeClient(); + await client.listContractAppeals({ shape: "uuid,board,decision_date,decision_text" }); + expect(params(calls).get("shape")).toBe("uuid,board,decision_date,decision_text"); + }); + + it("parses list results", async () => { + const { client } = makeClient({ count: 1, next: null, previous: null, results: [APPEAL] }); + const page = await client.listContractAppeals({ board: "cbca" }); + expect(page.count).toBe(1); + const first = page.results[0] as ContractAppealRecord; + expect(first.board).toBe("cbca"); + expect(first.docket_numbers).toEqual(["CBCA 7890", "CBCA 7891"]); + expect(first.listing_year).toBe(2026); + }); + + it("getContractAppeal uses the uuid route", async () => { + const { client, calls } = makeClient(APPEAL); + const appeal = await client.getContractAppeal(APPEAL.uuid!); + expect(calls[0].url).toContain(`/api/contract_appeals/${APPEAL.uuid!}/`); + expect(appeal.judge).toBe("Sullivan"); + expect(appeal.decision_date_repaired).toBe(false); + }); + + it("getContractAppeal rejects an empty uuid before issuing a request", async () => { + const { client, calls } = makeClient(); + await expect(client.getContractAppeal("")).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + it("getContractAppeal threads shape and flat", async () => { + const { client, calls } = makeClient(APPEAL); + await client.getContractAppeal(APPEAL.uuid!, { shape: "uuid,decision_text", flat: true, joiner: "__" }); + const p = params(calls); + expect(p.get("shape")).toBe("uuid,decision_text"); + expect(p.get("flat")).toBe("true"); + expect(p.get("joiner")).toBe("__"); + }); + + it("returns the decision body when the tier serves it", async () => { + const { client } = makeClient({ ...APPEAL, decision_text: "The appeal is sustained." }); + const appeal = await client.getContractAppeal(APPEAL.uuid!); + expect(appeal.decision_text).toBe("The appeal is sustained."); + }); + + it("leaves decision_text absent, not null, when the tier does not serve it", async () => { + // Below Enterprise the key is missing entirely, so a caller must test for presence rather than for a nullish value. + const { client } = makeClient(APPEAL); + const appeal = await client.getContractAppeal(APPEAL.uuid!); + expect("decision_text" in appeal).toBe(false); + }); +}); + +describe("ContractAppeal shape schema", () => { + const registry = new SchemaRegistry(); + + it.each(Object.keys(APPEAL).concat("decision_text"))("%s is a known field", (field) => { + expect(registry.getSchema("ContractAppeal").fields[field]).toBeDefined(); + }); + + it("docket_numbers is a list of scalars", () => { + const spec = registry.getField("ContractAppeal", "docket_numbers"); + expect(spec.isList).toBe(true); + expect(spec.nestedModel).toBeNull(); + }); + + it.each(["docket", "search"])("%s is a filter and never a response field", (field) => { + expect(registry.getSchema("ContractAppeal").fields[field]).toBeUndefined(); + }); +});