From bec6bd896c5c05ed091ff905c36d88e348e272cb Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 28 Sep 2026 14:29:03 -0500 Subject: [PATCH] feat(ebuy): add GSA eBuy requests, attachment URLs and access check `listEbuyRequests`, `getEbuyRequest`, `getEbuyAttachmentUrl` and `getEbuyAccess` cover `/api/ebuy/requests/` and `/api/ebuy/access/` (Tango API 5.1.0). Every filter the API accepts is a typed option on `ListEbuyRequestsOptions`, and `EbuyRequestRecord`, `EbuyAttachmentRecord` and `EbuyAccess` type the responses. Results are scoped to the caller's linked schedule contracts, so an account with none gets an empty list; `getEbuyAccess()` tells that apart from no matches. `getEbuyAttachmentUrl()` returns the presigned URL the download endpoint redirects to without following it, via a new `HttpClient.getRedirectLocation()`, and throws `TangoEbuyAttachmentLinkError` with the link's `url` for an external-link entry. `EbuyRequest` and `EbuyAttachment` shape schemas are registered, `ebuy/requests` is mapped in the conformance gate and dropped from both coverage baselines, and the overlay is regenerated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- README.md | 4 +- contracts/conformance_baseline.json | 1 - contracts/shape_coverage_baseline.json | 3 +- docs/API_REFERENCE.md | 78 +++++ scripts/check-filter-shape-conformance.ts | 3 +- scripts/check-shape-coverage.ts | 1 + src/client.ts | 109 ++++++- src/errors.ts | 14 + src/index.ts | 1 + src/shapes/explicitSchemas.ts | 381 ++++++++++++++++++++++ src/shapes/generatedOverlay.ts | 18 +- src/types.ts | 93 ++++++ src/utils/http.ts | 19 +- tests/unit/client.ebuy.test.ts | 265 +++++++++++++++ 15 files changed, 978 insertions(+), 22 deletions(-) create mode 100644 tests/unit/client.ebuy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9cb43..d38a556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ This project follows [Semantic Versioning](https://semver.org/). ### Added +- **GSA eBuy requests** (Tango API 5.1.0). Four methods: `listEbuyRequests(options)` over `/api/ebuy/requests/`, `getEbuyRequest(rfqId, options)`, `getEbuyAttachmentUrl(rfqId, docSeqNum)` and `getEbuyAccess()`. Every filter the API accepts is a typed option on `ListEbuyRequestsOptions` (`search`, `rfq_id`, `reference_number`, `request_type`, `status`, `sin`, `schedule`, `buyer_agency`, `agency`, `contract_number`, the `issue_date_*` and `close_date_*` bounds, `ordering`; `agency` needs Tango API 5.3.0), with new `EbuyRequestRecord`, `EbuyAttachmentRecord` and `EbuyAccess` types and registered `EbuyRequest` / `EbuyAttachment` shape schemas. + + Results are scoped to the GSA schedule contracts linked to the caller's account, and an account with none gets an empty list rather than an error; `getEbuyAccess()` reports `enabled`, a `reason` (`tier_required` or `no_contract_grant`) and the caller's own `contracts`. `status` is frozen at the last-seen state, so `Open` means "open the last time it was seen" — `last_seen` is the staleness signal. + + `getEbuyAttachmentUrl()` returns the short-lived presigned URL the download endpoint redirects to, without following it. An attachment that is an external link throws the new `TangoEbuyAttachmentLinkError`, a `TangoValidationError` whose `url` is the link target. The HTTP client gained `getRedirectLocation()` to support it. + - **Boards-of-contract-appeals decisions** (Tango API 4.26.0). `listContractAppeals(options)` and `getContractAppeal(uuid, options)` over `/api/contract_appeals/`, with every filter the API accepts declared as a typed option on `ListContractAppealsOptions` (`search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, the `decision_date_after` / `_before` pair, `listed`, `document_id`, `ordering`), the new `ContractAppealRecord` return type, and a registered `ContractAppeal` shape schema so the typed shape API resolves the resource's fields. These are Contract Disputes Act decisions from the CBCA (civilian) and the ASBCA (defense) — a dispute under an existing contract, not a challenge to an award. Bid protests remain the separate `listProtests()` resource, and the two do not overlap. @@ -29,7 +35,8 @@ 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. eBuy requests are in the contract without an SDK method yet, so it is baselined as a tracked gap. +- 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. +- Wrapping eBuy maps `ebuy/requests` in the conformance gate and removes it from both coverage baselines. - 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 @@ -41,6 +48,7 @@ This project follows [Semantic Versioning](https://semver.org/). ### Documentation +- New **GSA eBuy** section in `docs/API_REFERENCE.md` covering all four methods, the full filter table, and the data caveats (the frozen `status`, the empty-list-not-error scoping, the short-lived attachment URL). `README.md`'s method and error lists gained the new methods and error. - New **Contract Appeals** section in `docs/API_REFERENCE.md` covering both methods, the full filter table, and the two properties that catch people out (the core-subset default and the tier-gated, absent-rather-than-null `decision_text`). `README.md`'s method list gained both methods. - New **State & Local (SLED) — Beta** section in `docs/API_REFERENCE.md` covering all six methods, both defaults that surprise people, and the new `ShapeConfig` constants. - `docs/WEBHOOKS.md` troubleshooting gained the date-lapse rule and its one exception. An exclusion or a DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — but `alerts.sled_opportunity.match` **does** fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes. diff --git a/README.md b/README.md index e526bcf..2d466f5 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,10 @@ The Node.js client mirrors the Python SDK's high-level API. Selected highlights: - `getForecast(id, options)` / `getOpportunity(opportunityId, options)` / `getNotice(noticeId, options)` / `getGrant(grantId, options)` - `searchOpportunityAttachments(options)` -**GSA eLibrary / Protests / Contract Appeals / IT Dashboard / LCATs** +**GSA eLibrary / GSA eBuy / Protests / Contract Appeals / IT Dashboard / LCATs** - `listGsaElibraryContracts(options)` / `getGsaElibraryContract(uuid, options)` +- `listEbuyRequests(options)` / `getEbuyRequest(rfqId, options)` / `getEbuyAttachmentUrl(rfqId, docSeqNum)` / `getEbuyAccess()` - `listProtests(options)` / `getProtest(caseId)` - `listContractAppeals(options)` / `getContractAppeal(uuid, options)` - `listItDashboard(options)` / `getItDashboard(uii)` @@ -291,6 +292,7 @@ Errors are surfaced as typed exceptions, aligned with the Python SDK: - `TangoAuthError` – Authentication problems (e.g., invalid API key, 401). - `TangoNotFoundError` – Resource not found (404). - `TangoValidationError` – Invalid request parameters (400). Exposes the API's structured 400 payload via `issues` and `availableFields` (see the [API Reference](docs/API_REFERENCE.md#error-types)). +- `TangoEbuyAttachmentLinkError` – A `TangoValidationError` subclass thrown by `getEbuyAttachmentUrl()` when the attachment is an external link; its `url` is the link target. - `TangoRateLimitError` – Rate limit exceeded (429). - `TangoTimeoutError` – Request exceeded the configured `timeoutMs`. diff --git a/contracts/conformance_baseline.json b/contracts/conformance_baseline.json index a63dbeb..c036c0f 100644 --- a/contracts/conformance_baseline.json +++ b/contracts/conformance_baseline.json @@ -2,7 +2,6 @@ "_comment": "Accepted SDK coverage gaps vs the API contract. Gaps listed here downgrade from error to warning in scripts/check-filter-shape-conformance.ts. Each entry is tracked backlog: remove it in the same PR that closes the gap in the SDK. `missing_filters` maps a resource to filter params the mapped method does not expose; `unimplemented_resources` lists contract resources with no SDK method at all. events and news are content endpoints with no list method and stay baselined permanently (tango-python does the same).", "missing_filters": {}, "unimplemented_resources": [ - "ebuy/requests", "events", "news" ] diff --git a/contracts/shape_coverage_baseline.json b/contracts/shape_coverage_baseline.json index 0d20bc4..ec141ed 100644 --- a/contracts/shape_coverage_baseline.json +++ b/contracts/shape_coverage_baseline.json @@ -1,11 +1,10 @@ { "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": 15, + "count": 14, "known_gaps": [ "unmapped_resource|agencies_contracts_awarding|(root)|(no model mapped)", "unmapped_resource|agencies_contracts_funding|(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 ddbb4e7..5a89cff 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -383,6 +383,83 @@ const contract = await client.getGsaElibraryContract("00000000-0000-0000-0000-00 --- +## GSA eBuy + +Requests for quotes, proposals and information (RFQs, RFPs, RFIs) posted to GSA eBuy under GSA schedule contracts. Requires the Pro tier or above; below it the request endpoints return 403. + +eBuy results are scoped to the GSA schedule contracts linked to your account. A caller with no linked contract gets an **empty list, not an error** — call `getEbuyAccess()` to tell "no access" from "no matches". + +### `listEbuyRequests(options?)` + +```ts +const requests = await client.listEbuyRequests({ + sin: "54151S", + status: "Open", + close_date_after: "2026-10-01", + limit: 25, +}); +``` + +#### Parameters (GSA eBuy) + +| Name | Type | Description | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `search` | `string` | Full-text search over title, description, reference number, request id and attachment text. Ranks by relevance unless `ordering` is given. | +| `rfq_id` | `string` | eBuy request id, exact (e.g. `RFQ1835158`). | +| `reference_number` | `string` | The buyer's own solicitation number; dashed and undashed spellings both match. | +| `request_type` | `string` | `RFQ`, `RFP` or `RFI`. | +| `status` | `string` | `Open` or `Cancelled` — frozen at the last-seen state (see below). | +| `sin` | `string` | Special Item Number the request was posted under. | +| `schedule` | `string` | GSA schedule the request was posted under. | +| `buyer_agency` | `string` | Buying department as fed, free text. | +| `agency` | `string` | Agency name, abbreviation or code, including every sub-agency and office beneath it (Tango API 5.3.0+). | +| `contract_number` | `string` | Narrow to one of your own linked contracts. A contract you do not hold returns nothing rather than an error. | +| `issue_date_after` / `_before` | `string` | `YYYY-MM-DD`, inclusive. | +| `close_date_after` / `_before` | `string` | `YYYY-MM-DD`, inclusive. | +| `ordering` | `string` | `issue_date` (default `-issue_date`), `close_date`, `last_seen`, or `modified`; prefix `-` for descending. | + +Every filter except `contract_number` and the date bounds accepts `|` for OR. The standard `page` / `limit` / `shape` / `flat` / `flatLists` / `joiner` options apply. The default list shape is `rfq_id,request_type,title,schedule,sin,status,buyer_name,buyer_agency,buyer_agency_code,reference_number,issue_date,close_date,attachment_count,link_count,last_seen`. + +Three properties of the data worth knowing: + +- **`status` is frozen at the last-seen state.** eBuy only carries currently-active requests, so a request that closes stops appearing rather than getting a final row. `Open` means "open the last time it was seen" — use `last_seen` for staleness. +- **The contract number a request was posted under is never returned** in any payload. +- **`buyer_agency_code`** and several buyer and contracting-officer fields are sparse on older requests. + +### `getEbuyRequest(rfqId, options?)` + +```ts +const request = await client.getEbuyRequest("RFQ1835158"); +for (const attachment of request.attachments ?? []) { + console.log(attachment.doc_seq_num, attachment.doc_name, attachment.is_link); +} +``` + +Returns an `EbuyRequestRecord`. The default shape is every field plus `organization(*)` (the buying office, in the same shape as other resources' awarding office) and `attachments(*)`. A request outside your contract scope throws `TangoNotFoundError`, the same as an id that does not exist. + +### `getEbuyAttachmentUrl(rfqId, docSeqNum)` + +```ts +const url = await client.getEbuyAttachmentUrl("RFQ1835158", 3852759); +const res = await fetch(url); +``` + +Returns a presigned download URL for one attachment without downloading it. The URL expires after about five minutes, so fetch it promptly rather than storing it. + +- An attachment with `is_link: true` is an external link, not a stored document: the call throws `TangoEbuyAttachmentLinkError`, whose `url` is the link target. +- A document that has not been captured yet throws `TangoNotFoundError`. + +### `getEbuyAccess()` + +```ts +const access = await client.getEbuyAccess(); +// { enabled: false, reason: "no_contract_grant", contracts: [] } +``` + +Returns `{ enabled, reason, contracts }`. `reason` is `"tier_required"` below the Pro tier, `"no_contract_grant"` when no contract is linked to your account, and `null` when `enabled` is true; `tier_required` wins when both apply. `contracts` lists your own active grants, sorted. + +--- + ## Protests ### `listProtests(options?)` @@ -1059,6 +1136,7 @@ All thrown by async methods: - `TangoRateLimitError` - `TangoTimeoutError` - `TangoValidationError` +- `TangoEbuyAttachmentLinkError` (a `TangoValidationError` with a `url`) - `ShapeError` - `ShapeParseError` - `ShapeValidationError` diff --git a/scripts/check-filter-shape-conformance.ts b/scripts/check-filter-shape-conformance.ts index b5bf2a5..97e31fc 100644 --- a/scripts/check-filter-shape-conformance.ts +++ b/scripts/check-filter-shape-conformance.ts @@ -69,8 +69,7 @@ export const RESOURCE_TO_METHOD: Record = { offices: "listOffices", protests: "listProtests", contract_appeals: "listContractAppeals", - // eBuy requests are published by the API but not yet ported — baselined as a tracked gap. - "ebuy/requests": null, + "ebuy/requests": "listEbuyRequests", psc: "listPsc", mas_sins: "listMasSins", departments: "listDepartments", diff --git a/scripts/check-shape-coverage.ts b/scripts/check-shape-coverage.ts index aea38e4..1455e17 100644 --- a/scripts/check-shape-coverage.ts +++ b/scripts/check-shape-coverage.ts @@ -67,6 +67,7 @@ export const RESOURCE_TO_MODEL: Record = { budget_accounts: "BudgetAccount", protests: "Protest", contract_appeals: "ContractAppeal", + "ebuy/requests": "EbuyRequest", offices: "Office", assistance_listings: "AssistanceListing", business_types: "BusinessType", diff --git a/src/client.ts b/src/client.ts index 59fad05..0c23e09 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ import { DEFAULT_BASE_URL, ShapeConfig } from "./config.js"; -import { TangoNotFoundError, TangoValidationError } from "./errors.js"; +import { TangoEbuyAttachmentLinkError, TangoNotFoundError, TangoValidationError } from "./errors.js"; import { ModelFactory } from "./shapes/factory.js"; import { ShapeParser } from "./shapes/parser.js"; import type { ShapeSpec } from "./shapes/types.js"; @@ -9,6 +9,8 @@ import { unflattenResponse } from "./utils/unflatten.js"; import { AgencyRecord, ContractAppealRecord, + EbuyAccess, + EbuyRequestRecord, PaginatedResponse, ProtestRecord, RateLimitInfo, @@ -988,6 +990,46 @@ export interface ListContractAppealsOptions extends ListOptionsBase { [key: string]: unknown; } +/** + * GSA eBuy request list options. + * + * Results are scoped to the GSA schedule contracts linked to the caller's account; with no linked contract the list is empty rather than an error, so use `getEbuyAccess()` to tell "no access" from "no matches". + * Every filter except `contract_number` and the date bounds accepts `|` for OR. + */ +export interface ListEbuyRequestsOptions extends ListOptionsBase { + /** Separator for flattened keys. Only meaningful alongside `flat`. */ + joiner?: string; + /** Full-text search over title, description, reference number, request id and attachment text. Results rank by relevance unless `ordering` is given. */ + search?: string; + /** eBuy request id, exact (e.g. `RFQ1835158`). */ + rfq_id?: string; + /** The buyer's own solicitation number; matches dashed and undashed spellings. */ + reference_number?: string; + /** `RFQ`, `RFP` or `RFI`. */ + request_type?: string; + /** `Open` or `Cancelled`. Frozen at the last-seen state, so `Open` is not proof a request is still open — use `last_seen` for staleness. */ + status?: string; + sin?: string; + schedule?: string; + /** Buying department as fed, free text. */ + buyer_agency?: string; + /** Agency name, abbreviation or code, including every sub-agency and office beneath it. */ + agency?: string; + /** Narrow to one of your own linked contracts; a contract you do not hold returns nothing rather than an error. */ + contract_number?: string; + /** YYYY-MM-DD, inclusive. */ + issue_date_after?: string; + /** YYYY-MM-DD, inclusive. */ + issue_date_before?: string; + /** YYYY-MM-DD, inclusive. */ + close_date_after?: string; + /** YYYY-MM-DD, inclusive. */ + close_date_before?: string; + /** Sort field (issue_date, close_date, last_seen, modified); prefix `-` for descending. Defaults to `-issue_date`. */ + ordering?: string; + [key: string]: unknown; +} + export interface ListItDashboardOptions { page?: number; limit?: number; @@ -2711,6 +2753,71 @@ export class TangoClient { return await this.http.get(`/api/contract_appeals/${encodeURIComponent(uuid)}/`, params); } + /** + * List GSA eBuy requests (`/api/ebuy/requests/`). Requires the Pro tier or above. + * + * Scoped to the GSA schedule contracts linked to the caller's account: with no linked contract this returns an empty page, not an error. `getEbuyAccess()` tells the two apart. + * + * `status` is frozen at the last-seen state — a request that closes stops appearing rather than getting a final row, so `Open` means "open the last time it was seen". Use `last_seen` for staleness. + */ + async listEbuyRequests(options: ListEbuyRequestsOptions = {}): Promise> { + return this._genericPaginatedList("/api/ebuy/requests/", options); + } + + /** + * Get one GSA eBuy request by its request id (`/api/ebuy/requests/{rfq_id}/`). + * + * The default shape is every field plus `organization(*)` and `attachments(*)`. A request outside the caller's contract scope raises `TangoNotFoundError`, the same as an id that does not exist. + */ + async getEbuyRequest( + rfqId: string, + options: { shape?: string | null; flat?: boolean; flatLists?: boolean; joiner?: string } = {}, + ): Promise { + if (!rfqId) throw new TangoValidationError("eBuy rfq_id is required"); + const { shape, flat, flatLists, joiner } = options; + const params: AnyRecord = {}; + if (shape) params.shape = shape; + if (flat) { + params.flat = "true"; + if (joiner) params.joiner = joiner; + } + if (flatLists) params.flat_lists = "true"; + return await this.http.get(`/api/ebuy/requests/${encodeURIComponent(rfqId)}/`, params); + } + + /** + * Get a short-lived download URL for one eBuy attachment (`/api/ebuy/requests/{rfq_id}/attachments/{doc_seq_num}/download/`). + * + * Returns the redirect target without downloading the document. The URL is presigned and expires after about five minutes, so fetch it promptly. + * + * Throws `TangoEbuyAttachmentLinkError` (carrying the link's `url`) when the entry is an external link rather than a stored document, and `TangoNotFoundError` when the document has not been captured yet or the request is out of scope. + */ + async getEbuyAttachmentUrl(rfqId: string, docSeqNum: number | string): Promise { + if (!rfqId) throw new TangoValidationError("eBuy rfq_id is required"); + if (docSeqNum === undefined || docSeqNum === null || docSeqNum === "") { + throw new TangoValidationError("eBuy attachment doc_seq_num is required"); + } + const path = `/api/ebuy/requests/${encodeURIComponent(rfqId)}/attachments/${encodeURIComponent(String(docSeqNum))}/download/`; + try { + return await this.http.getRedirectLocation(path); + } catch (err) { + if (err instanceof TangoValidationError && isRecord(err.responseData) && typeof err.responseData.url === "string") { + const detail = typeof err.responseData.detail === "string" ? err.responseData.detail : undefined; + throw new TangoEbuyAttachmentLinkError(err.responseData.url, detail, err.statusCode, err.responseData); + } + throw err; + } + } + + /** + * Whether the caller can read eBuy requests (`/api/ebuy/access/`). + * + * `enabled` is false with `reason: "tier_required"` below the Pro tier, or `reason: "no_contract_grant"` when no contract is linked to the account. `contracts` lists the caller's own active grants. + */ + async getEbuyAccess(): Promise { + return await this.http.get("/api/ebuy/access/"); + } + /** List IT Dashboard investments. */ async listItDashboard(options: ListItDashboardOptions = {}): Promise> { return this._genericPaginatedList("/api/itdashboard/", options); diff --git a/src/errors.ts b/src/errors.ts index 119abb1..7572981 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -60,6 +60,20 @@ export class TangoValidationError extends TangoAPIError { } } +/** + * Raised by `getEbuyAttachmentUrl()` when the attachment is an external link rather than a stored document. + * `url` is the link's target, so a caller can follow it directly. + */ +export class TangoEbuyAttachmentLinkError extends TangoValidationError { + readonly url: string; + + constructor(url: string, message = "This entry is an external link, not a stored document.", statusCode?: number, responseData?: unknown) { + super(message, statusCode, responseData); + this.name = "TangoEbuyAttachmentLinkError"; + this.url = url; + } +} + export class TangoRateLimitError extends TangoAPIError { constructor(message = "Rate limit exceeded", statusCode?: number, responseData?: unknown) { super(message, statusCode, responseData); diff --git a/src/index.ts b/src/index.ts index 3aa20a0..fb09463 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export type { ListSledForecastsOptions, ListProtestsOptions, ListContractAppealsOptions, + ListEbuyRequestsOptions, ListItDashboardOptions, ListMetricsOptions, ResolveInput, diff --git a/src/shapes/explicitSchemas.ts b/src/shapes/explicitSchemas.ts index bb7b71d..a3e57d5 100644 --- a/src/shapes/explicitSchemas.ts +++ b/src/shapes/explicitSchemas.ts @@ -5737,6 +5737,385 @@ export const SLED_FORECAST_SCHEMA: FieldSchemaMap = { }, }; +// One attachment or link on a GSA eBuy request. `is_link` entries have no stored document; `doc_path` is the outbound URL. +export const EBUY_ATTACHMENT_SCHEMA: FieldSchemaMap = { + doc_seq_num: { + name: "doc_seq_num", + type: "int", + isOptional: false, + isList: false, + nestedModel: null, + }, + doc_name: { + name: "doc_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + doc_type: { + name: "doc_type", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + doc_path: { + name: "doc_path", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + is_link: { + name: "is_link", + type: "bool", + isOptional: false, + isList: false, + nestedModel: null, + }, + doc_session_date: { + name: "doc_session_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, +}; + +// GSA eBuy request. `status` is frozen at the last-seen state, and the contract number a request was posted under is never returned. +export const EBUY_REQUEST_SCHEMA: FieldSchemaMap = { + rfq_id: { + name: "rfq_id", + type: "str", + isOptional: false, + isList: false, + nestedModel: null, + }, + request_type: { + name: "request_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + title: { + name: "title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + schedule: { + name: "schedule", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + sin: { + name: "sin", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + status: { + name: "status", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + buyer_name: { + name: "buyer_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + buyer_agency: { + name: "buyer_agency", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + buyer_agency_code: { + name: "buyer_agency_code", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + reference_number: { + name: "reference_number", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + issue_date: { + name: "issue_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + close_date: { + name: "close_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + attachment_count: { + name: "attachment_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + link_count: { + name: "link_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + last_seen: { + name: "last_seen", + type: "datetime", + isOptional: false, + isList: false, + nestedModel: null, + }, + description: { + name: "description", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + buyer_email: { + name: "buyer_email", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + buyer_user_id: { + name: "buyer_user_id", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + award_method: { + name: "award_method", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + contract_type: { + name: "contract_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + commercial_type: { + name: "commercial_type", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + follow_on: { + name: "follow_on", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + source_sought: { + name: "source_sought", + type: "bool", + isOptional: true, + isList: false, + nestedModel: null, + }, + pop_start_date: { + name: "pop_start_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + pop_end_date: { + name: "pop_end_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + cancel_date: { + name: "cancel_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + last_mod_date: { + name: "last_mod_date", + type: "datetime", + isOptional: true, + isList: false, + nestedModel: null, + }, + oco_name: { + name: "oco_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + oco_title: { + name: "oco_title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + oco_agency: { + name: "oco_agency", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + oco_phone: { + name: "oco_phone", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + oco_aac: { + name: "oco_aac", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + ocs_name: { + name: "ocs_name", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + ocs_title: { + name: "ocs_title", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + ocs_agency: { + name: "ocs_agency", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + ocs_phone: { + name: "ocs_phone", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + ocs_aac: { + name: "ocs_aac", + type: "str", + isOptional: true, + isList: false, + nestedModel: null, + }, + amendment_count: { + name: "amendment_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + mod_version: { + name: "mod_version", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + qa_document_count: { + name: "qa_document_count", + type: "int", + isOptional: true, + isList: false, + nestedModel: null, + }, + amendments: { + name: "amendments", + type: "dict", + isOptional: true, + isList: true, + nestedModel: null, + }, + line_items: { + name: "line_items", + type: "dict", + isOptional: true, + isList: true, + nestedModel: null, + }, + addresses: { + name: "addresses", + type: "dict", + isOptional: true, + isList: true, + nestedModel: null, + }, + detail_fetched: { + name: "detail_fetched", + type: "bool", + isOptional: false, + isList: false, + nestedModel: null, + }, + first_seen: { + name: "first_seen", + type: "datetime", + isOptional: false, + isList: false, + nestedModel: null, + }, + organization: { + name: "organization", + type: "dict", + isOptional: true, + isList: false, + nestedModel: "OrganizationOffice", + }, + attachments: { + name: "attachments", + type: "dict", + isOptional: true, + isList: true, + nestedModel: "EbuyAttachment", + }, +}; + export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Office: OFFICE_SCHEMA, Location: LOCATION_SCHEMA, @@ -5765,6 +6144,8 @@ export const EXPLICIT_SCHEMAS: ExplicitSchemas = { Protest: PROTEST_SCHEMA, ProtestDocket: PROTEST_DOCKET_SCHEMA, ContractAppeal: CONTRACT_APPEAL_SCHEMA, + EbuyRequest: EBUY_REQUEST_SCHEMA, + EbuyAttachment: EBUY_ATTACHMENT_SCHEMA, Agency: AGENCY_SCHEMA, Grant: GRANT_SCHEMA, Vehicle: VEHICLE_SCHEMA, diff --git a/src/shapes/generatedOverlay.ts b/src/shapes/generatedOverlay.ts index 0f90a9c..fd3ded7 100644 --- a/src/shapes/generatedOverlay.ts +++ b/src/shapes/generatedOverlay.ts @@ -58,14 +58,6 @@ 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"), @@ -78,7 +70,7 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), url: f("url", "str"), }, - Attachments3: { + Attachments2: { attachment_id: f("attachment_id", "str"), doc_role: f("doc_role", "str"), doc_role_alt: f("doc_role_alt", "str"), @@ -91,7 +83,7 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), url: f("url", "str"), }, - Attachments4: { + Attachments3: { attachment_id: f("attachment_id", "str"), file_size: f("file_size", "str"), mime_type: f("mime_type", "str"), @@ -101,7 +93,7 @@ export const GENERATED_NESTED: Record = { type: f("type", "str"), url: f("url", "str"), }, - Attachments5: { + Attachments4: { attachment_id: f("attachment_id", "str"), doc_role: f("doc_role", "str"), doc_role_alt: f("doc_role_alt", "str"), @@ -902,7 +894,7 @@ export const GENERATED_OVERLAY: Record = { Notice: { address: f("address", "dict", false, "Address"), archive: f("archive", "dict", false, "Archive"), - attachments: f("attachments", "dict", true, "Attachments2"), + attachments: f("attachments", "dict", true, "Attachments"), meta: f("meta", "dict", false, "Meta"), office: f("office", "dict", false, "AwardingOffice"), opportunity: f("opportunity", "dict", false, "Opportunity2"), @@ -966,7 +958,7 @@ 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", true, "Attachments5"), + attachments: f("attachments", "dict", true, "Attachments4"), department: f("department", "dict", false, "Department3"), department_id: f("department_id", "str"), latest_notice: f("latest_notice", "dict", false, "LatestNotice"), diff --git a/src/types.ts b/src/types.ts index fddc861..d172512 100644 --- a/src/types.ts +++ b/src/types.ts @@ -221,3 +221,96 @@ export interface ContractAppealRecord { decision_text?: string | null; [key: string]: unknown; } + +/** One attachment or link on a GSA eBuy request. */ +export interface EbuyAttachmentRecord { + /** The attachment's position on the request; pass it to `getEbuyAttachmentUrl()`. */ + doc_seq_num?: number; + doc_name?: string | null; + doc_type?: number | null; + /** For a link entry (`is_link: true`) this is the outbound URL; otherwise the document's original path. */ + doc_path?: string | null; + /** `true` means the entry is an external link with no stored document behind it, so `getEbuyAttachmentUrl()` refuses it. */ + is_link?: boolean; + /** ISO datetime. */ + doc_session_date?: string | null; + [key: string]: unknown; +} + +/** + * Typed return model for `client.getEbuyRequest()`, and for rows of `client.listEbuyRequests()`. + * + * Every property is optional: the list's default shape carries a subset of them, and any shape can narrow the set further. + * `status` is frozen at the last-seen state — a request that closes stops appearing rather than getting a final row, so `Open` means "open the last time it was seen". Use `last_seen` for staleness. + * The contract number a request was posted under is never returned. + * `buyer_agency_code` and several buyer and contracting-officer fields are sparse on older requests. + */ +export interface EbuyRequestRecord { + /** eBuy request id, e.g. `RFQ1835158`. */ + rfq_id?: string; + /** `RFQ`, `RFP` or `RFI`. */ + request_type?: string | null; + title?: string | null; + schedule?: string | null; + sin?: string | null; + /** `Open` or `Cancelled`, frozen at the last-seen state. */ + status?: string | null; + buyer_name?: string | null; + /** Buying department as fed, free text. */ + buyer_agency?: string | null; + buyer_agency_code?: string | null; + /** The buyer's own solicitation number. */ + reference_number?: string | null; + /** ISO datetime. */ + issue_date?: string | null; + /** ISO datetime. */ + close_date?: string | null; + attachment_count?: number | null; + link_count?: number | null; + /** ISO datetime of the last time the request was seen as active. */ + last_seen?: string; + description?: string | null; + buyer_email?: string | null; + buyer_user_id?: string | null; + /** The buying office, in the same shape as other resources' awarding office. */ + organization?: Record | null; + award_method?: string | null; + contract_type?: string | null; + commercial_type?: string | null; + follow_on?: boolean | null; + source_sought?: boolean | null; + pop_start_date?: string | null; + pop_end_date?: string | null; + cancel_date?: string | null; + last_mod_date?: string | null; + oco_name?: string | null; + oco_title?: string | null; + oco_agency?: string | null; + oco_phone?: string | null; + oco_aac?: string | null; + ocs_name?: string | null; + ocs_title?: string | null; + ocs_agency?: string | null; + ocs_phone?: string | null; + ocs_aac?: string | null; + amendment_count?: number | null; + mod_version?: number | null; + qa_document_count?: number | null; + amendments?: unknown[] | null; + line_items?: unknown[] | null; + addresses?: unknown[] | null; + detail_fetched?: boolean; + /** ISO datetime. */ + first_seen?: string; + attachments?: EbuyAttachmentRecord[]; + [key: string]: unknown; +} + +/** Response of `client.getEbuyAccess()`: whether the caller can read eBuy requests, and through which of their own contracts. */ +export interface EbuyAccess { + enabled: boolean; + /** Why `enabled` is false, `null` when it is true. `tier_required` wins when both apply. */ + reason: "tier_required" | "no_contract_grant" | null; + /** The caller's own active contract grants, sorted. */ + contracts: string[]; +} diff --git a/src/utils/http.ts b/src/utils/http.ts index 63cc96f..edc8726 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -19,6 +19,8 @@ export interface RequestOptions { path: string; query?: Record; body?: unknown; + /** `"manual"` resolves a 3xx to `{ location }` instead of following it. */ + redirect?: "manual"; } function isSafePrimitive(value: unknown): value is string | number | boolean | symbol | bigint { @@ -189,7 +191,7 @@ export class HttpClient { * extracted from the response's `Retry-After` header. */ private async attemptRequest(options: RequestOptions): Promise { - const { method, path, query, body } = options; + const { method, path, query, body, redirect } = options; const url = new URL(path.replace(/^\//, ""), this.baseUrl.endsWith("/") ? `${this.baseUrl}` : `${this.baseUrl}/`); @@ -229,6 +231,7 @@ export class HttpClient { headers, body: jsonBody, signal: controller?.signal, + ...(redirect ? { redirect } : {}), }); } catch (err) { if (timeoutId) clearTimeout(timeoutId); @@ -252,6 +255,14 @@ export class HttpClient { this.lastResponseHeaders = headersToRecord(res.headers); this.rateLimitInfo = parseRateLimit(res.headers); + if (redirect === "manual" && res.status >= 300 && res.status < 400) { + const location = getHeader(res.headers, "location"); + if (!location) { + throw new TangoAPIError(`Redirect (status ${res.status}) carried no Location header`, res.status); + } + return { location } as T; + } + let text: string; let data: unknown = null; try { @@ -367,6 +378,12 @@ export class HttpClient { return this.request({ method: "GET", path, query }); } + /** GET a path that answers with a redirect, and return the redirect target without following it. */ + async getRedirectLocation(path: string, query?: Record): Promise { + const { location } = await this.request<{ location: string }>({ method: "GET", path, query, redirect: "manual" }); + return location; + } + post(path: string, body?: unknown): Promise { return this.request({ method: "POST", path, body }); } diff --git a/tests/unit/client.ebuy.test.ts b/tests/unit/client.ebuy.test.ts new file mode 100644 index 0000000..9c47201 --- /dev/null +++ b/tests/unit/client.ebuy.test.ts @@ -0,0 +1,265 @@ +/** + * Tests for the GSA eBuy endpoints (`/api/ebuy/requests/` and `/api/ebuy/access/`). + * + * Mocked transport only: eBuy data is scoped to the caller's own schedule contracts, so it is never recorded into a cassette. + */ + +import { TangoClient } from "../../src/client.js"; +import { TangoEbuyAttachmentLinkError, TangoNotFoundError, TangoValidationError } from "../../src/errors.js"; +import { SchemaRegistry } from "../../src/shapes/schema.js"; +import type { EbuyAccess, EbuyRequestRecord } from "../../src/types.js"; + +type RecordedCall = { url: string; init?: RequestInit | undefined }; + +interface MockResponse { + status?: number; + headers?: Record; + body?: unknown; +} + +function makeClient(response: MockResponse = {}): { client: TangoClient; calls: RecordedCall[] } { + const calls: RecordedCall[] = []; + const { status = 200, headers = {}, body = { count: 0, next: null, previous: null, results: [] } } = response; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + async text() { + return body === undefined ? "" : JSON.stringify(body); + }, + }; + }) as unknown as typeof fetch; + const client = new TangoClient({ apiKey: "k", baseUrl: "http://localhost:8000", fetchImpl, retries: 0 }); + return { client, calls }; +} + +function params(calls: RecordedCall[]): URLSearchParams { + return new URL(calls[0].url).searchParams; +} + +const REQUEST: EbuyRequestRecord = { + rfq_id: "RFQ1835158", + request_type: "RFQ", + title: "Cloud migration support", + schedule: "MAS", + sin: "54151S", + status: "Open", + buyer_name: "Jane Buyer", + buyer_agency: "Department of Veterans Affairs", + buyer_agency_code: "36", + reference_number: "36C10B26Q0012", + issue_date: "2026-09-01T16:58:02+00:00", + close_date: "2026-10-01T16:00:00+00:00", + attachment_count: 2, + link_count: 1, + last_seen: "2026-09-28T18:31:08+00:00", +}; + +const DETAIL: EbuyRequestRecord = { + ...REQUEST, + description: "Migrate three workloads.", + detail_fetched: true, + first_seen: "2026-09-01T17:00:00+00:00", + follow_on: false, + mod_version: null, + amendments: [], + line_items: [], + addresses: [], + organization: { organization_id: "00000000-0000-0000-0000-000000000036", office_code: "36C10B", office_name: "Technology Acquisition Center" }, + attachments: [ + { doc_seq_num: 3852759, doc_name: "sow.pdf", doc_type: 1, doc_path: "sow.pdf", is_link: false, doc_session_date: "2026-09-01T16:58:02+00:00" }, + { doc_seq_num: 3852760, doc_name: "Portal", doc_type: 2, doc_path: "https://example.gov/portal", is_link: true, doc_session_date: null }, + ], +}; + +describe("TangoClient — eBuy requests", () => { + it("listEbuyRequests hits /api/ebuy/requests/ with filters under API param names", async () => { + const { client, calls } = makeClient(); + await client.listEbuyRequests({ + search: "cybersecurity assessment", + rfq_id: "RFQ1835158", + reference_number: "W912DY-26-Q-0012", + request_type: "RFQ|RFP", + status: "Open", + sin: "54151S", + schedule: "MAS", + buyer_agency: "Department of Veterans Affairs", + agency: "VA", + contract_number: "CONTRACT-1", + issue_date_after: "2026-01-01", + issue_date_before: "2026-06-30", + close_date_after: "2026-07-01", + close_date_before: "2026-12-31", + ordering: "-close_date", + limit: 10, + }); + + expect(new URL(calls[0].url).pathname).toBe("/api/ebuy/requests/"); + const p = params(calls); + expect(Object.fromEntries(p.entries())).toEqual({ + page: "1", + limit: "10", + search: "cybersecurity assessment", + rfq_id: "RFQ1835158", + reference_number: "W912DY-26-Q-0012", + request_type: "RFQ|RFP", + status: "Open", + sin: "54151S", + schedule: "MAS", + buyer_agency: "Department of Veterans Affairs", + agency: "VA", + contract_number: "CONTRACT-1", + issue_date_after: "2026-01-01", + issue_date_before: "2026-06-30", + close_date_after: "2026-07-01", + close_date_before: "2026-12-31", + ordering: "-close_date", + }); + }); + + it("sends no shape when the caller names none", async () => { + const { client, calls } = makeClient(); + await client.listEbuyRequests(); + expect(params(calls).has("shape")).toBe(false); + }); + + it("caps limit at 100 and passes an explicit shape through", async () => { + const { client, calls } = makeClient(); + await client.listEbuyRequests({ limit: 500, shape: "rfq_id,title,attachments(doc_seq_num,is_link)" }); + const p = params(calls); + expect(p.get("limit")).toBe("100"); + expect(p.get("shape")).toBe("rfq_id,title,attachments(doc_seq_num,is_link)"); + }); + + it("parses list results", async () => { + const { client } = makeClient({ body: { count: 1, next: null, previous: null, results: [REQUEST] } }); + const page = await client.listEbuyRequests({ status: "Open" }); + expect(page.count).toBe(1); + expect(page.results[0].rfq_id).toBe("RFQ1835158"); + expect(page.results[0].attachment_count).toBe(2); + }); + + it("returns an empty page, not an error, for an account with no linked contract", async () => { + const { client } = makeClient(); + const page = await client.listEbuyRequests(); + expect(page.count).toBe(0); + expect(page.results).toEqual([]); + }); + + it("getEbuyRequest uses the rfq_id route and returns attachments", async () => { + const { client, calls } = makeClient({ body: DETAIL }); + const request = await client.getEbuyRequest("RFQ1835158"); + expect(new URL(calls[0].url).pathname).toBe("/api/ebuy/requests/RFQ1835158/"); + expect(params(calls).has("shape")).toBe(false); + expect(request.attachments?.map((a) => [a.doc_seq_num, a.is_link])).toEqual([ + [3852759, false], + [3852760, true], + ]); + expect(request.organization?.office_code).toBe("36C10B"); + }); + + it("getEbuyRequest threads shape and flat", async () => { + const { client, calls } = makeClient({ body: DETAIL }); + await client.getEbuyRequest("RFQ1835158", { shape: "rfq_id,organization(*)", flat: true, joiner: "__", flatLists: true }); + const p = params(calls); + expect(p.get("shape")).toBe("rfq_id,organization(*)"); + expect(p.get("flat")).toBe("true"); + expect(p.get("joiner")).toBe("__"); + expect(p.get("flat_lists")).toBe("true"); + }); + + it("getEbuyRequest rejects an empty id before issuing a request", async () => { + const { client, calls } = makeClient(); + await expect(client.getEbuyRequest("")).rejects.toThrow(TangoValidationError); + expect(calls).toHaveLength(0); + }); + + it("getEbuyRequest raises TangoNotFoundError for an out-of-scope id", async () => { + const { client } = makeClient({ status: 404, body: { detail: "Not found." } }); + await expect(client.getEbuyRequest("RFQ0000000")).rejects.toThrow(TangoNotFoundError); + }); +}); + +describe("TangoClient — eBuy attachment URL", () => { + const PRESIGNED = "https://documents.example.com/ebuy/sow.pdf?X-Amz-Expires=300&X-Amz-Signature=abc"; + + it("returns the redirect target without following it", async () => { + const { client, calls } = makeClient({ status: 302, headers: { Location: PRESIGNED }, body: undefined }); + const url = await client.getEbuyAttachmentUrl("RFQ1835158", 3852759); + expect(url).toBe(PRESIGNED); + expect(calls).toHaveLength(1); + expect(new URL(calls[0].url).pathname).toBe("/api/ebuy/requests/RFQ1835158/attachments/3852759/download/"); + expect(calls[0].init?.redirect).toBe("manual"); + }); + + it("raises TangoEbuyAttachmentLinkError carrying the link url for a link entry", async () => { + const { client } = makeClient({ + status: 400, + body: { detail: "This entry is an external link, not a stored document.", url: "https://example.gov/portal" }, + }); + const err = await client.getEbuyAttachmentUrl("RFQ1835158", 3852760).catch((e: unknown) => e); + expect(err).toBeInstanceOf(TangoEbuyAttachmentLinkError); + expect(err).toBeInstanceOf(TangoValidationError); + expect((err as TangoEbuyAttachmentLinkError).url).toBe("https://example.gov/portal"); + expect((err as TangoEbuyAttachmentLinkError).statusCode).toBe(400); + }); + + it("raises TangoNotFoundError when the document has not been captured yet", async () => { + const { client } = makeClient({ status: 404, body: { detail: "The document for this attachment has not been captured yet." } }); + const err = await client.getEbuyAttachmentUrl("RFQ1835158", 3852759).catch((e: unknown) => e); + expect(err).toBeInstanceOf(TangoNotFoundError); + expect((err as TangoNotFoundError).responseData).toEqual({ detail: "The document for this attachment has not been captured yet." }); + }); + + it("keeps a plain 400 without a url as TangoValidationError", async () => { + const { client } = makeClient({ status: 400, body: { detail: "bad" } }); + const err = await client.getEbuyAttachmentUrl("RFQ1835158", 1).catch((e: unknown) => e); + expect(err).toBeInstanceOf(TangoValidationError); + expect(err).not.toBeInstanceOf(TangoEbuyAttachmentLinkError); + }); + + it("rejects missing arguments before issuing a request", async () => { + const { client, calls } = makeClient(); + await expect(client.getEbuyAttachmentUrl("", 1)).rejects.toThrow(TangoValidationError); + await expect(client.getEbuyAttachmentUrl("RFQ1835158", "")).rejects.toThrow(TangoValidationError); + expect(calls).toHaveLength(0); + }); +}); + +describe("TangoClient — eBuy access", () => { + it.each([ + { enabled: true, reason: null, contracts: ["CONTRACT-1", "CONTRACT-2"] }, + { enabled: false, reason: "no_contract_grant", contracts: [] }, + { enabled: false, reason: "tier_required", contracts: [] }, + ])("returns the access payload as served ($reason)", async (payload) => { + const { client, calls } = makeClient({ body: payload }); + const access = await client.getEbuyAccess(); + expect(new URL(calls[0].url).pathname).toBe("/api/ebuy/access/"); + expect(access).toEqual(payload); + }); +}); + +describe("EbuyRequest shape schema", () => { + const registry = new SchemaRegistry(); + + it.each(Object.keys(DETAIL))("%s is a known field", (field) => { + expect(registry.getSchema("EbuyRequest").fields[field]).toBeDefined(); + }); + + it("attachments nest the EbuyAttachment schema as a list", () => { + const spec = registry.getField("EbuyRequest", "attachments"); + expect(spec.isList).toBe(true); + expect(spec.nestedModel).toBe("EbuyAttachment"); + expect(registry.getField("EbuyAttachment", "doc_seq_num").type).toBe("int"); + }); + + it("organization nests the shared office schema", () => { + expect(registry.getField("EbuyRequest", "organization").nestedModel).toBe("OrganizationOffice"); + }); + + it.each(["contract_number", "search", "agency"])("%s is a filter and never a response field", (field) => { + expect(registry.getSchema("EbuyRequest").fields[field]).toBeUndefined(); + }); +});