From 77f287869d226ed5d4e7ed8d55f2ebd5bc355838 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 9 Jul 2026 12:38:15 -0500 Subject: [PATCH] =?UTF-8?q?feat(protests):=20SBA=20OHA=20support=20?= =?UTF-8?q?=E2=80=94=20naics=5Fcode=20filter=20+=20five=20OHA=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tango API now serves SBA OHA (Office of Hearings and Appeals) decisions on /api/protests/ as a third source_system, alongside GAO and the Court of Federal Claims. This brings the SDK to parity, mirroring the tango-python change of the same name. ListProtestsOptions gains naics_code. It is sent verbatim, NOT remapped onto `naics` the way listContracts() and listOpportunities() remap the same option — the protests API param is literally naics_code. The new client test asserts both halves of that. ProtestRecord, PROTEST_SCHEMA, and PROTEST_DOCKET_SCHEMA gain challenged_party, naics_code, size_standard, outcome_reason, and judge. ProtestRecord has an index signature, so these already parsed at runtime — declaring them is what gives consumers types instead of `unknown`. The schema parity counts move 18 -> 23 (case) and 16 -> 21 (docket), verified against tango-python's explicit_schemas.py rather than assumed. Also maps `protests` in RESOURCE_TO_METHOD. It had no entry, so the resource was reported as "no SDK method mapped" and its filters were never validated against the contract — the same blind spot as the Python SDK, which is why the gap survived in both. Verified the checker now sees it: removing naics_code reports "protests: `listProtests` relies on index signature for filters: naics_code". 222 tests pass; typecheck, eslint, and build clean. The three prettier warnings on touched files pre-exist on origin/main (verified in a clean worktree). --- CHANGELOG.md | 4 ++ scripts/check-filter-shape-conformance.ts | 1 + src/client.ts | 4 +- src/shapes/explicitSchemas.ts | 70 +++++++++++++++++++++++ src/types.ts | 6 ++ tests/unit/client.test.ts | 31 ++++++++++ tests/unit/shapes.schema.parity.test.ts | 25 ++++++-- 7 files changed, 136 insertions(+), 5 deletions(-) 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", () => {