diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa88da..c44b659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- **SBA OHA support on the protests endpoint.** The Tango API now serves SBA OHA (Office of Hearings and Appeals) decisions as a third `source_system`, alongside GAO and the Court of Federal Claims. `ListProtestsOptions` gains a `naics_code` filter, and `ProtestRecord` / `PROTEST_SCHEMA` / `PROTEST_DOCKET_SCHEMA` gain the five SBA OHA fields: `challenged_party`, `naics_code`, `size_standard`, `outcome_reason`, and `judge`. They are opt-in via `shape=` (null on GAO and CoFC rows, so they are absent from the default shape) and ungated by tier. Note that `naics_code` is sent to `/api/protests/` verbatim — unlike `listContracts()` and `listOpportunities()`, which remap the same option onto the API's `naics` param. +- `protests` is now mapped in the conformance script's `RESOURCE_TO_METHOD`. It had no entry, so the resource was reported as "no SDK method mapped" and its filters were never checked against the contract — which is how the `naics_code` gap went unnoticed in both SDKs at once. + ## [1.1.0] - 2026-05-29 ### Changed (breaking) diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index 7d50f47..4d1a782 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -65,6 +65,7 @@ export const RESOURCE_TO_METHOD: Record = { gsa_elibrary_contracts: "listGsaElibraryContracts", itdashboard: "listItDashboard", budget_accounts: "listBudgetAccounts", + protests: "listProtests", offices: "listOffices", }; diff --git a/src/client.ts b/src/client.ts index 6a2d5ca..5e96cac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -526,6 +526,8 @@ export interface ListProtestsOptions { agency?: string; case_number?: string; solicitation_number?: string; + /** NAICS code at issue. Sent verbatim — not the `naics` remap used elsewhere. */ + naics_code?: string; protester?: string; search?: string; filed_date_after?: string; @@ -1909,7 +1911,7 @@ export class TangoClient { // Protests + IT Dashboard + Metrics // --------------------------------------------------------------------------- - /** List protests (GAO + CoFC). */ + /** List protests (GAO + CoFC + SBA OHA). */ async listProtests(options: ListProtestsOptions = {}): Promise> { return this._genericPaginatedList("/api/protests/", options); } diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index f49a88e..9ee2f8c 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -3336,6 +3336,41 @@ export const PROTEST_DOCKET_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + challenged_party: { + name: "challenged_party", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + naics_code: { + name: "naics_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + size_standard: { + name: "size_standard", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + outcome_reason: { + name: "outcome_reason", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + judge: { + name: "judge", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, }; export const PROTEST_SCHEMA: FieldSchemaMap = { @@ -3451,6 +3486,41 @@ export const PROTEST_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + challenged_party: { + name: "challenged_party", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + naics_code: { + name: "naics_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + size_standard: { + name: "size_standard", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + outcome_reason: { + name: "outcome_reason", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + judge: { + name: "judge", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, dockets: { name: "dockets", type: "dict", diff --git a/src/types.ts b/src/types.ts index f98655a..08a2183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,5 +147,11 @@ export interface ProtestRecord { resolved_agency?: Record | null; resolved_protester?: Record | null; docket?: Array>; + // SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest. + challenged_party?: string | null; + naics_code?: string | null; + size_standard?: string | null; + outcome_reason?: string | null; + judge?: string | null; [key: string]: unknown; } diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 64c1df9..dafc5ac 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -725,4 +725,35 @@ describe("TangoClient", () => { ); expect(deleteEndpointCall).toBeTruthy(); }); + + it("sends the protests naics_code filter verbatim, not as the `naics` remap", async () => { + const calls: { url: string }[] = []; + + const fetchImpl = async (url: string | URL): Promise => { + calls.push({ url: String(url) }); + const payload = { count: 0, next: null, previous: null, results: [] }; + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(payload); + }, + }; + }; + + const client = new TangoClient({ + apiKey: "test", + baseUrl: "https://example.test", + fetchImpl, + }); + + await client.listProtests({ source_system: "sba_oha", naics_code: "541512" }); + + const parsed = new URL(calls[0].url); + expect(parsed.pathname).toBe("/api/protests/"); + expect(parsed.searchParams.get("source_system")).toBe("sba_oha"); + // listContracts remaps naics_code -> `naics`; the protests API param is literally naics_code. + expect(parsed.searchParams.get("naics_code")).toBe("541512"); + expect(parsed.searchParams.get("naics")).toBeNull(); + }); }); diff --git a/tests/unit/shapes.schema.parity.test.ts b/tests/unit/shapes.schema.parity.test.ts index f7252c1..0030624 100644 --- a/tests/unit/shapes.schema.parity.test.ts +++ b/tests/unit/shapes.schema.parity.test.ts @@ -91,8 +91,8 @@ describe("Ported explicit schemas — parity with Python SDK", () => { ); }); - it("PROTEST_SCHEMA has 18 fields including dockets and organization expansions", () => { - expect(Object.keys(PROTEST_SCHEMA)).toHaveLength(18); + it("PROTEST_SCHEMA has 23 fields including dockets and organization expansions", () => { + expect(Object.keys(PROTEST_SCHEMA)).toHaveLength(23); expect(PROTEST_SCHEMA.case_id.isOptional).toBe(false); expect(PROTEST_SCHEMA.title).toBeDefined(); expect(PROTEST_SCHEMA.filed_date.type).toBe("datetime"); @@ -101,13 +101,30 @@ describe("Ported explicit schemas — parity with Python SDK", () => { expect(PROTEST_SCHEMA.organization.nestedModel).toBe("OrganizationOffice"); }); - it("PROTEST_DOCKET_SCHEMA has 16 fields", () => { - expect(Object.keys(PROTEST_DOCKET_SCHEMA)).toHaveLength(16); + it("PROTEST_SCHEMA carries the five SBA OHA fields as optional scalars", () => { + for (const field of [ + "challenged_party", + "naics_code", + "size_standard", + "outcome_reason", + "judge", + ]) { + expect(PROTEST_SCHEMA[field].type).toBe("str"); + expect(PROTEST_SCHEMA[field].isOptional).toBe(true); + expect(PROTEST_SCHEMA[field].nestedModel).toBeNull(); + } + }); + + it("PROTEST_DOCKET_SCHEMA has 21 fields", () => { + expect(Object.keys(PROTEST_DOCKET_SCHEMA)).toHaveLength(21); expect(PROTEST_DOCKET_SCHEMA.docket_number).toBeDefined(); expect(PROTEST_DOCKET_SCHEMA.case_number).toBeDefined(); expect(PROTEST_DOCKET_SCHEMA.filed_date.type).toBe("datetime"); expect(PROTEST_DOCKET_SCHEMA.docket_url.type).toBe("str"); expect(PROTEST_DOCKET_SCHEMA.digest).toBeDefined(); + // The five SBA OHA fields are servable inside dockets(...) too. + expect(PROTEST_DOCKET_SCHEMA.challenged_party).toBeDefined(); + expect(PROTEST_DOCKET_SCHEMA.judge).toBeDefined(); }); it("GSA_ELIBRARY_CONTRACT_SCHEMA has 9 fields with idv ref and recipient expansions", () => {