From a81135272d6ed24babc978ccd2797bb559718789 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Wed, 23 Sep 2026 08:18:53 -0500 Subject: [PATCH] fix: accept doc_role, SLED delisting and protest fields the API serves Shaping `attachments(name,doc_role,doc_role_alt)` on opportunities and notices, and `delisted_at` or `meta(jurisdiction_declared)` on SLED solicitations, threw `ShapeValidationError` client-side even though the API returns all of them. The vendored contract is refreshed to Tango API 5.1.0 and the overlay regenerated. The overlay generator now merges two walks that reach the same model's expand instead of letting the later, narrower one replace the earlier one; that replacement is what dropped `doc_role` from the opportunity attachment schema. The SLED fields join the curated schemas with their real types, and the model interfaces gain `delisted_at`, `jurisdiction_declared` and a typed `OpportunityAttachment` with the six document roles. `ProtestRecord` now types `agency` and `protester` as the strings the API returns, and `getProtest` documents that it takes the `case_id` UUID rather than a case number. Contract appeals and eBuy requests are in the refreshed contract without SDK methods yet, so both are baselined as tracked gaps. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 + README.md | 2 +- contracts/conformance_baseline.json | 2 + contracts/filter_shape_contract.json | 436 +++++++++++++++++++++- contracts/shape_coverage_baseline.json | 4 +- docs/API_REFERENCE.md | 6 +- scripts/check-filter-shape-conformance.ts | 3 + scripts/generate-shape-overlay.ts | 16 +- src/client.ts | 14 +- src/models/Notice.ts | 3 + src/models/Opportunity.ts | 25 ++ src/models/Sled.ts | 6 +- src/models/index.ts | 2 +- src/shapes/explicitSchemas.ts | 14 + src/shapes/generatedOverlay.ts | 34 +- src/types.ts | 18 +- tests/unit/client.observability.test.ts | 29 +- tests/unit/client.shape-parity.test.ts | 96 +++++ 18 files changed, 671 insertions(+), 47 deletions(-) create mode 100644 tests/unit/client.shape-parity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d00ad1..9d8acd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,16 @@ 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. - 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 + +- **`attachments(name,doc_role,doc_role_alt)` no longer throws `ShapeValidationError`** on `getOpportunity`, `listOpportunities`, `getNotice` and `listNotices`; the API serves both fields on the Pro plan and above when they are named. New `OpportunityAttachment` and `AttachmentDocRole` types cover the six roles and are used on `Opportunity` and `Notice`. +- **SLED solicitations accept `delisted_at` and `meta(jurisdiction_declared)`**, which were rejected client-side. `SledOpportunity` and `SledMetaPayload` carry the new fields, and `status_reason` now documents `delisted`. +- **`ProtestRecord` types `agency` and `protester` as the strings the API returns**, and gains `title`, `docket_url`, `decision_url`, `organization`, `dockets` and `decisions`. The never-served `docket` property is deprecated in favor of `dockets`. +- **`getProtest` takes the case's `case_id` UUID**, which is all the route accepts; a case number returns 404. The parameter is renamed `caseId` and the docs say how to look a case up by number, and `listProtests` is documented as covering GAO, COFC and SBA OHA. + ### Documentation - 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. diff --git a/README.md b/README.md index d709c46..665d4de 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ The Node.js client mirrors the Python SDK's high-level API. Selected highlights: **GSA eLibrary / Protests / IT Dashboard / LCATs** - `listGsaElibraryContracts(options)` / `getGsaElibraryContract(uuid, options)` -- `listProtests(options)` / `getProtest(caseNumber)` +- `listProtests(options)` / `getProtest(caseId)` - `listItDashboard(options)` / `getItDashboard(uii)` - `listLcats(options)` / `listIdvLcats(key, options)` diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index c036c0f..f5c22f7 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -2,6 +2,8 @@ "_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/filter_shape_contract.json b/contracts/filter_shape_contract.json index 3fa44aa..0e47b14 100644 --- a/contracts/filter_shape_contract.json +++ b/contracts/filter_shape_contract.json @@ -1,6 +1,6 @@ { "meta": { - "api_version": "4.25.1", + "api_version": "5.1.0", "description": "Canonical API filter/shape contract. Downstream consumers (SDK, MCP) should validate their conformance against this manifest.", "generated_from": "scripts/filter_shape_conformance.py", "schema_version": 2 @@ -252,7 +252,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -994,7 +994,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -2471,6 +2471,156 @@ "show_shapes" ] }, + "contract_appeals": { + "basename": "contractappeal", + "docs_file": null, + "docs_params": [], + "prefix": "contract_appeals", + "resource_key": "contract_appeals", + "runtime": { + "filter_params": [ + "appellant", + "board", + "decision_date_after", + "decision_date_before", + "decision_type", + "docket", + "document_id", + "judge", + "listed", + "search" + ], + "filter_params_detail": { + "appellant": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "board": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "decision_date_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "decision_date_before": { + "filter_class": "DateFilter", + "lookup": "lte", + "type": "date" + }, + "decision_type": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "docket": { + "filter_class": "DocketFilter", + "type": "string" + }, + "document_id": { + "filter_class": "CharFilter", + "type": "string" + }, + "judge": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "listed": { + "filter_class": "BooleanFilter", + "type": "boolean" + }, + "search": { + "filter_class": "ContractAppealSearchFilter", + "type": "string" + } + }, + "ordering_aliases": [], + "ordering_fields": [ + "appellant", + "decision_date", + "first_listed_at", + "rank" + ], + "pagination": { + "class": "StandardResultsSetPagination", + "max_page_size": 100 + }, + "shape": { + "expands": {}, + "fields": [ + "appellant", + "board", + "decision_date", + "decision_date_raw", + "decision_date_repaired", + "decision_text", + "decision_type", + "decision_type_raw", + "docket_numbers", + "docket_raw", + "docket_source", + "document_id", + "first_listed_at", + "judge", + "listed", + "listing_url", + "listing_year", + "text_char_count", + "text_status", + "url", + "uuid" + ] + }, + "shape_flat_paths": [ + "appellant", + "board", + "decision_date", + "decision_date_raw", + "decision_date_repaired", + "decision_text", + "decision_type", + "decision_type_raw", + "docket_numbers", + "docket_raw", + "docket_source", + "document_id", + "first_listed_at", + "judge", + "listed", + "listing_url", + "listing_year", + "text_char_count", + "text_status", + "url", + "uuid" + ], + "shape_gated_by_level": { + "business": [ + "decision_text" + ], + "free": [ + "decision_text" + ], + "pro": [ + "decision_text" + ] + }, + "shape_supported": true, + "shape_tier_required": "enterprise_a", + "viewset": "appeals.views.ContractAppealViewSet" + }, + "swagger_has_key": true, + "swagger_params": [ + "flat", + "flat_lists", + "joiner", + "limit", + "ordering", + "page", + "shape", + "show_shapes" + ] + }, "contracts": { "basename": "contract", "docs_file": null, @@ -2641,7 +2791,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -4016,6 +4166,241 @@ "swagger_has_key": false, "swagger_params": [] }, + "ebuy/requests": { + "basename": "ebuy-request", + "docs_file": null, + "docs_params": [], + "prefix": "ebuy/requests", + "resource_key": "ebuy/requests", + "runtime": { + "filter_params": [ + "buyer_agency", + "close_date_after", + "close_date_before", + "contract_number", + "issue_date_after", + "issue_date_before", + "reference_number", + "request_type", + "rfq_id", + "schedule", + "search", + "sin", + "status" + ], + "filter_params_detail": { + "buyer_agency": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "close_date_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "close_date_before": { + "filter_class": "DateFilter", + "lookup": "date__lte", + "type": "date" + }, + "contract_number": { + "filter_class": "CharFilter", + "type": "string" + }, + "issue_date_after": { + "filter_class": "DateFilter", + "lookup": "gte", + "type": "date" + }, + "issue_date_before": { + "filter_class": "DateFilter", + "lookup": "date__lte", + "type": "date" + }, + "reference_number": { + "filter_class": "SolicitationNumberFilter", + "type": "string" + }, + "request_type": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "rfq_id": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "schedule": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "search": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "sin": { + "filter_class": "BaseSmartFilter", + "type": "string" + }, + "status": { + "filter_class": "BaseSmartFilter", + "type": "string" + } + }, + "ordering_aliases": [], + "ordering_fields": [ + "close_date", + "issue_date", + "last_seen", + "modified" + ], + "pagination": { + "class": "StandardResultsSetPagination", + "max_page_size": 100 + }, + "shape": { + "expands": { + "attachments": { + "expands": {}, + "fields": [ + "doc_name", + "doc_path", + "doc_seq_num", + "doc_session_date", + "doc_type", + "is_link" + ] + }, + "organization": { + "expands": {}, + "fields": [ + "agency_code", + "agency_name", + "department_code", + "department_name", + "office_code", + "office_name", + "organization_id" + ] + } + }, + "fields": [ + "addresses", + "amendment_count", + "amendments", + "attachment_count", + "award_method", + "buyer_agency", + "buyer_agency_code", + "buyer_email", + "buyer_name", + "buyer_user_id", + "cancel_date", + "close_date", + "commercial_type", + "contract_type", + "description", + "detail_fetched", + "first_seen", + "follow_on", + "issue_date", + "last_mod_date", + "last_seen", + "line_items", + "link_count", + "mod_version", + "oco_aac", + "oco_agency", + "oco_name", + "oco_phone", + "oco_title", + "ocs_aac", + "ocs_agency", + "ocs_name", + "ocs_phone", + "ocs_title", + "pop_end_date", + "pop_start_date", + "qa_document_count", + "reference_number", + "request_type", + "rfq_id", + "schedule", + "sin", + "source_sought", + "status", + "title" + ] + }, + "shape_flat_paths": [ + "addresses", + "amendment_count", + "amendments", + "attachment_count", + "attachments", + "attachments.doc_name", + "attachments.doc_path", + "attachments.doc_seq_num", + "attachments.doc_session_date", + "attachments.doc_type", + "attachments.is_link", + "award_method", + "buyer_agency", + "buyer_agency_code", + "buyer_email", + "buyer_name", + "buyer_user_id", + "cancel_date", + "close_date", + "commercial_type", + "contract_type", + "description", + "detail_fetched", + "first_seen", + "follow_on", + "issue_date", + "last_mod_date", + "last_seen", + "line_items", + "link_count", + "mod_version", + "oco_aac", + "oco_agency", + "oco_name", + "oco_phone", + "oco_title", + "ocs_aac", + "ocs_agency", + "ocs_name", + "ocs_phone", + "ocs_title", + "organization", + "organization.agency_code", + "organization.agency_name", + "organization.department_code", + "organization.department_name", + "organization.office_code", + "organization.office_name", + "organization.organization_id", + "pop_end_date", + "pop_start_date", + "qa_document_count", + "reference_number", + "request_type", + "rfq_id", + "schedule", + "sin", + "source_sought", + "status", + "title" + ], + "shape_gated_by_level": {}, + "shape_supported": true, + "shape_tier_required": null, + "viewset": "ebuy.views.EbuyRequestViewSet" + }, + "swagger_has_key": false, + "swagger_params": [] + }, "entities": { "basename": "entity", "docs_file": null, @@ -4636,7 +5021,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -5368,7 +5753,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -6134,7 +6519,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -6477,7 +6862,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -8068,7 +8453,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -8708,7 +9093,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -9440,7 +9825,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -10692,6 +11077,8 @@ "expands": {}, "fields": [ "attachment_id", + "doc_role", + "doc_role_alt", "extracted_text", "file_size", "mime_type", @@ -10814,6 +11201,8 @@ "attachment_count", "attachments", "attachments.attachment_id", + "attachments.doc_role", + "attachments.doc_role_alt", "attachments.extracted_text", "attachments.file_size", "attachments.mime_type", @@ -10876,6 +11265,8 @@ ], "shape_gated_by_level": { "free": [ + "attachments.doc_role", + "attachments.doc_role_alt", "attachments.extracted_text" ] }, @@ -11123,6 +11514,8 @@ "expands": {}, "fields": [ "attachment_id", + "doc_role", + "doc_role_alt", "extracted_text", "file_size", "mime_type", @@ -11274,6 +11667,8 @@ "archive_date", "attachments", "attachments.attachment_id", + "attachments.doc_role", + "attachments.doc_role_alt", "attachments.extracted_text", "attachments.file_size", "attachments.mime_type", @@ -11357,6 +11752,8 @@ ], "shape_gated_by_level": { "free": [ + "attachments.doc_role", + "attachments.doc_role_alt", "attachments.extracted_text" ] }, @@ -11790,7 +12187,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -12141,7 +12538,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -12471,7 +12868,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -13885,6 +14282,7 @@ "expands": {}, "fields": [ "attachment_count", + "jurisdiction_declared", "last_change_source_declared", "last_revision_kind", "revision_count" @@ -13921,6 +14319,7 @@ "bid_opening_date", "bid_opening_raw", "category_codes", + "delisted_at", "description", "first_seen_at", "has_documents", @@ -13969,6 +14368,7 @@ "contact.email", "contact.name", "contact.phone", + "delisted_at", "description", "first_seen_at", "has_documents", @@ -13977,6 +14377,7 @@ "last_seen_at", "meta", "meta.attachment_count", + "meta.jurisdiction_declared", "meta.last_change_source_declared", "meta.last_revision_kind", "meta.revision_count", @@ -15075,7 +15476,6 @@ "expands": {}, "fields": [ "attachment_id", - "extracted_text", "file_size", "mime_type", "name", @@ -15681,7 +16081,6 @@ "opportunity.archive_date", "opportunity.attachments", "opportunity.attachments.attachment_id", - "opportunity.attachments.extracted_text", "opportunity.attachments.file_size", "opportunity.attachments.mime_type", "opportunity.attachments.name", @@ -15996,7 +16395,7 @@ "type": "string" }, "uei": { - "filter_class": "UppercaseCharFilter", + "filter_class": "UeiToEntityUuidFilter", "type": "string" } }, @@ -16577,12 +16976,14 @@ "assistance_listings", "budget/accounts", "business_types", + "contract_appeals", "contracts", "contracts_subawards", "departments", "dibbs/awards", "dibbs/rfps", "dibbs/rfqs", + "ebuy/requests", "entities", "entities_contracts", "entities_idvs", @@ -16628,6 +17029,7 @@ "dibbs/awards", "dibbs/rfps", "dibbs/rfqs", + "ebuy/requests", "entities_contracts", "entities_idvs", "entities_lcats", diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index ec141ed..d5cc037 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,10 +1,12 @@ { "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": 14, + "count": 16, "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)", "unmapped_resource|entities_idvs|(root)|(no model mapped)", "unmapped_resource|entities_lcats|(root)|(no model mapped)", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 96474d1..f734c68 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -387,18 +387,22 @@ const contract = await client.getGsaElibraryContract("00000000-0000-0000-0000-00 ### `listProtests(options?)` +Bid protests from GAO, the Court of Federal Claims (COFC) and SBA OHA. + ```ts const protests = await client.listProtests({ source_system: "gao", limit: 25 }); ``` `naics_code` is a typed filter sent to the API verbatim (it is **not** remapped to `naics`, unlike the contracts alias). -### `getProtest(caseNumber)` +### `getProtest(caseId)` ```ts const protest = await client.getProtest("CASE_UUID"); ``` +Takes the case's `case_id` UUID, not a case number. To look a case up by number, list with `case_number` and read `case_id` off the result. + --- ## IT Dashboard diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index d98647f..c1ddcc9 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -68,6 +68,9 @@ 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, + "ebuy/requests": null, psc: "listPsc", mas_sins: "listMasSins", departments: "listDepartments", diff --git a/scripts/generate-shape-overlay.ts b/scripts/generate-shape-overlay.ts index 6766a70..a2eba1e 100644 --- a/scripts/generate-shape-overlay.ts +++ b/scripts/generate-shape-overlay.ts @@ -213,6 +213,20 @@ function expandEntry(res: string, nodePath: string, ename: string, enode: ShapeN const overlay: Record> = {}; const reportRows: string[] = []; +// A model can be reached from more than one resource (vehicles embed an opportunity), so two walks can mint the same container expand. +// Merge the two nested schemas instead of letting the later walk replace the earlier one: a narrower embedded shape must not erase fields the owning resource serves. +// Where both define a field, the later walk still wins, as it did before merging; an expand either walk observed as a list stays a list. +function setExpand(container: string, ename: string, e: Entry): void { + const slot = (overlay[container] ??= {}); + const prev = slot[ename]; + if (prev?.nested && e.nested && prev.nested !== e.nested && nestedSchemas[prev.nested] && nestedSchemas[e.nested]) { + const merged = { ...nestedSchemas[prev.nested], ...nestedSchemas[e.nested] }; + slot[ename] = entry(e.type, prev.isList || e.isList, internNested(titleName(ename), merged)); + return; + } + slot[ename] = e; +} + function walk(res: string, nodePath: string, node: ShapeNode, schema: FieldSchemaMap | null, container: string): void { if (schema === null) return; const fields = node.fields ?? []; @@ -231,7 +245,7 @@ function walk(res: string, nodePath: string, node: ShapeNode, schema: FieldSchem const childSchema = nestedName ? baseSchema(nestedName) : null; if (fs_ === undefined || childSchema === null) { if (isWildcard(enode) && fs_ !== undefined) continue; - (overlay[container] ??= {})[ename] = expandEntry(res, nodePath, ename, enode); + setExpand(container, ename, expandEntry(res, nodePath, ename, enode)); reportRows.push(`${res}:${nodePath || "(root)"}.${ename} -> expand`); } else { walk(res, childPath, enode, childSchema, nestedName!); diff --git a/src/client.ts b/src/client.ts index c9f165f..1d8eabe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2631,15 +2631,19 @@ export class TangoClient { // Protests + IT Dashboard + Metrics // --------------------------------------------------------------------------- - /** List protests (GAO + CoFC). */ + /** List bid protests from GAO, the Court of Federal Claims (COFC) and SBA OHA. */ async listProtests(options: ListProtestsOptions = {}): Promise> { return this._genericPaginatedList("/api/protests/", options); } - /** Get a single protest by case number / id. */ - async getProtest(caseNumber: string): Promise { - if (!caseNumber) throw new TangoValidationError("Protest case number is required"); - return await this.http.get(`/api/protests/${encodeURIComponent(caseNumber)}/`); + /** + * Get a single protest case by its `case_id` UUID (`/api/protests/{case_id}/`). + * + * The route accepts only the UUID. A case number such as `B-423456` or `26-1391` is not an id — find the case with `listProtests({ case_number })` and use its `case_id`. + */ + async getProtest(caseId: string): Promise { + if (!caseId) throw new TangoValidationError("Protest case_id is required"); + return await this.http.get(`/api/protests/${encodeURIComponent(caseId)}/`); } /** List IT Dashboard investments. */ diff --git a/src/models/Notice.ts b/src/models/Notice.ts index 4f90b04..fdc2790 100644 --- a/src/models/Notice.ts +++ b/src/models/Notice.ts @@ -1,3 +1,5 @@ +import type { OpportunityAttachment } from "./Opportunity.js"; + export interface Notice { notice_id: string; title: string; @@ -5,4 +7,5 @@ export interface Notice { description?: string | null; posted_date?: string | null; naics_code?: string | null; + attachments?: OpportunityAttachment[] | null; } diff --git a/src/models/Opportunity.ts b/src/models/Opportunity.ts index acd5c18..691fc8d 100644 --- a/src/models/Opportunity.ts +++ b/src/models/Opportunity.ts @@ -1,3 +1,27 @@ +/** What job a document does inside its solicitation package, as the API labels it. */ +export type AttachmentDocRole = "requirement" | "instructions" | "pricing" | "terms" | "reference" | "unknown"; + +/** One document attached to a federal opportunity or notice (the `attachments(...)` expand). */ +export interface OpportunityAttachment { + attachment_id?: string | null; + resource_id?: string | null; + name?: string | null; + type?: string | null; + mime_type?: string | null; + file_size?: number | null; + posted_date?: string | null; + url?: string | null; + extracted_text?: string | null; + /** + * The document's role in the package. Requires a Pro plan or above and must be named explicitly — `attachments(*)` does not carry it. + * + * The key is absent on attachments that have not been labeled. + */ + doc_role?: AttachmentDocRole; + /** The runner-up role, or null when there is none. Same plan gate and naming rule as `doc_role`. */ + doc_role_alt?: AttachmentDocRole | null; +} + export interface Opportunity { opportunity_id: string; title: string; @@ -7,4 +31,5 @@ export interface Opportunity { active?: boolean | null; naics_code?: string | null; psc_code?: string | null; + attachments?: OpportunityAttachment[] | null; } diff --git a/src/models/Sled.ts b/src/models/Sled.ts index 991f8d0..c0598d9 100644 --- a/src/models/Sled.ts +++ b/src/models/Sled.ts @@ -34,6 +34,8 @@ export interface SledMetaPayload { last_revision_kind?: string | null; /** Whether the portal's own amendment marker moved at the emission behind `last_change_seen_at`. False means Tango inferred the change by diffing consecutive scrapes, which is the common case. */ last_change_source_declared?: boolean | null; + /** Whether a source stated the jurisdiction level — a portal field naming the issuer type, or a portal that belongs to one issuing government. False means Tango classified it from the issuer's name, which is the common case on a state's central portal. Null alongside a null level. */ + jurisdiction_declared?: boolean | null; } /** @@ -117,9 +119,11 @@ export interface SledOpportunity { agency?: string | null; /** Tango-derived liveness: `open`, `closed`, `awarded`, `cancelled`, `unknown`. */ status?: string | null; - /** Which input decided `status`: `deadline_future`, `deadline_past`, `no_deadline`, `source_terminal`. */ + /** Which input decided `status`: `deadline_future`, `deadline_past`, `no_deadline`, `source_terminal`, `delisted`. */ status_reason?: string | null; status_computed_at?: string | null; + /** When a complete crawl of the portal first omitted the solicitation, stamped only if that was before its deadline. Null when it was never delisted or has been seen again since. A set value derives `status_reason=delisted`. */ + delisted_at?: string | null; /** The portal's own status word, frozen at last capture. NOT liveness. */ source_status?: string | null; source_url?: string | null; diff --git a/src/models/index.ts b/src/models/index.ts index 2bc3cde..af66121 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -16,7 +16,7 @@ export type { Forecast } from "./Forecast.js"; export type { Grant } from "./Grant.js"; export type { Location } from "./Location.js"; export type { Notice } from "./Notice.js"; -export type { Opportunity } from "./Opportunity.js"; +export type { AttachmentDocRole, Opportunity, OpportunityAttachment } from "./Opportunity.js"; export type { RecipientProfile } from "./RecipientProfile.js"; export type { SbirTopic, SbirSolicitation } from "./Sbir.js"; export type { diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index 40adb8a..b1e7959 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -4997,6 +4997,13 @@ export const SLED_META_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + jurisdiction_declared: { + name: "jurisdiction_declared", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, }; // One advertised document — metadata and extraction stats, never the body. `size_bytes` and `char_count` only mean something as a pair. @@ -5258,6 +5265,13 @@ export const SLED_OPPORTUNITY_SCHEMA: FieldSchemaMap = { isList: false, nestedModel: null, }, + delisted_at: { + name: "delisted_at", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, source_status: { name: "source_status", type: "str", diff --git a/src/shapes/generatedOverlay.ts b/src/shapes/generatedOverlay.ts index 3e7bcc5..0f90a9c 100644 --- a/src/shapes/generatedOverlay.ts +++ b/src/shapes/generatedOverlay.ts @@ -58,7 +58,17 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), }, Attachments: { + doc_name: f("doc_name", "str"), + doc_path: f("doc_path", "str"), + doc_seq_num: f("doc_seq_num", "str"), + doc_session_date: f("doc_session_date", "date"), + doc_type: f("doc_type", "str"), + is_link: f("is_link", "bool"), + }, + Attachments2: { attachment_id: f("attachment_id", "str"), + doc_role: f("doc_role", "str"), + doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), file_size: f("file_size", "int"), mime_type: f("mime_type", "str"), @@ -68,8 +78,10 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), url: f("url", "str"), }, - Attachments2: { + Attachments3: { attachment_id: f("attachment_id", "str"), + doc_role: f("doc_role", "str"), + doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), file_size: f("file_size", "int"), mime_type: f("mime_type", "str"), @@ -79,8 +91,20 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), url: f("url", "str"), }, - Attachments3: { + Attachments4: { + attachment_id: f("attachment_id", "str"), + file_size: f("file_size", "str"), + mime_type: f("mime_type", "str"), + name: f("name", "str"), + posted_date: f("posted_date", "date"), + resource_id: f("resource_id", "str"), + type: f("type", "str"), + url: f("url", "str"), + }, + Attachments5: { attachment_id: f("attachment_id", "str"), + doc_role: f("doc_role", "str"), + doc_role_alt: f("doc_role_alt", "str"), extracted_text: f("extracted_text", "str"), file_size: f("file_size", "str"), mime_type: f("mime_type", "str"), @@ -878,7 +902,7 @@ export const GENERATED_OVERLAY: Record = { Notice: { address: f("address", "dict", false, "Address"), archive: f("archive", "dict", false, "Archive"), - attachments: f("attachments", "dict", true, "Attachments"), + attachments: f("attachments", "dict", true, "Attachments2"), meta: f("meta", "dict", false, "Meta"), office: f("office", "dict", false, "AwardingOffice"), opportunity: f("opportunity", "dict", false, "Opportunity2"), @@ -942,13 +966,13 @@ export const GENERATED_OVERLAY: Record = { agency: f("agency", "dict", false, "Agency3"), agency_id: f("agency_id", "str"), archive_date: f("archive_date", "date"), - attachments: f("attachments", "dict", false, "Attachments3"), + attachments: f("attachments", "dict", true, "Attachments5"), department: f("department", "dict", false, "Department3"), department_id: f("department_id", "str"), latest_notice: f("latest_notice", "dict", false, "LatestNotice"), latest_notice_id: f("latest_notice_id", "str"), meta: f("meta", "dict", false, "Meta2"), - notice_history: f("notice_history", "dict", false, "NoticeHistory2"), + notice_history: f("notice_history", "dict", true, "NoticeHistory2"), office_id: f("office_id", "str"), place_of_performance: f("place_of_performance", "dict", false, "PlaceOfPerformance4"), secondary_contact: f("secondary_contact", "dict", false, "PrimaryContact"), diff --git a/src/types.ts b/src/types.ts index c3b3473..9a71abd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,21 +154,31 @@ export interface AgencyRecord { } /** - * Typed return model for `client.getProtest()`. Mirrors the canonical - * GAO/COFC protest case schema. + * Typed return model for `client.getProtest()`. Mirrors the API's protest case (GAO, COFC and SBA OHA). */ export interface ProtestRecord { + /** The case UUID — the id `getProtest()` takes. */ case_id?: string; case_number?: string; + /** `gao`, `cofc` or `sba_oha`. */ source_system?: string; + title?: string | null; outcome?: string | null; case_type?: string | null; filed_date?: string | null; decision_date?: string | null; - agency?: Record | null; - protester?: Record | null; + /** The protested agency's name as the source publishes it. The resolved office is under `organization`. */ + agency?: string | null; + /** The protester's name as the source publishes it. */ + protester?: string | null; + docket_url?: string | null; + decision_url?: string | null; + organization?: Record | null; resolved_agency?: Record | null; resolved_protester?: Record | null; + dockets?: Array> | null; + decisions?: Array> | null; + /** @deprecated The API does not return `docket`; the per-docket rows are under `dockets`. */ docket?: Array>; [key: string]: unknown; } diff --git a/tests/unit/client.observability.test.ts b/tests/unit/client.observability.test.ts index a0d847b..e064367 100644 --- a/tests/unit/client.observability.test.ts +++ b/tests/unit/client.observability.test.ts @@ -205,22 +205,31 @@ describe("Typed return models (resolve / validate / getAgency / getProtest)", () expect(a.abbreviation).toBe("DOD"); }); - it("getProtest returns ProtestRecord-shaped object", async () => { - const fakeFetch = async () => - jsonResponse({ - case_id: "B-12345", - case_number: "B-12345", - source_system: "GAO", - outcome: "dismissed", + it("getProtest fetches the case by case_id and returns ProtestRecord-shaped object", async () => { + const caseId = "89026562-0b1c-5d2e-8f3a-4b5c6d7e8f90"; + const urls: string[] = []; + const fakeFetch = async (url: string | URL) => { + urls.push(String(url)); + return jsonResponse({ + case_id: caseId, + case_number: "26-292", + source_system: "cofc", + outcome: "Sustained", + agency: "N/A", + protester: "ACME FEDERAL, LLC", }); + }; const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost", fetchImpl: fakeFetch as unknown as typeof fetch, retries: 0, }); - const p: ProtestRecord = await client.getProtest("B-12345"); - expect(p.case_id).toBe("B-12345"); - expect(p.source_system).toBe("GAO"); + const p: ProtestRecord = await client.getProtest(caseId); + const protester: string | null | undefined = p.protester; + expect(new URL(urls[0]).pathname).toBe(`/api/protests/${caseId}/`); + expect(p.case_id).toBe(caseId); + expect(protester).toBe("ACME FEDERAL, LLC"); + expect(p.agency).toBe("N/A"); }); }); diff --git a/tests/unit/client.shape-parity.test.ts b/tests/unit/client.shape-parity.test.ts new file mode 100644 index 0000000..faeae14 --- /dev/null +++ b/tests/unit/client.shape-parity.test.ts @@ -0,0 +1,96 @@ +/** + * Fields the API serves that the client-side shape validator must accept. + * + * Each case shapes a real API field through the public client against a stubbed response, so a schema that lags the API fails here with `ShapeValidationError` instead of in a user's code. + */ + +import { TangoClient } from "../../src/client.js"; + +type RecordedCall = { url: string }; + +function makeClient(body: unknown): { client: TangoClient; calls: RecordedCall[] } { + const calls: RecordedCall[] = []; + const fetchImpl = (async (url: string | URL) => { + calls.push({ url: String(url) }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify(body); + }, + }; + }) as unknown as typeof fetch; + const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); + return { client, calls }; +} + +const ATTACHMENTS_SHAPE = "title,attachments(name,doc_role,doc_role_alt)"; + +const ATTACHMENTS = [ + { name: "Performance Work Statement.pdf", doc_role: "requirement", doc_role_alt: "terms" }, + { name: "Pricing Sheet.xlsx", doc_role: "pricing", doc_role_alt: null }, +]; + +function page(record: Record): Record { + return { count: 1, next: null, previous: null, results: [record] }; +} + +describe("attachment document roles on opportunities and notices", () => { + it("getOpportunity shapes attachments(name,doc_role,doc_role_alt)", async () => { + const { client, calls } = makeClient({ title: "Base ops", attachments: ATTACHMENTS }); + const opp = await client.getOpportunity("opp-1", { shape: ATTACHMENTS_SHAPE }); + + expect(new URL(calls[0].url).searchParams.get("shape")).toBe(ATTACHMENTS_SHAPE); + expect(opp.attachments).toEqual(ATTACHMENTS); + }); + + it("listOpportunities shapes attachments(name,doc_role,doc_role_alt)", async () => { + const { client } = makeClient(page({ title: "Base ops", attachments: ATTACHMENTS })); + const resp = await client.listOpportunities({ shape: ATTACHMENTS_SHAPE }); + + expect(resp.results[0].attachments).toEqual(ATTACHMENTS); + }); + + it("getNotice shapes attachments(name,doc_role,doc_role_alt)", async () => { + const { client } = makeClient({ title: "Base ops", attachments: ATTACHMENTS }); + const notice = await client.getNotice("notice-1", { shape: ATTACHMENTS_SHAPE }); + + expect(notice.attachments).toEqual(ATTACHMENTS); + }); + + it("listNotices shapes attachments(name,doc_role,doc_role_alt)", async () => { + const { client } = makeClient(page({ title: "Base ops", attachments: ATTACHMENTS })); + const resp = await client.listNotices({ shape: ATTACHMENTS_SHAPE }); + + expect(resp.results[0].attachments).toEqual(ATTACHMENTS); + }); +}); + +describe("SLED delisting and jurisdiction provenance", () => { + const SHAPE = "opportunity_id,status,status_reason,delisted_at,meta(attachment_count,jurisdiction_declared)"; + const RECORD = { + opportunity_id: "sled-1", + status: "closed", + status_reason: "delisted", + delisted_at: "2026-09-12T14:03:00Z", + meta: { attachment_count: 2, jurisdiction_declared: false }, + }; + + it("listSledOpportunities shapes delisted_at and meta(jurisdiction_declared)", async () => { + const { client } = makeClient(page(RECORD)); + const resp = await client.listSledOpportunities({ shape: SHAPE }); + const row = resp.results[0] as Record; + + expect(row.status_reason).toBe("delisted"); + expect(row.delisted_at).toEqual(new Date("2026-09-12T14:03:00Z")); + expect(row.meta).toEqual({ attachment_count: 2, jurisdiction_declared: false }); + }); + + it("getSledOpportunity shapes delisted_at and meta(jurisdiction_declared)", async () => { + const { client } = makeClient(RECORD); + const row = await client.getSledOpportunity("sled-1", { shape: SHAPE }); + + expect(row.delisted_at).toEqual(new Date("2026-09-12T14:03:00Z")); + expect((row.meta as Record).jurisdiction_declared).toBe(false); + }); +});