diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7463743 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +# Lint + typecheck + test gate runs on every PR and push to main. +# +# The SDK filter/shape conformance check needs the canonical manifest from the +# private makegov/tango repo, which requires a TANGO_API_REPO_ACCESS_TOKEN secret +# the public CI does not have. The conformance job SKIPS cleanly when the token +# is absent (rather than failing on an empty token) and becomes a hard gate the +# moment the secret is configured. The lint + test gate below is self-contained +# and blocks the PR on failure. +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node-version: ["18", "20", "22"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + # No lockfile is committed (package-lock.json is gitignored), and the + # "prepare" script runs a build that needs tsc — so ignore scripts here + # and build explicitly below. + run: npm install --ignore-scripts --no-audit --no-fund + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test + # `vitest run` forces a single non-watch pass in CI. + run: npx vitest run + + conformance: + # Requires the canonical filter_shape manifest from the private makegov/tango + # repo. When TANGO_API_REPO_ACCESS_TOKEN is not configured, every real step + # is skipped and the job passes (rather than failing on an empty token). + # Configure the secret to turn this into a hard gate automatically. + runs-on: ubuntu-latest + + steps: + - name: Determine token availability + id: gate + env: + TANGO_API_REPO_ACCESS_TOKEN: ${{ secrets.TANGO_API_REPO_ACCESS_TOKEN }} + run: | + if [ -n "$TANGO_API_REPO_ACCESS_TOKEN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping SDK conformance check — TANGO_API_REPO_ACCESS_TOKEN not configured." + fi + + - uses: actions/checkout@v4 + if: steps.gate.outputs.ready == 'true' + + - name: Checkout tango API repo (manifest source) + if: steps.gate.outputs.ready == 'true' + uses: actions/checkout@v4 + with: + repository: makegov/tango + path: tango-api + token: ${{ secrets.TANGO_API_REPO_ACCESS_TOKEN }} + + - name: Set up Node.js + if: steps.gate.outputs.ready == 'true' + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + if: steps.gate.outputs.ready == 'true' + run: npm install --ignore-scripts --no-audit --no-fund + + - name: Check SDK filter/shape conformance + if: steps.gate.outputs.ready == 'true' + run: npx tsx scripts/check-filter-shape-conformance.ts --manifest tango-api/contracts/filter_shape_contract.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ae47c..6aa88da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,57 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.1.0] - 2026-05-29 + +### Changed (breaking) +- Removed `getIdvSummary` and `listIdvSummaryAwards`. These called + `/api/idvs/{id}/summary/` and `/api/idvs/{id}/summary/awards/`, which have + never existed in the Tango API (no OpenAPI backing), so no consumer could + have been using them successfully. Use `getIdv` + `listIdvAwards` instead. + +### Fixed +- `Contract` interface: removed dead fields (`id`, `award_id`, + `recipient_name`, `award_amount`, `awarding_agency`, `funding_agency`) and + added the real API fields from `ContractListSerializer` (`key`, `piid`, + `obligated`, `total_contract_value`, `base_and_exercised_options_value`, + `awarding_office`, `funding_office`, `naics_code`, `psc_code`, `set_aside`, + `solicitation_identifier`, `parent_award`, `legislative_mandates`, + `subawards_summary`, `place_of_performance`). All fields optional (the + endpoint is shape-on-demand). The deprecated fields remain declared (marked + `@deprecated`) for one minor cycle and will be removed in `2.0.0`. New + exported types: `OrganizationOfficePayload`, `PlaceOfPerformance`, + `SubawardsSummary`, `LegislativeMandates`, `ParentAwardReference`, + `EntityBasic`. +- `listContracts`: no longer sends `page=1` to the cursor-only `/api/contracts/` + endpoint. When no cursor is supplied, neither `page` nor `cursor` is sent and + the API returns the first page by default. The stale code comment claiming + page-based pagination support has been corrected. +- Shape validation: registered the `ContractOrIDVCompetition` nested schema + (alias of `Competition`) so nested selections like + `competition(extent_competed,number_of_offers_received)` on contract / IDV + shapes validate instead of raising a shape-validation error. + +### Added +- Budget accounts surface (tango v4.6.8): `listBudgetAccounts`, + `getBudgetAccount`, `getBudgetAccountQuarters`, `getBudgetAccountRecipients`. + New exported `BudgetAccount` interface and `ListBudgetAccountsOptions`. +- Singleton detail GETs: `getContract`, `getContractSubawards`, + `getContractTransactions`, `getForecast`, `getGrant`, `getNotice`, + `getOpportunity`, `getSubaward`. +- `getEntityBudgetFlows(uei)` for `/api/entities/{uei}/budget-flows/`. +- `listVehicleOrders(uuid, options)` for `/api/vehicles/{uuid}/orders/` + (parity with Python). +- `grantId` filter on `listGrants` (camelCase alias mapped to the `grant_id` + API param; `grant_id` also accepted directly). + +### CI +- Added `ci.yml` PR + push-to-main gate (lint, typecheck, build, test on Node + 18/20/22). The filter/shape conformance check is a separate job that skips + cleanly until a `TANGO_API_REPO_ACCESS_TOKEN` secret for the private manifest + repo is configured, at which point it becomes a hard gate. +- Gave the webhooks real-HTTP-server round-trip tests an explicit 20s timeout + (the default 5s was tight on the slower Node 18 CI runtime). + ## [1.0.0] - 2026-05-13 First stable release. `tango-node` is now at **full feature parity** with both the Tango API and `tango-python` for the surface that remains after the subject-based webhook removal (see "Removed" below). Every read method and every endpoint/alert/signing helper available on `tango_python.TangoClient` has an idiomatic camelCase counterpart on `TangoClient`, the SDK's docs are auto-published to `docs.makegov.com/sdks/node/` via the composer pipeline (makegov/docs#15 / makegov/docs#16), and from `1.x` on we'll only ship breaking changes on a major bump. diff --git a/package.json b/package.json index b2f3020..65cc3a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makegov/tango-node", - "version": "1.0.0", + "version": "1.1.0", "description": "Official Node.js SDK for the Tango API – dynamic response shaping, typed models, and full endpoint coverage.", "type": "module", "main": "./dist/index.js", diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index 6b82842..7d50f47 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -64,6 +64,7 @@ export const RESOURCE_TO_METHOD: Record = { naics: "listNaics", gsa_elibrary_contracts: "listGsaElibraryContracts", itdashboard: "listItDashboard", + budget_accounts: "listBudgetAccounts", offices: "listOffices", }; diff --git a/src/client.ts b/src/client.ts index 1d7e7d4..6a2d5ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -143,7 +143,7 @@ export interface ListOptionsBase { } export interface ListContractsOptions extends ListOptionsBase { - /** Cursor-based pagination (mirror Python parity). Mutually exclusive with `page`. */ + /** Cursor-based pagination. /api/contracts/ is cursor-only; `page` is ignored. */ cursor?: string | null; /** Bag of arbitrary filters (legacy passthrough). Prefer named kwargs below. */ filters?: AnyRecord; @@ -349,6 +349,10 @@ export interface ListGrantsOptions extends ListOptionsBase { cfda_number?: string; funding_categories?: string; funding_instruments?: string; + /** SDK-friendly alias for `grant_id`. Supports multi-value OR via `|`. */ + grantId?: string; + /** Filter by grant ID (detail-endpoint identifier). Multi-value OR via `|`. */ + grant_id?: string; opportunity_number?: string; ordering?: string; posted_date_after?: string; @@ -360,6 +364,22 @@ export interface ListGrantsOptions extends ListOptionsBase { [key: string]: unknown; } +export interface ListBudgetAccountsOptions extends ListOptionsBase { + federal_account_symbol?: string; + fiscal_year?: number | string; + fiscal_year_gte?: number | string; + fiscal_year_lte?: number | string; + agency_code?: string; + bureau_name?: string; + account_title?: string; + bea_category?: string; + on_off_budget?: string; + subfunction_code?: string; + search?: string; + ordering?: string; + [key: string]: unknown; +} + /** * List methods on `TangoClient` that `iterate()` knows how to drive. Every * entry must accept an options object and return a `PaginatedResponse` @@ -712,17 +732,19 @@ export class TangoClient { // --------------------------------------------------------------------------- async listContracts(options: ListContractsOptions = {}): Promise>> { - const { page, cursor, limit = 25, shape, flat = false, flatLists = false, filters = {}, ...restFilters } = options; + // `page` is destructured out and intentionally discarded: /api/contracts/ is + // cursor-only (KeysetPagination) and does not honor page-based pagination. + const { page: _page, cursor, limit = 25, shape, flat = false, flatLists = false, filters = {}, ...restFilters } = options; + void _page; const params: AnyRecord = { limit: Math.min(limit, 100), }; - // /api/contracts/ supports both page- and cursor-based pagination. Prefer - // cursor when provided (Python parity); otherwise fall back to page. + // /api/contracts/ is cursor-only (KeysetPagination). Send the cursor when + // provided; otherwise send neither page nor cursor — the API returns the + // first page by default. (Previously sent page=1, which the endpoint ignores.) if (cursor) { params.cursor = cursor; - } else { - params.page = page ?? 1; } const shapeToUse = shape ?? ShapeConfig.CONTRACTS_MINIMAL; @@ -751,6 +773,46 @@ export class TangoClient { return paginated; } + /** Get a single contract by its key (`/api/contracts/{key}/`). */ + async getContract( + key: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!key) throw new TangoValidationError("Contract key is required"); + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.CONTRACTS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/contracts/${encodeURIComponent(key)}/`, params); + return this.materializeOne("Contract", shapeSpec, data, flat, joiner); + } + + /** List subawards under a contract (`/api/contracts/{key}/subawards/`). */ + async getContractSubawards(key: string, options: ListSubawardsOptions = {}): Promise> { + if (!key) throw new TangoValidationError("Contract key is required"); + return this._genericPaginatedList(`/api/contracts/${encodeURIComponent(key)}/subawards/`, options); + } + + /** List transactions under a contract (`/api/contracts/{key}/transactions/`). */ + async getContractTransactions( + key: string, + options: ListOptionsBase & { [key: string]: unknown } = {}, + ): Promise> { + if (!key) throw new TangoValidationError("Contract key is required"); + return this._genericPaginatedList(`/api/contracts/${encodeURIComponent(key)}/transactions/`, options); + } + // --------------------------------------------------------------------------- // Entities // --------------------------------------------------------------------------- @@ -849,6 +911,31 @@ export class TangoClient { return paginated; } + /** Get a single forecast by id (`/api/forecasts/{id}/`). */ + async getForecast( + id: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!id) throw new TangoValidationError("Forecast id is required"); + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.FORECASTS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/forecasts/${encodeURIComponent(id)}/`, params); + return this.materializeOne("Forecast", shapeSpec, data, flat, joiner); + } + // --------------------------------------------------------------------------- // Opportunities // --------------------------------------------------------------------------- @@ -881,6 +968,31 @@ export class TangoClient { return paginated; } + /** Get a single opportunity by id (`/api/opportunities/{opportunity_id}/`). */ + async getOpportunity( + opportunityId: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!opportunityId) throw new TangoValidationError("opportunity_id is required"); + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.OPPORTUNITIES_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/opportunities/${encodeURIComponent(opportunityId)}/`, params); + return this.materializeOne("Opportunity", shapeSpec, data, flat, joiner); + } + // --------------------------------------------------------------------------- // Notices // --------------------------------------------------------------------------- @@ -913,12 +1025,37 @@ export class TangoClient { return paginated; } + /** Get a single notice by id (`/api/notices/{notice_id}/`). */ + async getNotice( + noticeId: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!noticeId) throw new TangoValidationError("notice_id is required"); + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.NOTICES_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/notices/${encodeURIComponent(noticeId)}/`, params); + return this.materializeOne("Notice", shapeSpec, data, flat, joiner); + } + // --------------------------------------------------------------------------- // Grants // --------------------------------------------------------------------------- async listGrants(options: ListGrantsOptions = {}): Promise>> { - const { page = 1, limit = 25, shape, flat = false, flatLists = false, ...filters } = options; + const { page = 1, limit = 25, shape, flat = false, flatLists = false, grantId, ...filters } = options; const params: AnyRecord = { page, @@ -933,6 +1070,11 @@ export class TangoClient { if (flatLists) params.flat_lists = "true"; } + // SDK-friendly camelCase alias maps to the snake_case API param. + if (grantId !== undefined && filters.grant_id === undefined) { + filters.grant_id = grantId; + } + Object.assign(params, filters); const data = await this.http.get("/api/grants/", params); @@ -945,6 +1087,31 @@ export class TangoClient { return paginated; } + /** Get a single grant by its grant id (`/api/grants/{grant_id}/`). */ + async getGrant( + grantId: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise> { + if (!grantId) throw new TangoValidationError("grant_id is required"); + + const { shape, flat = false, flatLists = false, joiner = "." } = options; + const params: AnyRecord = {}; + + const shapeToUse = shape ?? ShapeConfig.GRANTS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + const data = await this.http.get(`/api/grants/${encodeURIComponent(grantId)}/`, params); + return this.materializeOne("Grant", shapeSpec, data, flat, joiner); + } + // --------------------------------------------------------------------------- // Vehicles (Awards) // --------------------------------------------------------------------------- @@ -1047,6 +1214,56 @@ export class TangoClient { return buildPaginatedResponse({ ...data, results }); } + /** + * List task orders under a Vehicle's IDVs (`/api/vehicles/{uuid}/orders/`). + * + * `ordering` allows server-side sort: `award_date` (default), `obligated`, + * `total_contract_value`. Prefix with `-` for descending. + */ + async listVehicleOrders( + uuid: string, + options: { + page?: number; + limit?: number; + shape?: string | null; + flat?: boolean; + flatLists?: boolean; + joiner?: string; + ordering?: string; + } = {}, + ): Promise>> { + if (!uuid) { + throw new TangoValidationError("Vehicle uuid is required"); + } + + const { page = 1, limit = 25, shape, flat = false, flatLists = false, joiner = ".", ordering } = options; + + const params: AnyRecord = { + page, + limit: Math.min(limit, 100), + }; + + const shapeToUse = shape ?? ShapeConfig.VEHICLE_ORDERS_MINIMAL; + const shapeSpec = this.parseShape(shapeToUse, flat, flatLists); + if (shapeToUse) { + params.shape = shapeToUse; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + } + + if (ordering) params.ordering = ordering; + + const data = await this.http.get(`/api/vehicles/${encodeURIComponent(uuid)}/orders/`, params); + const rawResults = Array.isArray(data?.results) ? (data.results as AnyRecord[]) : []; + + const results = this.materializeList("Contract", shapeSpec, rawResults, flat, joiner); + + return buildPaginatedResponse({ ...data, results }); + } + // --------------------------------------------------------------------------- // IDVs (Awards) // --------------------------------------------------------------------------- @@ -1193,30 +1410,6 @@ export class TangoClient { return buildPaginatedResponse>(data); } - async getIdvSummary(identifier: string): Promise> { - if (!identifier) { - throw new TangoValidationError("IDV solicitation identifier is required"); - } - return await this.http.get(`/api/idvs/${encodeURIComponent(identifier)}/summary/`); - } - - async listIdvSummaryAwards( - identifier: string, - options: { limit?: number; cursor?: string | null; ordering?: string } = {}, - ): Promise>> { - if (!identifier) { - throw new TangoValidationError("IDV solicitation identifier is required"); - } - - const { limit = 25, cursor = null, ordering } = options; - const params: AnyRecord = { limit: Math.min(limit, 100) }; - if (cursor) params.cursor = cursor; - if (ordering) params.ordering = ordering; - - const data = await this.http.get(`/api/idvs/${encodeURIComponent(identifier)}/summary/awards/`, params); - return buildPaginatedResponse>(data); - } - // --------------------------------------------------------------------------- // Webhooks (v2) // --------------------------------------------------------------------------- @@ -1616,6 +1809,12 @@ export class TangoClient { return this._genericPaginatedList("/api/subawards/", options); } + /** Get a single subaward by its key (`/api/subawards/{key}/`). */ + async getSubaward(key: string): Promise { + if (!key) throw new TangoValidationError("Subaward key is required"); + return await this.http.get(`/api/subawards/${encodeURIComponent(key)}/`); + } + /** List GSA eLibrary contracts. */ async listGsaElibraryContracts(options: ListGsaElibraryContractsOptions = {}): Promise> { return this._genericPaginatedList("/api/gsa_elibrary_contracts/", options); @@ -1641,6 +1840,71 @@ export class TangoClient { return this._genericPaginatedList(path, rest); } + // --------------------------------------------------------------------------- + // Budget (federal account x fiscal year rollups) + // --------------------------------------------------------------------------- + + /** List budget accounts (`/api/budget/accounts/`). One row per federal account x fiscal year. */ + async listBudgetAccounts(options: ListBudgetAccountsOptions = {}): Promise> { + return this._genericPaginatedList("/api/budget/accounts/", options); + } + + /** Get a single budget account by id (`/api/budget/accounts/{id}/`). */ + async getBudgetAccount( + id: string | number, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean } = {}, + ): Promise { + if (id === undefined || id === null || id === "") { + throw new TangoValidationError("Budget account id is required"); + } + const { shape, flat, flatLists } = options; + const params: AnyRecord = {}; + if (shape) params.shape = shape; + if (flat) params.flat = "true"; + if (flatLists) params.flat_lists = "true"; + return await this.http.get(`/api/budget/accounts/${encodeURIComponent(String(id))}/`, params); + } + + /** + * Get quarterly TAS-grain obligation / outlay flow for a budget account + * (`/api/budget/accounts/{id}/quarters/`). FY21+ only. + * + * `tas` narrows to a single Treasury Account Symbol. + */ + async getBudgetAccountQuarters( + id: string | number, + options: { tas?: string; limit?: number } = {}, + ): Promise> { + if (id === undefined || id === null || id === "") { + throw new TangoValidationError("Budget account id is required"); + } + const { tas, limit = 25 } = options; + const params: AnyRecord = { limit: Math.min(limit, 100) }; + if (tas) params.tas = tas; + const data = await this.http.get(`/api/budget/accounts/${encodeURIComponent(String(id))}/quarters/`, params); + return buildPaginatedResponse(data); + } + + /** + * Get funding-office x recipient contract-flow detail for a budget account + * (`/api/budget/accounts/{id}/recipients/`). + * + * `funding_organization_id` narrows to a single funding office (Organization UUID). + */ + async getBudgetAccountRecipients( + id: string | number, + options: { funding_organization_id?: string; limit?: number } = {}, + ): Promise> { + if (id === undefined || id === null || id === "") { + throw new TangoValidationError("Budget account id is required"); + } + const { funding_organization_id, limit = 25 } = options; + const params: AnyRecord = { limit: Math.min(limit, 100) }; + if (funding_organization_id) params.funding_organization_id = funding_organization_id; + const data = await this.http.get(`/api/budget/accounts/${encodeURIComponent(String(id))}/recipients/`, params); + return buildPaginatedResponse(data); + } + // --------------------------------------------------------------------------- // Protests + IT Dashboard + Metrics // --------------------------------------------------------------------------- @@ -1812,6 +2076,12 @@ export class TangoClient { return this._genericPaginatedList(`/api/entities/${encodeURIComponent(uei)}/lcats/`, options); } + /** Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`). */ + async getEntityBudgetFlows(uei: string): Promise { + if (!uei) throw new TangoValidationError("UEI is required"); + return await this.http.get(`/api/entities/${encodeURIComponent(uei)}/budget-flows/`); + } + /** Get rolling metrics for an entity (`/api/entities/{uei}/metrics/{months}/{periodGrouping}/`). */ async getEntityMetrics(uei: string, months: number | string, periodGrouping: string): Promise { if (!uei) throw new TangoValidationError("UEI is required"); diff --git a/src/index.ts b/src/index.ts index 35f7dcc..51e1db2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { TangoClient } from "./client.js"; export type { ListOptionsBase, + ListBudgetAccountsOptions, ListContractsOptions, ListEntitiesOptions, ListVehiclesOptions, diff --git a/src/models/BudgetAccount.ts b/src/models/BudgetAccount.ts new file mode 100644 index 0000000..69b6971 --- /dev/null +++ b/src/models/BudgetAccount.ts @@ -0,0 +1,95 @@ +/** + * Federal account x fiscal year budget rollup. + * + * One row per `(federal_account_symbol, fiscal_year)` covering the full budget + * lifecycle (requested -> enacted -> apportioned -> obligated -> outlayed), + * pre-computed ratios + trends, the contract/assistance/unlinked breakdown, and + * request-vs-actual contract spend. + * + * `/api/budget/accounts/` uses shape-on-demand: which fields appear depends on + * the `?shape=` query param, so EVERY field is optional regardless of the + * OpenAPI schema's nominal "required" assertions. Mirrors the API + * `BudgetAccount` schema. + */ +export interface BudgetAccount { + id?: number; + federal_account_symbol?: string; + fiscal_year?: number; + agency_code?: string | null; + agency_name?: string | null; + bureau_name?: string | null; + account_title?: string | null; + bea_category?: string | null; + on_off_budget?: string | null; + subfunction_code?: string | null; + + // Lifecycle + requested_ba?: number | null; + enacted_ba?: number | null; + apportioned?: number | null; + obligated_total?: number | null; + outlayed_total?: number | null; + unobligated_balance?: number | null; + + // Contract / assistance / unlinked breakdown + contract_obligated?: number | null; + contract_outlayed?: number | null; + n_contracts?: number | null; + n_unique_contract_recipients?: number | null; + assistance_obligated?: number | null; + assistance_outlayed?: number | null; + n_grants?: number | null; + n_unique_grant_recipients?: number | null; + unlinked_obligated?: number | null; + contract_share_of_obligated?: number | null; + contract_share_of_obligated_capped?: number | null; + contract_share_capped_flag?: boolean; + assistance_share_of_obligated?: number | null; + assistance_share_of_obligated_capped?: number | null; + assistance_share_capped_flag?: boolean; + + // Forward-look + next_year_requested_ba?: number | null; + ba_growth_next_year?: number | null; + ba_growth_next_year_pct?: number | null; + + // Ratios + enacted_to_requested_pct?: number | null; + enacted_to_requested_pct_capped?: number | null; + enacted_to_requested_pct_capped_flag?: boolean; + apportioned_to_enacted_pct?: number | null; + apportioned_to_enacted_pct_capped?: number | null; + apportioned_to_enacted_pct_capped_flag?: boolean; + obligated_to_apportioned_pct?: number | null; + obligated_to_apportioned_pct_capped?: number | null; + obligated_to_apportioned_pct_capped_flag?: boolean; + obligated_to_enacted_pct?: number | null; + obligated_to_enacted_pct_capped?: number | null; + obligated_to_enacted_pct_capped_flag?: boolean; + outlayed_to_obligated_pct?: number | null; + outlayed_to_obligated_pct_capped?: number | null; + outlayed_to_obligated_pct_capped_flag?: boolean; + unobligated_pct?: number | null; + + // Trends + enacted_ba_yoy_pct?: number | null; + obligated_yoy_pct?: number | null; + contract_obligated_yoy_pct?: number | null; + enacted_ba_5yr_cagr?: number | null; + contract_obligated_5yr_cagr?: number | null; + + // Request-vs-actual + requested_contractual_services?: number | null; + requested_personnel_share?: number | null; + actual_vs_requested_contract?: number | null; + actual_vs_requested_contract_capped?: number | null; + actual_vs_requested_contract_capped_flag?: boolean; + + // Provenance + narrative + appendix_pdf_url?: string | null; + account_narrative_excerpt?: string | null; + top_contract_recipients?: unknown[] | null; + top_grant_recipients?: unknown[] | null; + created?: string; + modified?: string; +} diff --git a/src/models/Contract.ts b/src/models/Contract.ts index 6ccc8a3..950c37e 100644 --- a/src/models/Contract.ts +++ b/src/models/Contract.ts @@ -1,17 +1,96 @@ -import { RecipientProfile } from "./RecipientProfile.js"; -import { Agency } from "./Agency.js"; -import { Location } from "./Location.js"; +import { EntityBasic } from "./Entity.js"; +/** + * Office payload returned for `awarding_office` / `funding_office` on a + * contract. Mirrors the API `OrganizationOfficePayload` schema. + */ +export interface OrganizationOfficePayload { + organization_id?: string | null; + office_code?: string | null; + office_name?: string | null; + agency_code?: string | null; + agency_name?: string | null; + department_code?: number | null; + department_name?: string | null; +} + +/** Place-of-performance payload (API `PlaceOfPerformance` schema). */ +export interface PlaceOfPerformance { + country_code?: string | null; + country_name?: string | null; + state_code?: string | null; + state_name?: string | null; + city_name?: string | null; + zip_code?: string | null; +} + +/** Subaward rollup attached to a contract (API `SubawardsSummary` schema). */ +export interface SubawardsSummary { + count?: number | null; + total_amount?: number | null; +} + +/** + * Legislative-mandate flags attached to a contract (API `LegislativeMandates` + * schema). Each value is an opaque object as returned by the API. + */ +export interface LegislativeMandates { + clinger_cohen_act_planning?: Record | null; + construction_wage_rate_requirements?: Record | null; + employment_eligibility_verification?: Record | null; + interagency_contracting_authority?: Record | null; + labor_standards?: Record | null; + materials_supplies_articles_equipment?: Record | null; + other_statutory_authority?: Record | null; + service_contract_inventory?: Record | null; +} + +/** Reference to a parent award (IDV) (API `ParentAwardReference` schema). */ +export interface ParentAwardReference { + key?: string | null; + piid?: string | null; +} + +/** + * Contract list/detail item. + * + * `/api/contracts/` uses shape-on-demand: which fields appear in a response + * depends on the `?shape=` query param, so EVERY field is optional regardless + * of the OpenAPI schema's nominal "required" assertions. + * + * Field set mirrors the API `ContractList` schema (`ContractListSerializer`). + */ export interface Contract { - id: string; - award_id: string; - recipient_name: string; - description: string; - award_amount?: string | null; + key?: string; + piid?: string | null; award_date?: string | null; - fiscal_year?: number | null; - recipient?: RecipientProfile | null; - awarding_agency?: Agency | null; - funding_agency?: Agency | null; - place_of_performance?: Location | null; + fiscal_year?: number; + obligated?: number | null; + base_and_exercised_options_value?: number | null; + total_contract_value?: number | null; + naics_code?: number | null; + psc_code?: string | null; + set_aside?: string; + solicitation_identifier?: string | null; + description?: string | null; + awarding_office?: OrganizationOfficePayload; + funding_office?: OrganizationOfficePayload; + recipient?: EntityBasic; + parent_award?: ParentAwardReference; + legislative_mandates?: LegislativeMandates; + place_of_performance?: PlaceOfPerformance; + subawards_summary?: SubawardsSummary; + + /** @deprecated Never returned by the API; removed in v2.0.0. */ + id?: string; + /** @deprecated Never returned by the API; removed in v2.0.0. */ + award_id?: string; + /** @deprecated Never returned by the API. Use `recipient.display_name`. Removed in v2.0.0. */ + recipient_name?: string; + /** @deprecated Never returned by the API. Use `obligated` / `total_contract_value`. Removed in v2.0.0. */ + award_amount?: string | null; + /** @deprecated Never returned by the API. Use `awarding_office`. Removed in v2.0.0. */ + awarding_agency?: unknown; + /** @deprecated Never returned by the API. Use `funding_office`. Removed in v2.0.0. */ + funding_agency?: unknown; } diff --git a/src/models/Entity.ts b/src/models/Entity.ts index c5148f4..e92fdd2 100644 --- a/src/models/Entity.ts +++ b/src/models/Entity.ts @@ -1,4 +1,14 @@ import { Location } from "./Location.js"; + +/** + * Minimal entity reference returned in nested contexts (e.g. a contract's + * `recipient`). Mirrors the API `EntityBasic` schema. + */ +export interface EntityBasic { + uei?: string; + display_name?: string; +} + export interface Entity { key: string; display_name: string; diff --git a/src/models/index.ts b/src/models/index.ts index 90613e0..c048ccf 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,7 +1,15 @@ export type { Agency } from "./Agency.js"; -export type { Contract } from "./Contract.js"; +export type { BudgetAccount } from "./BudgetAccount.js"; +export type { + Contract, + OrganizationOfficePayload, + PlaceOfPerformance, + SubawardsSummary, + LegislativeMandates, + ParentAwardReference, +} from "./Contract.js"; export type { Department } from "./Department.js"; -export type { Entity } from "./Entity.js"; +export type { Entity, EntityBasic } from "./Entity.js"; export type { Forecast } from "./Forecast.js"; export type { Grant } from "./Grant.js"; export type { Location } from "./Location.js"; diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index e35c9f8..f49a88e 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -3716,6 +3716,10 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Location: LOCATION_SCHEMA, PlaceOfPerformance: PLACE_OF_PERFORMANCE_SCHEMA, Competition: COMPETITION_SCHEMA, + // Alias: CONTRACT_SCHEMA/IDV_SCHEMA reference the competition leaf as + // "ContractOrIDVCompetition"; it is the same field set as Competition. + // Register both so nested shape selection resolves. + ContractOrIDVCompetition: COMPETITION_SCHEMA, ParentAward: PARENT_AWARD_SCHEMA, LegislativeMandates: LEGISLATIVE_MANDATES_SCHEMA, SubawardsSummary: SUBAWARDS_SUMMARY_SCHEMA, diff --git a/tests/unit/client.iterate.test.ts b/tests/unit/client.iterate.test.ts index d60ace3..cf915b5 100644 --- a/tests/unit/client.iterate.test.ts +++ b/tests/unit/client.iterate.test.ts @@ -33,44 +33,46 @@ function makeFetch( describe("TangoClient.iterate (offset pagination)", () => { it("walks pages via the `next` URL's `?page=` parameter", async () => { + // Uses `iterateEntities` because /api/entities/ is genuinely page-based. + // (/api/contracts/ is cursor-only and is covered by the cursor test below.) const base = "https://example.test"; const { fetchImpl, calls } = makeFetch([ { count: 5, - next: `${base}/api/contracts/?page=2`, - results: [{ piid: "A1" }, { piid: "A2" }], + next: `${base}/api/entities/?page=2`, + results: [{ uei: "A1" }, { uei: "A2" }], }, { count: 5, - next: `${base}/api/contracts/?page=3`, - results: [{ piid: "B1" }, { piid: "B2" }], + next: `${base}/api/entities/?page=3`, + results: [{ uei: "B1" }, { uei: "B2" }], }, { count: 5, next: null, - results: [{ piid: "C1" }], + results: [{ uei: "C1" }], }, ]); const client = new TangoClient({ apiKey: "k", baseUrl: base, fetchImpl, retries: 0 }); const seen: string[] = []; - for await (const c of client.iterateContracts({ awarding_agency: "9700" })) { - seen.push(String((c as Record).piid ?? "")); + for await (const c of client.iterateEntities({ state: "VA" })) { + seen.push(String((c as Record).uei ?? "")); } expect(seen).toEqual(["A1", "A2", "B1", "B2", "C1"]); expect(calls.length).toBe(3); - // First call carries listContracts' default page=1; subsequent calls - // advance to page=2 then page=3. Iteration stops when `next` is null. + // First call carries listEntities' default page=1; the iterator then + // follows `?page=` from each response's `next` URL. Stops when `next` is null. const u1 = new URL(calls[0]); expect(u1.searchParams.get("page")).toBe("1"); - expect(u1.searchParams.get("awarding_agency")).toBe("9700"); + expect(u1.searchParams.get("state")).toBe("VA"); const u2 = new URL(calls[1]); expect(u2.searchParams.get("page")).toBe("2"); - expect(u2.searchParams.get("awarding_agency")).toBe("9700"); + expect(u2.searchParams.get("state")).toBe("VA"); const u3 = new URL(calls[2]); expect(u3.searchParams.get("page")).toBe("3"); diff --git a/tests/unit/client.observability.test.ts b/tests/unit/client.observability.test.ts index 52df43e..a0d847b 100644 --- a/tests/unit/client.observability.test.ts +++ b/tests/unit/client.observability.test.ts @@ -91,7 +91,7 @@ describe("listContracts cursor pagination", () => { expect(capturedUrl).not.toContain("page="); }); - it("falls back to page when cursor is absent", async () => { + it("sends neither page nor cursor when cursor is absent (cursor-only endpoint)", async () => { let capturedUrl = ""; const fakeFetch = async (url: string) => { capturedUrl = url; @@ -108,8 +108,9 @@ describe("listContracts cursor pagination", () => { fetchImpl: fakeFetch as unknown as typeof fetch, retries: 0, }); + // `page` is ignored: /api/contracts/ is cursor-only. await client.listContracts({ page: 3 }); - expect(capturedUrl).toContain("page=3"); + expect(capturedUrl).not.toContain("page="); expect(capturedUrl).not.toContain("cursor="); }); diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index ce22d6a..64c1df9 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -72,7 +72,8 @@ describe("TangoClient", () => { expect(parsed.searchParams.get("shape")).toBe(ShapeConfig.CONTRACTS_MINIMAL); expect(parsed.searchParams.get("flat")).toBe("true"); expect(parsed.searchParams.get("limit")).toBe("5"); - expect(parsed.searchParams.get("page")).toBe("2"); + // /api/contracts/ is cursor-only: `page` is never forwarded. + expect(parsed.searchParams.get("page")).toBeNull(); }); it("uses default shapes for entities and supports search", async () => { @@ -537,8 +538,6 @@ describe("TangoClient", () => { await client.listIdvChildIdvs({ key: "IDV-KEY", limit: 5 }); await client.listIdvTransactions("IDV-KEY", { limit: 50 }); - await client.getIdvSummary("SOL"); - await client.listIdvSummaryAwards("SOL", { limit: 25, ordering: "-award_date" }); const parsedCalls = calls.map((u) => new URL(u)); diff --git a/tests/webhooks/simulate.test.ts b/tests/webhooks/simulate.test.ts index e280d85..fac028c 100644 --- a/tests/webhooks/simulate.test.ts +++ b/tests/webhooks/simulate.test.ts @@ -247,7 +247,9 @@ describe("deliver - round-trip through real http server", () => { } finally { await close(server); } - }); + // Real HTTP server round-trip + crypto signing: the default 5s vitest + // timeout is tight on slower/cold runtimes (observed flaking on Node 18 CI). + }, 20000); it("propagates the server's status code and body", async () => { const { server, url } = await listen((req, res) => { @@ -265,5 +267,7 @@ describe("deliver - round-trip through real http server", () => { } finally { await close(server); } - }); + // Real HTTP server round-trip: default 5s timeout is tight on slower + // runtimes (observed flaking on Node 18 CI). + }, 20000); });