diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a9f0bc..c37d6328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Breaking, pending the API route cutover: Test phone methods take a Number ID + without a project argument. `testing.triggerEvent(numberId, body)` omits + body `session` and accepts `fromNumber`; `listEventFixtures()` has no project + argument. History fixtures keep their project argument and use + `/messaging/projects/{projectId}/test-history-fixtures`. Real Numbers return + `404 resource_not_found`. Scope names remain `sandbox:read` / `sandbox:write`. + Coordinated API draft: polymorfa/polymorfa#792. This package is not published + or deployed by that change. + - UI Kit: keep a 320px thread beside the Inbox list in narrow two-pane layouts, and include the linked guides in the package. - Breaking: the browser UI packages are now one package, the UI Kit diff --git a/contracts/README.md b/contracts/README.md index d0d8c19a..7af781b7 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,775 +1,12 @@ # Contract coverage -## Campaign and custody additions - -`campaign-custody-20261014.json` pins thirty machine-accessible operations and -their referenced components to API source -`f97ad78b172e80788c33bdc0fcf28f75db9283ec`. TypeScript's new typed helpers select -`2026-10-14` when request options omit a version. The global API default and -579-operation baseline snapshots remain unchanged. - -The supplement is held with `published: false`, and the release gate checks -both source audiences. The five native ports, remaining 625-operation contract -reconciliation, accepted staging/production API evidence, and publication are -separate unfinished gates. `campaign-20261014-contract.test.ts` checks all thirty -wire operations; it does not establish provider delivery or deployed access. - -## Request log supplement - -`request-logs.json` pins `GET /platform/projects/{projectId}/request-logs` and -its `RequestLog` and `RequestLogPageMetadata` schemas to API merge commit -`f97fb511cf3cd7bf14fbccf21a679c0f7a6ebbf3` (polymorfa/polymorfa#549). -`Client.requestLogs` is checked against it in `request-logs-contract.test.ts`. -The release gate requires this pin to match the global source before -publication, so the method publishes only with a reconciled, accepted contract. - -## Webhook phone offline start day - -`webhook-phone-offline.json` pins `phoneOfflineStartDay` on the nine Platform -and Messaging webhook schemas that carry it to API source commit -`4380e1113f4b6e5830852f35b13e0ee15b6e340b`, the merge of polymorfa/polymorfa#631 -into dev. `webhook-phone-offline-contract.test.ts` checks the SDK types and -`PHONE_OFFLINE_START_DAY` against it. Refresh the global snapshots in a later -snapshot update. - -## Message provider references - -Native message receipts, webhook references, acknowledgements, history, and -channel messages expose `whatsapp_ids`, with `linked_devices`, `official_api`, -or both observed provider references. A temporary optional `whatsapp_id` -output alias remains for older consumers. The Polymorfa `id` remains the -action and reply identifier. The server and browser SDK types reflect both -fields; the alias does not select a transport. - -Hybrid Link adds six covered server methods: `quickLinks.availability`, -`messages.operationStatus`, and `hybridLink.getPolicy`, `setPolicy`, `state`, -and `setPaused`. Native send, reaction, edit, and delete requests accept an -explicit transport. Graph-compatible calls remain outside typed API coverage; -`graphTransportHeaders` supplies their routing header. The API enforces -preview enrollment and live Number authority independently of these SDK types. - -## Test-event supplement - -The integrated snapshot uses Hybrid Link API source commit -`2dd0c1563b1fc4e6525f708afc12e0685110cfe0`, now merged into API `dev` -by PR #260. It includes the merged -campaign, usage, voice, and HMS history API contracts. SDK package publication -remains separate. -`Client.callPolicy` and `Client.callOptOuts` cover six team-policy operations; -`MessagingClient.voip.retrieveCallPermission` and `.check` cover two Messaging -operations. The six Console counterparts are excluded. Message content and -`call.permission_changed` are typed in server and browser packages where -applicable. - -`testing-events.json` records the four test-event schemas from the same API -source commit as the full snapshots, including the source path and file hash. -The TypeScript test-event catalog and override types use that revision. -`testing-events.json` records the four test-event schemas from API source commit -`6839976e7ad54e044c4d789fd296edc44772a907`, including the source path and file -hash. The TypeScript test-event catalog and override types use that revision. -Local schema references are rebased to this supplement's `schemas` root. -The fixture contract test compares the exported catalog against this snapshot. -It includes `session.restriction_updated`, its boolean `restrictionActive` -override, and `call_restricted` in `callEndReason`. The retired BanSafe test -fixtures are absent. CLI consumers require a -published SDK package before updating their pinned dependency. - -## Testing phone supplement - -`testing-phone.json` records the three Test number phone operations -(`getTestingPhone`, `sendTestingPhoneMessage`, `unlinkTestingPhoneDevice`) and -their schemas from API `dev` merge commit -`2f1510f901aca1d68398a6dc9dbf243348c672c0` (polymorfa/polymorfa#526), with the -source path and file hash. `MessagingClient.testing.getPhone`, -`sendPhoneMessage` and `unlinkPhoneDevice` use this revision; a contract test -compares their paths and fields. The full snapshots are not repinned here. - -## Full snapshots - -The current snapshots are byte-identical copies of the Messaging and Platform -contracts at API dev merge commit `171a9683ff45d11a4b5d3917dc428ad2722b0d99`. -The full revision contains the inherited Logs and Usage contracts and all four -public Analytics reads. Each language ledger contains 579 operations: 442 -covered and 137 excluded, with no missing, partial or changed fingerprints -against these pinned snapshots. The three campaign update/reschedule methods -are verified in the TypeScript, Python and Rust wire suites; their old PR-owner -exclusions are removed. This baseline does not establish parity with newer API -contracts or package publication. Six reviewed -Console changes cover analytics projections, consent preferences, operational -error vitals and shared drains. They require dashboard identity and remain -excluded from server SDK authority. - -The seven financial operations cover limits, priorities, complete ordering and -one-resource combined read/save. Project priority takes precedence. Numbers -inherit customer priority; a higher number priority overrides it within the -project. Customer limits include all assigned numbers. Initial real QuickLink -creation accepts optional billing controls before admission. These methods -operate actual monthly credit-debit limits; the separate nested included-usage -quota prototype remains outside those methods. Reads require `billing:read`; -writes require `billing:manage`, live team authority and Pay-As-You-Go. -SDK publication and API deployment remain separate gates. - -The preceding repin at `b8fa167e47c901131d3bd9865e6479b153ad5157` -added only the private Console `listConsoleNumberOptions` operation, -a bounded searchable number-label read with keyset pagination. Its dashboard -identity requirement excludes it from server SDK methods. All 568 preceding -operation fingerprints and the public API contract remain unchanged. The ledger -then contained 569 operations: 429 covered and 140 excluded. -The preceding private `usageInsight` contract at -`49b529fd74f42fdd6bb30671ebd4b0ec8e9558f3` -added exact org/project/number scope metadata and immutable-number filtering. -The selected number's current UTC monthly completed outgoing calls and included -allowance remain separate from the chart period. Unknown tiers have no allowance -read; known unlimited tiers are explicit. Request logs and serverless receipts -have no number attribution, so their number measurements are unavailable. -No per-number allowance is pooled into a project/team quota; credit caps still use -their separate financial read. The preceding private read at -`9aa751ef923990c4b9ff6df56f64e35c7ae78027` distinguished catalog pricing from -measured quantities and actual charged credits across 18 aggregate categories. -Completed outbound calls match the monthly gate; serverless counts surviving -admitted project receipts, without number attribution or durable billing authority. -Complete WhatsApp media/HMS GB-month usage remains unavailable. The total -production-number metric does not treat the legacy session limit as a cap. -The preceding source, -`61d0a0e6e2c7b0eca1971657afc80926b4d4142b`, added registered consumption meters. -The earlier source, `ba09326dd3c6688f05320c1f7685aeb0290a0ed2`, -added project-filtered priority reads and mixed customer/number ordering; those -public operations retain their typed SDK methods and unchanged fingerprints. -Usage insights require dashboard identity; no server SDK method grants access -to that Console route. The three Console-only setup operations also remain -excluded from SDK authority. -Reads require team keys with `billing:read`; writes require -`billing:manage`, live team authority and Pay-As-You-Go. Existing credential masks -do not gain these permissions. Paid service continues through its window when a -limit is reached. The older Graph and test-event supplements keep their independent -pins; these financial operations do not change them. API deployment, SDK and CLI -publication remain separate gates. - -The preceding snapshots were described as follows. - -The two public Flow draft reads in the pinned Platform snapshot are covered by -`Client.project(projectId).flows.list` and `.retrieve`. The API declares both -public, project-bound, and `sessions:read`. - -The six dynamic Flow endpoint and managed Flow key operations (API PRs #428 and -#434) are covered by `Client.project(projectId).flows.endpoint`, `setEndpoint`, -`deleteEndpoint`, `endpointReceipts`, `encryptionKey` and -`rotateEncryptionKey`. The API withholds them from its public spec while the -Cloud onboarding beta has no audience. - -The snapshots are byte-identical copies of the Messaging and Platform OpenAPI -files at `polymorfa/polymorfa` commit -`5c8014d7d58b4d3e483d9d124ee9d88bd85820f9`, the merge of PR #388 into API -`dev`. `testing-events.json` uses the same commit; its four schemas are -unchanged. The response to feature evaluation includes a nullable -`experimentRevision`; both evaluation routes remain excluded from SDK methods -because they require a host runtime or live dashboard identity. The Platform -referral route was removed on API `dev` before this merge, so the snapshot has -no `/platform/referral` operation; referrals stay Console-only. `source.json` -records the exact source paths and SHA-256 hashes. SDK package publication -remains separate. - -Moving from PR source commit `2f24c1d62` to the merge commit adds 17 -operations and changes 236 fingerprints; no operation was removed. The -fingerprint changes were reviewed: - -- 211 operations only add documented `X-RateLimit-Limit`, - `X-RateLimit-Remaining` and `X-RateLimit-Reset` response headers. Most of - the other changed operations add the same headers. -- Campaigns add `sendWindow` on both surfaces. `Campaign.sendWindow` is - `CampaignSendWindow | null`; Messaging create and update, and Platform create - and update, accept `CampaignSendWindowRequest` (update and Platform create - accept null to remove it). Platform list, get, create and update now declare - `PlatformCampaign`, an open object with the Messaging campaign fields, so - those methods return it instead of `PlatformPayload`. The Platform update - body names `name`, `senderConfig`, `scheduledAt` and `sendWindow` and stays - open. -- QuickLink create, get and status add the `reauthorization` purpose to - `QuickLinkPurpose`. The public QuickLink token routes stay excluded. -- Hosted-history message reads add `mediaRetrieval` (`HistoryMediaRetrieval`). -- `VoipParticipant` adds optional `handRaised`. -- `POST /messaging/voip/calls` now accepts exactly one of `to`, - `participants` or `groupId`. `MessagingClient.voip.place` types only `to` - here, so the row is partial; group placement is implemented on SDK `dev`. -- The two account feature routes add a body `projectId`; they stay excluded. - -The 17 added operations are recorded as follows. Two Console account-health -routes (`/api/account/cloud-health` and its export) are excluded because they -require dashboard identity. Six Calls operations (call links, link preview, -raised hand, ring participant, call reaction and `GET /platform/calls/{callId}`) -have typed methods on SDK `dev` and are missing on this branch until `dev` -merges. Nine Official API operations are missing: four Cloud template routes -(proposed in SDK PR #308), stored message media download (proposed in SDK PR -#316), the customer service window, credential health, credential -reauthorization and the Meta pricing summary. - -Webhook payload types follow the same commit: `message.received` adds -`replyChoice` and `parentMessageId` and three linked-device message types, -`message.ack` adds optional `pricing` (`MetaPricingReport`), and call -participants add optional `handRaised`. - -`MessagingClient.campaigns.update` covers the new draft update route, while -`MessagingClient.campaigns.reschedule` and `Client.campaigns.reschedule` cover -moving a launched campaign that is waiting to start. The Messaging update is -sent once after an uncertain response because its route has no replay key; -reschedule uses the API's idempotency key. The Platform reschedule request names -`projectId` for an team key. The other refreshed operation -fingerprints were checked against their existing method paths and types. -files at API `dev` merge commit `e818ba62830d3727a7822379bb46c1fece90ef1b` -(API PR #433, which corrects the payment order amount and status schemas from -PR #429, on top of PR #431). PR #433 changes only the `sendMessage` -fingerprint: `tax`, `shipping` and `discount` become single closed objects -and `OrderStatusContent` requires `order` or `payment`. Moving from -`ee217bf7bf6320e20bce341335589f17357e374c` adds no operation. It changes 251 -operation fingerprints, all reviewed: every Messaging and Platform error code -enum gains `order_status_transition_invalid` and `order_cancellation_failed`, -`MessageContent` gains `orderDetails` and `orderStatus`, and the webhooks gain -`order.payment_updated`. Earlier releases pinned `29abb7b66a8e225af4df5cf354e9836ccb2ce04c` -(API PR #362). This single revision contains every merged Official API -contract the SDK consumes: Number-scoped Cloud templates and template editing, -customer service windows, Meta pricing counts, Cloud credential health and -reauthorization, QuickLink sync receipts, typing with an inbound message ID, -the Meta lifecycle webhook payloads, and the Flow provider lifecycle. It also -contains the merged campaign send windows and rate-limit headers. - -- Brazil payment orders (beta): `messages.send` accepts `orderDetails` - (`OrderDetailsMessageContent`, Pix dynamic code, payment link and boleto) and - `orderStatus` (`OrderStatusMessageContent`) content. `order.payment_updated` - is typed as `OrderPaymentUpdatedPayload`. The API enforces beta enrollment, - Official API routing and the order checks; Polymorfa relays orders and payment - reports and does not process funds. -- `MessagingClient.officialGroups` (beta) covers the twelve Official group - operations: list, create, retrieve, update, delete, getInviteLink, - resetInviteLink, removeParticipants, listJoinRequests, approveJoinRequests, - rejectJoinRequests and pin. Changes make one network attempt even with a - retry override and pass `Idempotency-Key` through. `Client.campaigns` - `recordConversion` and `conversions` cover the two conversion operations. -- `MessagingClient.cloudTemplates` covers list, retrieve, create, edit and - delete. Create, edit and delete make one network attempt even when a caller - supplies an idempotency key or retry override. Create and delete accept the - API's `Idempotency-Key`, so a manual retry with the same key returns - `409 idempotency_completed` rather than writing again. -- `MessagingClient.chats.getServiceWindow`, `MessagingClient.sessions.getMetaPricing`, - `getCloudCredentialHealth` and `reauthorizeCloudCredentials` cover the four - new Number reads and the reauthorization link. Reauthorization makes one attempt. -- `Client.project(projectId).flows` covers create, update, delete, upload, - publish, deprecate, discard, sync and receipts. The API now accepts server - API keys for these routes, so the earlier dashboard-only exclusions are - replaced. Provider writes make one attempt; `sync` reconciles an uncertain - operation without repeating it. -- The two Console-only account health reads are excluded. -- Campaign records type the stored `sendWindow`; create and update requests on - both surfaces accept it. - -250 fingerprints changed. Each was reviewed: 202 changed only by the new -`cloud_template_authority_restricted` public error code and the documented -`X-RateLimit-*` response headers. The rest add campaign `sendWindow`, the -QuickLink `sync` status and `reauthorization` purpose, the typing `id`, the -template `Idempotency-Key` header, typed Flow `metaLinks`, the feature -decision `experimentRevision`, and closed Platform campaign shapes. SDK types -follow each change on covered operations; feature evaluation stays excluded. `cloud-api-supplements.json` pins the Graph-compatible -catalog and Flow encryption routes to the same commit. - -API PR #377, merged in this snapshot, rejects invalid project-template -drafts with 400 before submission. The typed `MessagingClient.templates.submit` -method makes one network attempt, including when callers supply an idempotency -key or retry override; the route declares no replay contract. - -API PR #380, merged at the pinned commit, adds the read-only linked-catalog -product page. `MessagingClient.cloudCatalogs.listProducts` follows its WABA and -catalog path, `sessions:read` credential boundary, projected product fields, -and opaque cursor response. It does not establish live catalog access. - -API PRs #333 and #385, merged at the pinned commit, add the Official API -message-media retrieval status and the stored-copy download route. -`MessagingClient.chats.downloadMessageMedia` and `downloadMessageMediaStream` -cover the route; `HistoryMessage.mediaRetrieval` types the status. This does -not establish deployed HMS beta access or SDK package publication. - -The merged Campaigns failed-state follow-up updates the `campaign.failed` -reason example and the public archive/delete `409` descriptions. It changes no -operation fingerprint or SDK method mapping. - -The API merge also incorporates two unrelated routes from API PRs #333 and -#332. Hosted-history media download is covered by `MessagingClient.chats`; Messaging -campaign update is assigned to SDK PR #307. Both are intentional exclusions in -this Calls ledger. Existing hosted-history message reads now type the optional -`mediaRetrieval` status and remain covered. - -The Platform event-list contract adds `afterOffset` and indexed page metadata -for both organization and project routes. `Client.events.list` and project-view -`events.list` expose `FollowableIndexedEventPage` in that mode; `nextPage()` follows -`nextOffset` rather than combining an offset with a cursor. -Both views also expose `events.listIndexed` with explicit `page.nextOffset` and -`page.highWatermark` metadata for callers that manage their own polling loop. - -The preceding refresh added 17 operation rows and removes eight. Eight removed Console -operation routes moved to `/platform/operations` and -`/platform/projects/{projectId}/operations`; the existing `Client.operations` -and project-view `operations` resources cover list, get, transitions and cancel. -Three new Console retention and SIP discovery operations stay excluded. -`Client.callRetention` covers `GET` and `PUT /platform/call-retention`, merged -from SDK `dev`. The three public Calls analytics operations are covered by -`Client.calls.list`, `Client.calls.export`, and `Client.calls.stats`. -`Client.calls.exportAll` walks export pages. The 13 public Voice operations are -covered by `Client.voice.audio` and `Client.voice.providerCredentials`; their -13 Console counterparts are excluded. The full 536-operation snapshot includes -all 15 Functions routes already merged to API `dev`. - -HMS history adds four server-only Messaging reads under -`MessagingClient.chats`: `list`, `retrieve`, `listMessages`, and -`retrieveMessage`. All four are covered; they require an team key or -project token, their respective `chats:read` or `messages:read` scope, HMS on -the Number, and `messaging.history` beta enrollment. Client tokens are refused -locally. Pagination retains both cursors and the data-region response header. -The only pre-existing schema change is `hms_not_enabled` in the shared public -error enum, which changes 166 Messaging and 46 Platform fingerprints. Those -fingerprints were reconciled against the source specs; no pre-existing route or -response shape changed. - -SDK `dev` through `8392f66` adds `Client.sipTrunks.endpoint()` for -`GET /platform/sip/endpoint`. Its `SipEndpoint` result is a discriminated union: -`hosted` carries the host, transports and RTP range; `sip_not_hosted` carries -null host/RTP and no transports. Those existing types and request tests match -this exact snapshot, so the operation is covered. The merge also retains the -release script's compiled-version stamping and the single BanSafe payload block. - -The preceding 192 refreshed fingerprints were reviewed. Most reflect `number_restricted` -in the shared public error enum. Testing fixtures add -`session.restriction_updated`, `restrictionActive`, and the `call_restricted` -call-end reason. SIP transport schema wrappers preserve their existing type. -QuickLink settings declare an add-on-required `402`, handled by the existing -payment-required error class. The webhook catalog also adds the restriction -payload, requires `code` on `session.logged_out`, narrows its reason enum, and -narrows the previous BanSafe health band; SDK types follow those schemas. - -An earlier schema refresh changes only `POST /platform/campaigns`: its required -body is now `CreatePlatformCampaignRequest`, with required `name`, named optional -fields, and no additional top-level properties. The SDK requires `projectId` -because its Platform campaign resource belongs to organization clients. The -six opaque JSON fields remain `unknown`, while `senderConfig` remains an open -object. No routes were added or removed by this schema refresh. - -This revision also covers `GET /platform/usage`, `/platform/usage/records`, -and `/platform/gates` through `Client.usage.summary`, `listRecords`, and -`listGates`. `iterateRecords` follows record-page cursors. Usage is measured -but not charged; gate state requires an team key. - -The Campaigns P0 revision added these operations. Audiences gain member -management (`POST`/`GET /platform/audiences/{listId}/members` and -`DELETE .../{phone}`), campaigns gain recipient append and listing on both -surfaces, and the organization gains STOP/START keyword settings -(`GET`/`PUT /platform/optouts/settings`). All eight are covered by -`Client.audiences`, `Client.campaigns`, `Client.optOuts` and -`MessagingClient.campaigns`. - -Two campaign contracts changed in a way callers can observe. -`GET /platform/campaigns/{campaignId}/recipients` answers with `{ data, page }` -and a `status` filter instead of a bare array, and each recipient carries -`sentAt`, `deliveredAt`, `readAt`, `failedAt` and `respondedAt`; -`Client.campaigns.recipients` was updated to match. Campaign stop answers with -`CampaignStopOperation`, whose `operationId` is null when the campaign had no -active delivery run and was cancelled immediately; -`MessagingClient.campaigns.stop` no longer shares `CampaignOperationResponse`. -Campaign create on both surfaces accepts inline `recipients`. - -This revision also declares `projectId` as a query parameter on the -single-campaign Platform operations: `GET`, `PATCH` and `DELETE` -`/platform/campaigns/{campaignId}`, plus `/analytics`, `/events` and -`/recipients`. It is deliberately `required: false`: a team API key is not -bound to one project and must name the owning project, while a project token is -bound to its own project and must omit it at the API. The SDK exposes -`Client.campaigns` only on organization clients, so its types require -`projectId`; project views and project-token clients do not expose that resource. -`Client.campaigns.retrieve`, -`update`, `delete`, `analytics` and `events` take a `PlatformCampaignParams` -argument for it, and `recipients` carries it in its existing params. - -`PATCH /platform/campaigns/{campaignId}` accepts `recipientListId`, a string or -null, which points an unlaunched draft at another audience or detaches it. The -API refuses the change once the campaign has launched or its audience has been -copied into recipients. The contract still declares this request body as an -open object, so the SDK does not close it: `UpdatePlatformCampaignRequest` -names `recipientListId` and keeps an index signature for every other field. -The operation declares `409` for the refusal after launch, which the transport -already maps to `PolymorfaConflictError`; no SDK change was needed for it. - -The webhook catalog adds `contact.opted_out` and `contact.opted_in` with the -exported `ContactOptPayload`. That earlier revision registered -`bansafe.health_changed` and `bansafe.risk_changed`; the current API source has -retired both events and `bansafe.enforcement`. - -| Status | Operations | -| -------------------------------------------------------------------------------- | ---------: | -| Covered | 381 | -| Missing | 15 | -| Excluded | 132 | -| Partial | 1 | -| Changed fingerprint | 0 | -| Total | 529 | -| The webhook catalog includes `contact.opted_out` and `contact.opted_in` with the | -| exported `ContactOptPayload`. The unproduced `bansafe.health_changed`, | -| `bansafe.risk_changed`, and `bansafe.enforcement` names are retired. Historical | -| signed events with these names still decode as unknown events. | - -The base contract snapshot below excludes the four WhatsApp business analytics -reads described at the end of this document. - -| Status | Operations | -| ------------------- | ---------: | -| Covered | 422 | -| Missing | 0 | -| Excluded | 136 | -| Partial | 0 | -| Changed fingerprint | 0 | -| Total | 558 | - -This revision adds test event triggering -(`POST /messaging/testing/{projectId}/events`) and fixture listing -(`GET /messaging/testing/{projectId}/events/fixtures`), covered by -`MessagingClient.testing.triggerEvent` (with the optional `Idempotency-Key` -header through `options.idempotencyKey`) and -`MessagingClient.testing.listEventFixtures`. - -An earlier revision added app-reported call diagnostics -(`POST /messaging/voip/calls/{id}/reports`, covered by -`MessagingClient.voip.report`, and sent automatically by the browser and -Calls clients), replaces `includeSelfAudio` with `conferenceMode` in session -call settings, and moves Console call detail from `clientReports` to -`appReports` (excluded, Console-only). Earlier revisions added -`hostCloudApiCalls` to session call settings, a `sip` -connection transport in Console call detail, and the SIP trunk operations, covered by `Client.sipTrunks`, -and the calling switch, routing and revision fields of session call settings. The SIP error codes and -`calls_disabled` added to the shared public error enum changed the fingerprint of every -operation that references it; those operations were reviewed and only the -error enum differs. The Console SIP trunk and call operations are excluded. -`createProject` and `requestProductionEnrollment` match `CreatedProject` and -the `billingMode` field of the production enrollment result. - -Coverage spans the TypeScript server SDK, browser transport, and Calls package. -It does not claim coverage in other languages, package publication, or a -successful live call. - -## Reconciliation - -Revision `2259a1fd` adds the project event stream. `Client.events.stream` -covers `GET /platform/projects/{projectId}/events/stream` with reconnect and -resume, and `Client.events.acknowledgeStream` covers its manual -acknowledgement route. The source pinned here corrects the -`PlatformAccessEventStreamFrame` discriminator to reference the defined, -Platform-prefixed schemas. Revision `2259a1fd` also adds -`conversationTtlSeconds` to client rules, turns `recipientMode` into an enum, -and sets a minimum of 0 on `rateLimit` and `maxDaily`; the client-rules types -follow. - -Revision `51026bfe` adds the optional `Idempotency-Key` header and its `409` -outcomes to seven Messaging writes, and four `idempotency_*` public error -codes to the shared error schema. That schema change moves the fingerprint of every Messaging -operation that references it; each keeps its existing typed method. - -This revision adds `request_id` (required) and `request_log_url` to every -error object, and extends the `PublicError` code enum with the WhatsApp codes -(`recipient_not_on_whatsapp`, `conversation_window_closed`, -`template_not_approved`, `media_too_large`, `whatsapp_rate_limited`, -`new_chat_limit_reached`, `whatsapp_account_restricted`) and the BanSafe codes -the API now delivers. That moved 384 fingerprints; each was reviewed, and all -but three changed only in error responses. The other three are `createProject` -and `requestProductionEnrollment` (upstream Pay-As-You-Go changes, now typed as -`CreatedProject` and `ProductionEnrollmentResult.billingMode`), and the -excluded console logs read. `PolymorfaError` exposes the new fields. - -This revision replaces raw account platform codes with `phonePlatform` and -`accountType` on the session account, profile, and `session.connected` -contracts, and removes the unreturned `verifiedJids` client-rules field. The -three changed operations keep their existing typed methods. - -This refresh retires direct session creation and observation-policy writes. QuickLinks supply typed configuration and test simulation. The SDK adds project history fixture upload, saved defaults, and trusted-server Meta continuation tied to an existing QuickLink. - -Public Number, conversation, user and message identifiers remain supported. -Message responses expose exact provider IDs in `whatsapp_ids`; the temporary -optional `whatsapp_id` alias remains for older consumers. Channel -actions use the public message ID; call participants expose public identities; -history events carry a public message index and a separate provider archive. -The raw LID resolver is replaced by `MessagingClient.identities.resolve`. - -The ledger reconciles moved Messaging and Platform paths against the exact -source revision. Component references are resolved before fingerprinting, so a -referenced request or response change cannot pass unnoticed. Removed console-only -operation polling and unsupported browser handoff methods are not SDK APIs. - -BanSafe is reconciled against these same snapshots. `Client.banSafe`, -`Client.projects`, and `Client.sessions` cover the 25 Platform Health, -telemetry, findings, enforcement, incident, claim, and settings operations. -`MessagingClient.banSafe` covers the 10 Messaging Safe Mode, warm-up, Ban -Insurance evidence, and Health policy operations. Finding acknowledgement and -enforcement appeals are Console-only and stay excluded. No BanSafe gap remains. - -The Platform `BanSafeNumberDetail` schema at this revision lists `sessionId`, -`session`, `phoneNumber`, `projectId`, and `enforcement` as required but omits -them from `properties` while setting `additionalProperties: false`. The API -handler returns those fields, so `BanSafeNumberDetail` keeps them. Console, -staff, browser-owned onboarding, and capability-token routes have explicit -exclusion reasons. No whole-contract parity or package release is claimed. - -`MessagingClient.voip` covers the Calls place, accept, reject, leave -(`voipLeaveCall`), end, and add-participant operations and the session call -settings (`getCallSettings`, `updateCallSettings`). Request tests invoke these -methods and check the HTTP method, encoded path, body, authentication, and -response handling. Calls contract revision 1 removed the mode, socket-ticket, -agent-token, and browser-token routes. It also added three call-state codes -to the shared `PublicError` enum, which changes the fingerprint of every -Messaging operation that returns it. The Console-only -`/console/call-settings/{sessionId}` routes are excluded. - -The unified `Client` owns organization control-plane resources and creates -immutable project views with `client.project(projectId)`. QuickLink management -uses `Client.quickLinkSettings`; obsolete `/v1/widget` mappings are gone. -Credential-free service probes use `SystemClient`, project-token Bridge route -discovery uses `BridgeClient`, and the CLI listener protocol stays CLI-only; the public event stream is a separate SDK method. - -## Updating the ledger - -1. Select one exact source commit on the matching branch. Record whether it is - merged or a coordinated PR dependency. Copy both - authoritative OpenAPI files without editing or formatting them. -2. Run `scripts/check-coverage.mjs` against the new files and the existing - ledger without `--strict` to inspect gaps and removed operations. Determine - absent rows by their family, HTTP method, and path; an old reason string is - not evidence that a row is absent. -3. Inspect the real SDK transport before assigning coverage. A renamed API - route does not inherit coverage from a method that still calls its old - path. Keep unimplemented methods missing and explain credential-based - exclusions. Raw requests do not count as typed methods. -4. Update the ledger, source revision, hashes, and mapping tests together. - Remove obsolete rows after reviewing their replacements. -5. Run the full SDK CI checks. The repository coverage tests require the - reviewed counts, zero changed fingerprints, valid public method mappings, - and source snapshot integrity. - -`npm run check:coverage` uses the checked-in snapshots. Its strict flag requires -a ledger row for every operation; it does not require every row to be covered. -The cross-repository workflow compares an exact source revision and reports -missing or changed contracts for follow-up. Its dedicated GitHub App must have -access to read SDK contents and write coverage issues. A token-creation failure -occurs before comparison and says nothing about SDK parity. - -The merged Platform and billing adjustments reuse reviewed source from SDK commit -`75146778f7a20257a6c0f0f3139329ab308f40f0` (billing API parity): client-token administration, session lifecycle -routes, paid-number expiry, tier quotes and confirmation, and payment-required -errors. They are checked against these same canonical API snapshots. Removed -dashboard-only billing reminders and client-token session start/status helpers -are not retained as compatibility aliases. - -## Functions contract - -Functions is tracked separately in `functions/openapi.json`, with its exact -monorepo source commit and extraction hash in `functions/source.json`. This -snapshot contains only the 15 Functions operations and their transitive schemas. -`npm run check:functions` verifies their ledger; SDK tests exercise every method. -The main Messaging and Platform snapshots use the pinned Hybrid Link API branch; -Functions subset retains its separate source revision. - -All 15 Functions methods require `client.project(projectId).functions` and an -organization enabled for Functions. The SDK never retries Function mutations or -invocations automatically. Browser/client-token SDKs do not expose this server -control plane. A local implementation or installed method does not establish -hosted availability. - -The current dev reconciliation changes Campaigns error declarations only: -draft reads/writes no longer declare a plan-related 402, and launch/live sender -edits declare the existing public 503 envelope. Request and successful response -shapes remain unchanged for all eleven refreshed fingerprints; the existing -transport error handling applies. Focused Campaigns and contract checks pass. - -The Graph coverage exclusion applies to the full Graph API, not the separately -typed `cloudCatalogs` and `flowEncryption` subsets recorded in -`cloud-api-supplements.json`. - -## Campaign conversion supplement - -`campaign-conversions.json` records the two beta campaign conversion -operations and their four schemas from API source commit -`7d1b7d6eb035447dc8c5d20420e4cc172ba99b37` (polymorfa/polymorfa PR #427), with -the source file hash. The operations are withheld from the public Platform -spec while the beta has no audience. The full snapshots stay pinned to -`source.json` until the next repin, which will add both operations to the -coverage ledger as `Client.campaigns.recordConversion` and -`Client.campaigns.conversions`. `campaign-conversions-contract.test.ts` -compares the exported types with the supplement. - -## WhatsApp business analytics contract - -`Client.analytics.get` covers the team and project Analytics reads. -The strict response contains number/project-scoped messaging aggregates, -retained-call outcomes, measured timings, quality, failure categories, -missed-call follow-up and daily call series. `Client.analytics.metrics` covers -the two collector reads and returns Prometheus/OpenMetrics text rather than -a JSON envelope. All four use scoped reader authority and Analytics opt-in. - -The full Platform snapshot, each Analytics operation and `source.json` pin the -same API source revision. Reconciliation tests check exact snapshot hashes and -strict operation fingerprints. Counts do not establish package publication or -customer access. - -## AB props capabilities - -`abprops-capabilities.json` pins the stored group read and canonical number-change -webhook to backend source `2f9efad933b3a7a455f73245a566c827ac004608`. Both consumers use this exact -revision. The full legacy snapshots retain their recorded revision; unrelated -API drift is not declared covered by this scoped update. The capability methods -remain subject to the enrolled beta. Source integration does not publish a package. - -## Campaign message variation supplement - -`campaign-message-variations.json` records the weighted message variation -schemas and the `messageVariations` fields of the Messaging and Platform -campaign create, update and response schemas from API `dev` merge commit -`9dc523d940edc5954a37ad85647b89be1cd58e3c` (polymorfa/polymorfa#409), with -both source paths and file hashes. `campaign-message-variations-contract.test.ts` -checks the SDK types against it. The full snapshots stay pinned to -`contracts/source.json` until the next repin. - -## Campaign A/B test supplement - -`campaign-ab-tests.json` records the A/B variant, strategy, outcome and result -schemas, and the related Messaging and Platform campaign and analytics fields, -from API `dev` merge commit `8d248b578a7c76034b30a5a97349adcd15ce5c55` -(polymorfa/polymorfa#406), with both source paths and file hashes. -`campaign-ab-tests-contract.test.ts` checks the SDK types against it. - -## Feature evaluation supplement - -`feature-evaluation.json` records the four feature availability operations and -their referenced components from API `dev` merge commit -`2e38a3342b0692e62c3fa62f432d98b8d681c5bb` (polymorfa/polymorfa#816), with both -source paths and file hashes. `scripts/sync-feature-evaluation-contract.mjs` -extracts it without changing the operation objects. Run it with a monorepo -checkout and an exact commit to refresh the supplement. - -The supplement was first pinned at `d36e12ff05` (polymorfa/polymorfa#561). -That PR added the optional request `locale` (`en` or `pt-BR`) and the item -fields `state` (`off`, `opt_in` or `on`), `terms` and `enrollmentSource` -(`customer`, `staff` or null). `exposureToken` is always null, and -`measurementAllowed` is accepted with no effect. The enrollment and exposure -routes changed only in their descriptions; the exposure route always returns 403. - -The October 27 repin to #816 adds native experiments to both evaluate -operations. Each returned feature has a required `experiments` array of -`{ key, variant, revision, assigned }`. The single-experiment decision fields -are deprecated with fixed values: `variant` and `experimentRevision` are always -null, and `experimentAssigned` is always false. `measurementAllowed` is marked -deprecated. The resolved `evaluateFeatures` component set also picks up shared -`PublicError` codes added on API dev since #561, such as -`team_deletion_scheduled` and `whatsapp_number_not_registered`; no SDK type -reads them from this supplement. - -All four operations stay excluded, with the reasons in `coverage.json`, and no -SDK package calls them. `evaluateFeatures` accepts team and project -credentials; callers use an authenticated request. The three account routes -require dashboard identity. `feature-evaluation-contract.test.ts` checks that -the stored operations reproduce the recorded fingerprints with -`check-coverage.mjs`, that the reviewed request and response fields (including -`experiments`) are present, and that the ledger and SDK sources keep the -exclusions. - -The full snapshots and `coverage.json` stay pinned to `contracts/source.json`. -At the next full repin, these four rows change as recorded here. No SDK method -or type depends on this supplement, so `scripts/release-gate.mjs` does not hold -publication for it. - -## Campaign webhook metadata supplement - -`campaign-paused-reasons.json` pins the two accepted webhook schemas at API -`9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f`, with the exact Messaging spec hash. -The original pause producer is API326 `a6812f1570814346d5e2a973ed1b855026981793`. -Pause `reason`/`code` remain optional open strings. The accepted recipient-skip -schema also includes optional cap `messageIndex`/`followUpId`; the old326 full -skip schema is not claimed equivalent. Pause timestamps are seconds and skip -timestamps milliseconds. `webhook-contract.test.ts` overlays only these scoped -schemas; all-six source/test attribution is in -`native-campaign-webhook-metadata.json`. Compatibility and source-only package -examples are in `docs/campaign-webhook-metadata.md`. Full baseline snapshots, -coverage operation pins and API date remain unchanged. - -## Campaign follow-up supplement - -`campaign-follow-ups.json` records the receipt-timed follow-up schemas -(`CampaignFollowUp`, `CampaignFollowUpSummary`, `CampaignFollowUpReport`) and -the `followUp`/`followUps` fields of the Messaging and Platform campaign, -create, update, recipient and analytics schemas from API `dev` merge commit -`87b25fd9e2494d90f01f7511fb47be6a28845fa1` (polymorfa/polymorfa#503), with both source paths and file -hashes. `campaign-follow-ups-contract.test.ts` checks the SDK types against it. -The full snapshots stay pinned to `contracts/source.json` until the next repin. - -## Campaign contact frequency caps (beta) - -`client.campaigns.getFrequencyCap()` reads team limits and their revision. -`client.campaigns.updateFrequencyCap({ maxPer24Hours: 2, maxPer7Days: 5, revision })` -replaces both windows. Send both limits as null to remove them. A stale revision -raises `PolymorfaConflictError`; read and reconcile before trying again. A new -limit requires beta access and can return `403 feature_unavailable`. -Campaign create/update accepts `frequencyCap`; null removes a campaign cap. -Recipient reads expose `failureReason` and accept `reason: "frequency_cap"`. -Analytics adds `frequencyCap.skippedRecipientCount`. Raising limits never retries -settled skips. `contracts/campaign-frequency-cap.json` pins the merged API source; -package publication and API availability remain separate release gates. - -`campaign-segments-drip.json` pins the merged audience segment and drip additions -at accepted API source `9bb1e2ed92d8b4a07ff727f8e7ae185498f0868f`, including -source hashes, focused schemas, fields and operations. The frequency-cap -supplement uses that same source. All 21 frequency-cap and 48 segment/drip -fragments are exactly unchanged at this revision. Their native campaign manifest -references match the two full source hashes. The full baseline snapshots and -mixed campaign/custody supplement keep their independent pins. This reconciliation -records source provenance; package publication, deployed acceptance and release -holds remain separate. - -## Client-token ceilings - -`client-token-ceilings.json` pins API source `02aee1d010c5009401c0b60ab98eafd3dfd69f08` -and its exact Platform spec hash for session/Customer mint ceilings and rule -validation. The supplement is held from publication. Reconcile full snapshots, -feature pins and the exact deployed API acceptance before publication; source -merge, passing SDK tests and opaque-token transport do not establish availability. -Session `allow` requires the action-ceilings beta; an empty session list seals -zero actions, while Customer lists remain non-empty. Reserved regional actions -remain gated. The Next.js handler forwards only the grant's API actions and does -not turn a wrapper-only grant into the session's broader API action set. - -All five native mint helpers preserve omitted, empty and populated session -ceilings. Go uses the minimum Go 1.24 `omitzero` encoding to distinguish nil from -an explicit empty slice. Python, PHP and Rust expose the reserved regional -action spellings in their request types; Go and C# retain their existing string -lists. Server-only mint guards remain in every language. This extends the -existing mint request, with no added operation or coverage-ledger claim. - -## Verified documentation snippets - -[`doc-snippets.json`](doc-snippets.json) exports checked programs for all six -server SDKs. Read [the consumer and maintenance contract](doc-snippets.md) before -using it: pin the export commit, match API/spec provenance, preserve exact -placeholders and key scope, and use cURL for absent or mismatched entries. -Current metadata describes source packages and omits registry install commands. - -## Machine-credential fixture supplement - -`machine-credentials.v1.json` is the byte-identical API #647 fixture from merged -source `e95d02da674da8242453c2a41efd48883fa04734`. Its producer path, blob and hash -are in `machine-credentials.source.json`. `npm run check:machine-credentials` -checks provenance and shared consumption in all six languages. Native credential -tests check framing, growth, unchanged wire transport and diagnostic redaction. -Signed negative-claim vectors are locally accepted as opaque strings; the API -authenticates them. This supplement does not repin the baseline OpenAPI snapshots, -change scopes, or establish package publication or deployed acceptance. - -## Simultaneous calls supplement - -`calls-simultaneous-calls.json` pins the exact GET/PUT call-settings operations, -request/response schemas and Ban Insurance claim/webhook changes to accepted -API #559 merge `c1a43a1eb409ad57c2b9677c606a3d42d7b5598c`. It records full -source hashes and canonical fragment hashes for each audience. -`native-calls-simultaneous-calls.json` attributes the six native counterparts -and tests. These are scoped supplements; the global `171a9683` baseline is -unchanged. `published: false` retains the release hold. Accepted API source -alone does not prove deployment, registry publication or installed-client -acceptance. PHP and Rust qualification remains pending. +## Number-scoped Test phone and events + +`test-number-dropin-routes.json`, `testing-phone.json` and `testing-events.json` +pin the six moved operations to API `eda77760b56095a771c55e5d2df18be23e985377` +(see the full source commit in each JSON). The six coverage entries carry +`sourceCommit` overrides; the global baseline remains unchanged. Collision +schemas in the merged snapshot are namespaced `TestDropin*` so unrelated +operations retain their baseline fingerprints. The focused snapshots preserve +the authoritative schema names. Native route parity and qualification must be completed before this change is ready. This is an unpublished breaking contract +change, coordinated with polymorfa/polymorfa#792. diff --git a/contracts/coverage.json b/contracts/coverage.json index 2305b40e..c17b6631 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -2770,13 +2770,14 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "operationId": "listTestEventFixtures", - "fingerprint": "0a1482d5fd5a365c0ba2e241d1f45ed0d5c88615c871ea97af442c8f26f3d22d", + "fingerprint": "671863a514ecdb1c2dff2e3b40a0d167e340ef0b41892440d6fca313fef8abc3", "typescript": { "status": "covered", "method": "MessagingClient.testing.listEventFixtures" }, + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", "go": { "status": "covered", "method": "MessagingClient.Testing.ListEventFixtures" @@ -5174,13 +5175,14 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "operationId": "triggerTestEvent", - "fingerprint": "c2d5c723923084c9fcca1be246f046b23d6a613eae5b6e25b71c2e6df462ec55", + "fingerprint": "6739ea1960b01aed8b8b393f2749427f1e1a8c08bcc6a466f14e5ec6cce487bd", "typescript": { "status": "covered", "method": "MessagingClient.testing.triggerEvent" }, + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", "go": { "status": "covered", "method": "MessagingClient.Testing.TriggerEvent" @@ -5205,13 +5207,14 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "operationId": "createTestingHistoryFixture", - "fingerprint": "8941f260e0e4156976fe76ca2ce3545dbf2ff0a0a909bee938687e201cdfac65", + "fingerprint": "97956f2867fe294dfd9f97172135498a805e45ae97175d36f885453bbb43db69", "typescript": { "status": "covered", "method": "MessagingClient.testing.createHistoryFixture" }, + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", "go": { "status": "covered", "method": "MessagingClient.Testing.CreateHistoryFixture" @@ -18393,6 +18396,102 @@ "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority.", "milestone": "not-server-sdk" } + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "operationId": "getTestingPhone", + "fingerprint": "7987f6e6ada35684e331e740553f15bb92a382c6d0d9d3d628bd37f07f14d317", + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", + "typescript": { + "status": "covered", + "method": "MessagingClient.testing.getPhone" + }, + "go": { + "status": "covered", + "method": "MessagingClient.Testing.GetPhone" + }, + "python": { + "status": "covered", + "method": "AsyncMessagingClient.testing.get_phone" + }, + "php": { + "status": "covered", + "method": "MessagingClient::testing.getPhone" + }, + "dotnet": { + "status": "covered", + "method": "MessagingClient.Testing.GetPhoneAsync" + }, + "rust": { + "status": "covered", + "method": "MessagingClient.testing.get_phone" + } + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "operationId": "unlinkTestingPhoneDevice", + "fingerprint": "af5ec3b2b83e7d7455018a8410fa2cfa1e0300ded3fc35942081716fea2b7555", + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", + "typescript": { + "status": "covered", + "method": "MessagingClient.testing.unlinkPhoneDevice" + }, + "go": { + "status": "covered", + "method": "MessagingClient.Testing.UnlinkPhoneDevice" + }, + "python": { + "status": "covered", + "method": "AsyncMessagingClient.testing.unlink_phone_device" + }, + "php": { + "status": "covered", + "method": "MessagingClient::testing.unlinkPhoneDevice" + }, + "dotnet": { + "status": "covered", + "method": "MessagingClient.Testing.UnlinkPhoneDeviceAsync" + }, + "rust": { + "status": "covered", + "method": "MessagingClient.testing.unlink_phone_device" + } + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "operationId": "sendTestingPhoneMessage", + "fingerprint": "e6f35140f5817b11464f15c60a9ad841fa0a16d5763d19000e82fefd72cea346", + "sourceCommit": "eda77760b56095a771c55e5d2df18be23e985377", + "typescript": { + "status": "covered", + "method": "MessagingClient.testing.sendPhoneMessage" + }, + "go": { + "status": "covered", + "method": "MessagingClient.Testing.SendPhoneMessage" + }, + "python": { + "status": "covered", + "method": "AsyncMessagingClient.testing.send_phone_message" + }, + "php": { + "status": "covered", + "method": "MessagingClient::testing.sendPhoneMessage" + }, + "dotnet": { + "status": "covered", + "method": "MessagingClient.Testing.SendPhoneMessageAsync" + }, + "rust": { + "status": "covered", + "method": "MessagingClient.testing.send_phone_message" + } } ], "languages": ["typescript", "go", "python", "php", "dotnet", "rust"], diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 8f780fda..c8f4fb67 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "sourceCommit": "ff82061b9f872f1f39bc1fca3584df83209c4c52", - "sourceFilesSha256": "4b157347cdf705f6be7fdce859b1313dc2d9523ebaefdbbdebf3030ba8bf0341", + "sourceCommit": "16c330d388fdc67a370332d8235db927cf6323cb", + "sourceFilesSha256": "5987c9c9903914e6b4fb4e4b05419e0a8ff76bdeecb2cb4b7ec9cef469c1b657", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { - "messaging": "7f6ddab25a673e45da7a209292ddfd7354acccf30c27e2fa651113e321b66d3d", + "messaging": "ce6ba7a0c61dcbe31028656a5584dd8056142a395181ada7d26a1e1b8b806dd9", "platform": "71454ab4bb66d844aeedd325d9a62086cd6b6ba39a135197fd4f5ca29ef089e8" }, "releaseStatus": "source-only", diff --git a/contracts/doc-snippets.md b/contracts/doc-snippets.md index 688fa634..70c8746b 100644 --- a/contracts/doc-snippets.md +++ b/contracts/doc-snippets.md @@ -41,7 +41,7 @@ recipe. Its schemas and union branches are validated against the pinned OpenAPI. No optional request fields are included in this initial catalog. There are **31 operations per language**, 186 complete programs. Each language's -coverage ledger contains 442 covered operations, leaving **411 omitted per +coverage ledger contains 445 covered operations, leaving **414 omitted per language**. The initial recipes cover text sends, message actions, session lifecycle, business profiles/catalogs/collections and project event listing. The remaining operations have no verified recipe in this catalog and retain diff --git a/contracts/openapi.messaging.json b/contracts/openapi.messaging.json index 95038e1b..c794038b 100644 --- a/contracts/openapi.messaging.json +++ b/contracts/openapi.messaging.json @@ -20544,15 +20544,376 @@ }, "additionalProperties": false }, - "TriggerTestEventRequest": { + "TestingPhoneState": { "type": "object", "properties": { "session": { + "type": "string" + }, + "phone": { + "type": "string", + "description": "The Test number, in E.164 format." + }, + "online": { + "type": "boolean", + "description": "True while the simulated phone is running." + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deviceId": { + "type": "integer" + } + }, + "required": [ + "deviceId" + ] + }, + "description": "Companions linked to the phone, such as Polymorfa's own session and Inspector." + } + }, + "required": [ + "session", + "phone", + "online", + "devices" + ] + }, + "TestingPhoneMessageRequest": { + "type": "object", + "properties": { + "to": { + "type": "string", + "pattern": "^\\+[1-9]\\d{7,14}$", + "description": "Another Test number in the same project, in E.164 format." + }, + "text": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Test number session name in the project." + "maxLength": 4096 + } + }, + "required": [ + "to", + "text" + ], + "additionalProperties": false + }, + "TestingPhoneMessage": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "to": { + "type": "string" + }, + "messageId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "session", + "to", + "messageId" + ] + }, + "TestingPhoneUnlink": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "deviceId": { + "type": "integer" + }, + "unlinked": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "session", + "deviceId", + "unlinked" + ] + }, + "TestDropinTestingPhoneState": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "phone": { + "type": "string", + "description": "The Test number, in E.164 format." + }, + "online": { + "type": "boolean", + "description": "True while the simulated phone is running." + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deviceId": { + "type": "integer" + } + }, + "required": [ + "deviceId" + ] + }, + "description": "Companions linked to the phone, such as Polymorfa's own session and Inspector." + } + }, + "required": [ + "session", + "phone", + "online", + "devices" + ] + }, + "TestDropinPublicError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "billing_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "enum": [ + "zero_knowledge_session", + "invalid_parameter", + "invalid_credential", + "entitlement_required", + "cloud_template_authority_restricted", + "gate_limit_reached", + "billing_credit_required", + "addon_required", + "safe_mode_not_entitled", + "warmup_plan_not_entitled", + "method_not_allowed", + "permission_denied", + "missing_scope", + "resource_not_found", + "hms_not_enabled", + "media_fetch_failed", + "resource_gone", + "feature_unavailable", + "stream_cursor_invalid", + "stream_cursor_expired", + "stream_connection_limit_reached", + "state_conflict", + "team_deletion_scheduled", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_unavailable", + "hybrid_group_linked_devices_unavailable", + "hybrid_transport_unavailable", + "hybrid_target_reference_unavailable", + "send_outcome_unknown", + "idempotency_conflict", + "idempotency_in_progress", + "idempotency_completed", + "idempotency_outcome_unknown", + "call_claimed", + "call_not_ringing", + "connection_limit", + "unsupported_for_connection", + "call_permission_required", + "invalid_sip_trunk", + "calls_disabled", + "call_recipient_opted_out", + "call_destination_blocked", + "call_permission_request_limited", + "call_permission_granted", + "order_status_transition_invalid", + "order_cancellation_failed", + "call_opt_out_limit", + "number_restricted", + "sip_trunk_in_use", + "sip_trunk_revision_conflict", + "sip_trunk_limit", + "sip_unavailable", + "voice_not_enabled", + "provider_credential_invalid", + "provider_unavailable", + "asset_not_ready", + "voice_asset_in_use", + "voice_asset_revision_conflict", + "voice_unavailable", + "rate_limit_exceeded", + "internal_error", + "operation_not_supported", + "upstream_failure", + "service_unavailable", + "credential_verification_unavailable", + "session_not_ready", + "command_dispatch_failed", + "result_unknown", + "parent_bsuid_not_supported", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "marketing_disabled_on_cloud_api", + "whatsapp_account_restricted", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "whatsapp_groups_ineligible", + "whatsapp_group_creation_paused", + "whatsapp_group_limit_reached", + "whatsapp_group_full", + "whatsapp_group_suspended", + "whatsapp_group_has_no_participants", + "bansafe_suspended", + "bansafe_org_suspended", + "bansafe_cold_blocked", + "bansafe_cold_held", + "bansafe_throttled", + "bansafe_daily_allowance_reached", + "bansafe_accounting_unavailable", + "bansafe_send_outcome_unknown", + "campaigns_not_entitled", + "campaign_throughput_capped", + "campaign_approval_required", + "campaign_approval_stale", + "campaign_variables_missing" + ] + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "nullable": true + }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + }, + "required": [ + "type", + "code", + "message", + "param", + "request_id" + ] + }, + "data": { + "type": "string", + "nullable": true, + "enum": [ + null + ], + "description": "Always null for a failed request." + }, + "docs": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "error", + "data", + "docs" + ] + }, + "TestDropinTestingPhoneMessageRequest": { + "type": "object", + "properties": { + "to": { + "type": "string", + "pattern": "^\\+[1-9]\\d{7,14}$", + "description": "Another Test number in the same project, in E.164 format." + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "required": [ + "to", + "text" + ], + "additionalProperties": false + }, + "TestDropinTestingPhoneMessage": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "to": { + "type": "string" + }, + "messageId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "session", + "to", + "messageId" + ] + }, + "TestDropinTestingPhoneUnlink": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "deviceId": { + "type": "integer" }, + "unlinked": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "session", + "deviceId", + "unlinked" + ] + }, + "TestDropinTriggerTestEventRequest": { + "type": "object", + "properties": { "event": { "type": "string", "enum": [ @@ -20569,9 +20930,9 @@ "description": "Fixture name. Each name is the event type it produces." }, "overrides": { - "$ref": "#/components/schemas/TestEventOverrides" + "$ref": "#/components/schemas/TestDropinTestEventOverrides" }, - "fromSession": { + "fromNumber": { "type": "string", "minLength": 1, "maxLength": 128, @@ -20579,10 +20940,217 @@ } }, "required": [ - "session", "event" ], "additionalProperties": false + }, + "TestDropinTestEventOverrides": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "from": { + "type": "string", + "pattern": "^\\+[1-9]\\d{7,14}$" + }, + "pushName": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "mediaType": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "document", + "sticker" + ] + }, + "caption": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "ackStatus": { + "type": "string", + "enum": [ + "delivered", + "read", + "played", + "error" + ] + }, + "messageId": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "failureReason": { + "type": "string", + "enum": [ + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "send_failed", + "blocked_by_safety" + ] + }, + "video": { + "type": "boolean" + }, + "durationSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 86400 + }, + "callEndReason": { + "type": "string", + "enum": [ + "user_hangup", + "timeout", + "lost_connection", + "rejected", + "call_restricted" + ] + }, + "restrictionActive": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "CONNECTING", + "CONNECTED", + "DISCONNECTED" + ] + }, + "statusReason": { + "type": "string", + "enum": [ + "SCAN_QR", + "AUTO_RECONNECT", + "FAILED", + "MANUAL_STOP", + "QR_TIMEOUT", + "LOGGED_OUT", + "TEMPORARY_BAN", + "STREAM_ERROR" + ] + }, + "templateName": { + "type": "string", + "pattern": "^[a-z0-9_]{1,512}$" + }, + "templateStatus": { + "type": "string", + "enum": [ + "APPROVED", + "REJECTED" + ] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "additionalProperties": false + }, + "TestDropinTriggerTestEventResponse": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "session": { + "type": "string" + }, + "delivery": { + "type": "string", + "enum": [ + "generated", + "simulated" + ] + }, + "eventId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "source": { + "type": "string", + "enum": [ + "test", + "runtime" + ] + } + }, + "required": [ + "event", + "session", + "delivery", + "eventId", + "source" + ] + }, + "TestDropinTestEventFixtureList": { + "type": "object", + "properties": { + "fixtures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "description": { + "type": "string" + }, + "overrides": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "description", + "overrides" + ] + } + } + }, + "required": [ + "fixtures" + ] } }, "headers": { @@ -20606,6 +21174,27 @@ "type": "integer", "minimum": 0 } + }, + "TestDropinX-RateLimit-Limit": { + "description": "Team request quota per rolling minute, when the per-team request limiter applies.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "TestDropinX-RateLimit-Remaining": { + "description": "Requests left in the per-team quota; zero when this limiter rejects the request.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "TestDropinX-RateLimit-Reset": { + "description": "Unix timestamp in seconds when the oldest counted request leaves the rolling window, when the per-team request limiter applies. Follow Retry-After on a rejected request.", + "schema": { + "type": "integer", + "minimum": 0 + } } }, "securitySchemes": { @@ -73113,28 +73702,314 @@ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" }, "data": null, - "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Direct session signup was retired; create a QuickLink first", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "quicklink:manage" + }, + "description": "**Required scope:** `quicklink:manage`" + } + }, + "/messaging/projects/{projectId}/test-history-fixtures": { + "post": { + "operationId": "createTestingHistoryFixture", + "tags": [ + "Testing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "senderPhone": { + "type": "string", + "pattern": "^\\+[1-9][0-9]{1,14}$" + }, + "text": { + "type": "string", + "maxLength": 4096 + }, + "timestamp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "fromMe": { + "type": "boolean" + } + }, + "required": [ + "id", + "senderPhone", + "text", + "timestamp", + "fromMe" + ], + "additionalProperties": false + }, + "maxItems": 100 + } + }, + "required": [ + "messages" + ], + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Project-scoped history fixture", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fixtureId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "fixtureId" + ] + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid fixture", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" } } - }, + } + }, + "403": { + "description": "Test access required", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } - } - }, - "403": { - "description": "Authenticated but not permitted (missing scope or rule violation)", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73148,25 +74023,25 @@ "docs": "https://docs.polymorfa.com/api/errors#permission-denied" } } - }, + } + }, + "404": { + "description": "Project not found", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } - } - }, - "404": { - "description": "Resource not found", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73180,93 +74055,124 @@ "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" } } - }, - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } } }, - "410": { - "description": "Direct session signup was retired; create a QuickLink first", + "502": { + "description": "Test runtime unavailable", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { - "type": "invalid_request_error", - "code": "resource_gone", - "message": "The requested retained resource has expired or was removed.", + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", "param": null, "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" }, "data": null, - "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" } } } - }, - "500": { - "description": "Internal server error", + } + }, + "x-mint": { + "content": "sandbox:write" + }, + "description": "**Required scope:** `sandbox:write`" + } + }, + "/messaging/test-events/fixtures": { + "get": { + "operationId": "listTestEventFixtures", + "tags": [ + "Testing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List test event fixtures", + "x-required-scope": "sandbox:read", + "responses": { + "200": { + "description": "Available fixtures and the overrides each accepts", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" - }, - "example": { - "error": { - "type": "api_error", - "code": "internal_error", - "message": "An unexpected service failure occurred. Keep the request ID for support.", - "param": null, - "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" - }, - "data": null, - "docs": "https://docs.polymorfa.com/api/errors#internal-error" + "$ref": "#/components/schemas/TestDropinTestEventFixtureList" } } }, "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + } + }, + "403": { + "description": "Read scope required; client tokens are not permitted", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } } } } }, "x-mint": { - "content": "quicklink:manage" + "content": "sandbox:read" }, - "description": "**Required scope:** `quicklink:manage`" + "description": "**Required scope:** `sandbox:read`" } }, - "/messaging/testing/{projectId}/history-fixtures": { + "/messaging/{session}/test-events": { "post": { - "operationId": "createTestingHistoryFixture", + "operationId": "triggerTestEvent", "tags": [ "Testing" ], @@ -73275,16 +74181,29 @@ "BearerAuth": [] } ], + "summary": "Trigger a test event for a Test number", "x-required-scope": "sandbox:write", "parameters": [ { "schema": { "type": "string", - "format": "uuid" + "minLength": 1, + "maxLength": 128, + "description": "Test Number ID." }, "required": true, - "name": "projectId", + "name": "session", "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[\\x21-\\x7e]{1,255}$", + "description": "Optional. Repeating a request with the same key and body reuses the same event ID, so a retry after a failed or uncertain response never creates a second event or duplicate webhook deliveries." + }, + "required": false, + "name": "idempotency-key", + "in": "header" } ], "requestBody": { @@ -73292,103 +74211,50 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "senderPhone": { - "type": "string", - "pattern": "^\\+[1-9][0-9]{1,14}$" - }, - "text": { - "type": "string", - "maxLength": 4096 - }, - "timestamp": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true - }, - "fromMe": { - "type": "boolean" - } - }, - "required": [ - "id", - "senderPhone", - "text", - "timestamp", - "fromMe" - ], - "additionalProperties": false - }, - "maxItems": 100 - } - }, - "required": [ - "messages" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TestDropinTriggerTestEventRequest" } } } }, "responses": { - "201": { - "description": "Project-scoped history fixture", + "202": { + "description": "Test event accepted for delivery", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "fixtureId": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "fixtureId" - ] + "$ref": "#/components/schemas/TestDropinTriggerTestEventResponse" } } }, "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } } }, "400": { - "description": "Invalid fixture", + "description": "Invalid fixture or override", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73405,22 +74271,22 @@ } }, "403": { - "description": "Test access required", + "description": "Test numbers access required, or a client token was used", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73437,22 +74303,22 @@ } }, "404": { - "description": "Project not found", + "description": "Resource not found", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73468,23 +74334,55 @@ } } }, + "429": { + "description": "Test event rate limit reached", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "The request limit was reached. Follow Retry-After when supplied.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#rate-limit-exceeded" + } + } + } + }, "502": { - "description": "Test runtime unavailable", + "description": "Test event delivery unavailable. Retry with the same Idempotency-Key to avoid a second event.", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73502,14 +74400,17 @@ } }, "x-mint": { - "content": "sandbox:write" + "content": "Linked Device API sandbox:write" }, - "description": "**Required scope:** `sandbox:write`" + "x-protocols": [ + "Linked Device API" + ], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" } }, - "/messaging/testing/{projectId}/events/fixtures": { + "/messaging/{session}/phone": { "get": { - "operationId": "listTestEventFixtures", + "operationId": "getTestingPhone", "tags": [ "Testing" ], @@ -73518,38 +74419,40 @@ "BearerAuth": [] } ], - "summary": "List test event fixtures", + "summary": "Get a Test number's phone", "x-required-scope": "sandbox:read", "parameters": [ { "schema": { "type": "string", - "format": "uuid" + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." }, "required": true, - "name": "projectId", + "name": "session", "in": "path" } ], "responses": { "200": { - "description": "Available fixtures and the overrides each accepts", + "description": "The simulated phone and its linked companions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestEventFixtureList" + "$ref": "#/components/schemas/TestDropinTestingPhoneState" } } }, "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } } }, @@ -73557,19 +74460,19 @@ "description": "Test numbers access required, or a client token was used", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73586,22 +74489,22 @@ } }, "404": { - "description": "Project not found", + "description": "Resource not found", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73618,22 +74521,22 @@ } }, "502": { - "description": "Test access could not be verified", + "description": "The simulated phone could not be read. Retry later.", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73651,14 +74554,17 @@ } }, "x-mint": { - "content": "sandbox:read" + "content": "Linked Device API sandbox:read" }, - "description": "**Required scope:** `sandbox:read`" + "x-protocols": [ + "Linked Device API" + ], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:read`" } }, - "/messaging/testing/{projectId}/events": { + "/messaging/{session}/phone/messages": { "post": { - "operationId": "triggerTestEvent", + "operationId": "sendTestingPhoneMessage", "tags": [ "Testing" ], @@ -73667,27 +74573,19 @@ "BearerAuth": [] } ], - "summary": "Trigger a test event for a Test number", + "summary": "Send a message from a Test number's phone", "x-required-scope": "sandbox:write", "parameters": [ { "schema": { "type": "string", - "format": "uuid" + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." }, "required": true, - "name": "projectId", + "name": "session", "in": "path" - }, - { - "schema": { - "type": "string", - "pattern": "^[\\x21-\\x7e]{1,255}$", - "description": "Optional. Repeating a request with the same key and body reuses the same event ID, so a retry after a failed or uncertain response never creates a second event or duplicate webhook deliveries." - }, - "required": false, - "name": "idempotency-key", - "in": "header" } ], "requestBody": { @@ -73695,50 +74593,50 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerTestEventRequest" + "$ref": "#/components/schemas/TestDropinTestingPhoneMessageRequest" } } } }, "responses": { "202": { - "description": "Test event accepted for delivery", + "description": "The phone sent the message", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerTestEventResponse" + "$ref": "#/components/schemas/TestDropinTestingPhoneMessage" } } }, "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } } }, "400": { - "description": "Invalid fixture, override, or real session", + "description": "Invalid recipient, or the phone is offline", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73758,19 +74656,19 @@ "description": "Test numbers access required, or a client token was used", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73787,22 +74685,22 @@ } }, "404": { - "description": "Project or Test number not found", + "description": "Resource not found", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73819,22 +74717,22 @@ } }, "429": { - "description": "Test event rate limit reached", + "description": "Testing rate limit reached", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73851,22 +74749,22 @@ } }, "502": { - "description": "Test event delivery unavailable. Retry with the same Idempotency-Key to avoid a second event.", + "description": "The phone could not send the message. Retry later.", "headers": { "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" }, "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" }, "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/components/schemas/TestDropinPublicError" }, "example": { "error": { @@ -73884,9 +74782,209 @@ } }, "x-mint": { - "content": "sandbox:write" + "content": "Linked Device API sandbox:write" }, - "description": "**Required scope:** `sandbox:write`" + "x-protocols": [ + "Linked Device API" + ], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" + } + }, + "/messaging/{session}/phone/devices/{deviceId}/unlink": { + "post": { + "operationId": "unlinkTestingPhoneDevice", + "tags": [ + "Testing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Unlink a companion from a Test number's phone", + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 99, + "description": "The companion's device ID, as listed by the phone." + }, + "required": true, + "name": "deviceId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The companion was unlinked and logged out", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinTestingPhoneUnlink" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + } + }, + "400": { + "description": "The phone is offline", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Project or companion not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "502": { + "description": "The phone could not unlink the companion. Retry later.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/TestDropinX-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestDropinPublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Linked Device API sandbox:write" + }, + "x-protocols": [ + "Linked Device API" + ], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" } } }, diff --git a/contracts/source.json b/contracts/source.json index d6a23c2f..40e36122 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -3,12 +3,12 @@ "commit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "branch": "dev", "published": true, - "note": "Full Messaging and Platform snapshots pin the merged API dev revision. SDK publication and API deployment remain separate gates.", + "note": "The global source commit remains the merged API baseline. The six Number-scoped Test route overrides are pinned to accepted dev eda77760b56095a771c55e5d2df18be23e985377 in test-number-dropin-routes.json. This mixed snapshot is source preparation, not package publication or deployed API acceptance.", "contracts": { "messaging": { "sourcePath": "apps/api/docs/openapi.json", "snapshotPath": "contracts/openapi.messaging.json", - "sha256": "7f6ddab25a673e45da7a209292ddfd7354acccf30c27e2fa651113e321b66d3d" + "sha256": "ce6ba7a0c61dcbe31028656a5584dd8056142a395181ada7d26a1e1b8b806dd9" }, "platform": { "sourcePath": "apps/api/docs/openapi.management.json", diff --git a/contracts/test-number-dropin-routes.json b/contracts/test-number-dropin-routes.json new file mode 100644 index 00000000..9c18172a --- /dev/null +++ b/contracts/test-number-dropin-routes.json @@ -0,0 +1,1596 @@ +{ + "repository": "polymorfa/polymorfa", + "commit": "eda77760b56095a771c55e5d2df18be23e985377", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "5b51b9933e2f002e2f845ee184eb243d62093d8acb4c4101fb4996cc2df79d85", + "operations": { + "getTestingPhone": { + "method": "GET", + "path": "/messaging/{session}/phone", + "operation": { + "operationId": "getTestingPhone", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Get a Test number's phone", + "x-required-scope": "sandbox:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The simulated phone and its linked companions", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestingPhoneState" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "502": { + "description": "The simulated phone could not be read. Retry later.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Linked Device API sandbox:read" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:read`" + } + }, + "sendTestingPhoneMessage": { + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "operation": { + "operationId": "sendTestingPhoneMessage", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Send a message from a Test number's phone", + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestingPhoneMessageRequest" + } + } + } + }, + "responses": { + "202": { + "description": "The phone sent the message", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestingPhoneMessage" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid recipient, or the phone is offline", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "429": { + "description": "Testing rate limit reached", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "The request limit was reached. Follow Retry-After when supplied.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#rate-limit-exceeded" + } + } + } + }, + "502": { + "description": "The phone could not send the message. Retry later.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Linked Device API sandbox:write" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" + } + }, + "unlinkTestingPhoneDevice": { + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "operation": { + "operationId": "unlinkTestingPhoneDevice", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Unlink a companion from a Test number's phone", + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "The Test number's session ID." + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 99, + "description": "The companion's device ID, as listed by the phone." + }, + "required": true, + "name": "deviceId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The companion was unlinked and logged out", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestingPhoneUnlink" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "The phone is offline", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Project or companion not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "502": { + "description": "The phone could not unlink the companion. Retry later.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Linked Device API sandbox:write" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" + } + }, + "triggerTestEvent": { + "method": "POST", + "path": "/messaging/{session}/test-events", + "operation": { + "operationId": "triggerTestEvent", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Trigger a test event for a Test number", + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Test Number ID." + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[\\x21-\\x7e]{1,255}$", + "description": "Optional. Repeating a request with the same key and body reuses the same event ID, so a retry after a failed or uncertain response never creates a second event or duplicate webhook deliveries." + }, + "required": false, + "name": "idempotency-key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TriggerTestEventRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Test event accepted for delivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TriggerTestEventResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid fixture or override", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "429": { + "description": "Test event rate limit reached", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "The request limit was reached. Follow Retry-After when supplied.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#rate-limit-exceeded" + } + } + } + }, + "502": { + "description": "Test event delivery unavailable. Retry with the same Idempotency-Key to avoid a second event.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Linked Device API sandbox:write" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" + } + }, + "listTestEventFixtures": { + "method": "GET", + "path": "/messaging/test-events/fixtures", + "operation": { + "operationId": "listTestEventFixtures", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List test event fixtures", + "x-required-scope": "sandbox:read", + "responses": { + "200": { + "description": "Available fixtures and the overrides each accepts", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestEventFixtureList" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Read scope required; client tokens are not permitted", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + } + }, + "x-mint": { + "content": "sandbox:read" + }, + "description": "**Required scope:** `sandbox:read`" + } + }, + "createTestingHistoryFixture": { + "method": "POST", + "path": "/messaging/projects/{projectId}/test-history-fixtures", + "operation": { + "operationId": "createTestingHistoryFixture", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "projectId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "senderPhone": { + "type": "string", + "pattern": "^\\+[1-9][0-9]{1,14}$" + }, + "text": { + "type": "string", + "maxLength": 4096 + }, + "timestamp": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "fromMe": { + "type": "boolean" + } + }, + "required": [ + "id", + "senderPhone", + "text", + "timestamp", + "fromMe" + ], + "additionalProperties": false + }, + "maxItems": 100 + } + }, + "required": ["messages"], + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Project-scoped history fixture", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fixtureId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["fixtureId"] + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid fixture", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "403": { + "description": "Test access required", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Project not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "502": { + "description": "Test runtime unavailable", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "sandbox:write" + }, + "description": "**Required scope:** `sandbox:write`" + } + } + }, + "schemas": { + "TestingPhoneState": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "phone": { + "type": "string", + "description": "The Test number, in E.164 format." + }, + "online": { + "type": "boolean", + "description": "True while the simulated phone is running." + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deviceId": { + "type": "integer" + } + }, + "required": ["deviceId"] + }, + "description": "Companions linked to the phone, such as Polymorfa's own session and Inspector." + } + }, + "required": ["session", "phone", "online", "devices"] + }, + "PublicError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "billing_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "enum": [ + "zero_knowledge_session", + "invalid_parameter", + "invalid_credential", + "entitlement_required", + "cloud_template_authority_restricted", + "gate_limit_reached", + "billing_credit_required", + "addon_required", + "safe_mode_not_entitled", + "warmup_plan_not_entitled", + "method_not_allowed", + "permission_denied", + "missing_scope", + "resource_not_found", + "hms_not_enabled", + "media_fetch_failed", + "resource_gone", + "feature_unavailable", + "stream_cursor_invalid", + "stream_cursor_expired", + "stream_connection_limit_reached", + "state_conflict", + "team_deletion_scheduled", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_unavailable", + "hybrid_group_linked_devices_unavailable", + "hybrid_transport_unavailable", + "hybrid_target_reference_unavailable", + "send_outcome_unknown", + "idempotency_conflict", + "idempotency_in_progress", + "idempotency_completed", + "idempotency_outcome_unknown", + "call_claimed", + "call_not_ringing", + "connection_limit", + "unsupported_for_connection", + "call_permission_required", + "invalid_sip_trunk", + "calls_disabled", + "call_recipient_opted_out", + "call_destination_blocked", + "call_permission_request_limited", + "call_permission_granted", + "order_status_transition_invalid", + "order_cancellation_failed", + "call_opt_out_limit", + "number_restricted", + "sip_trunk_in_use", + "sip_trunk_revision_conflict", + "sip_trunk_limit", + "sip_unavailable", + "voice_not_enabled", + "provider_credential_invalid", + "provider_unavailable", + "asset_not_ready", + "voice_asset_in_use", + "voice_asset_revision_conflict", + "voice_unavailable", + "rate_limit_exceeded", + "internal_error", + "operation_not_supported", + "upstream_failure", + "service_unavailable", + "credential_verification_unavailable", + "session_not_ready", + "command_dispatch_failed", + "result_unknown", + "parent_bsuid_not_supported", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "marketing_disabled_on_cloud_api", + "whatsapp_account_restricted", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "whatsapp_groups_ineligible", + "whatsapp_group_creation_paused", + "whatsapp_group_limit_reached", + "whatsapp_group_full", + "whatsapp_group_suspended", + "whatsapp_group_has_no_participants", + "bansafe_suspended", + "bansafe_org_suspended", + "bansafe_cold_blocked", + "bansafe_cold_held", + "bansafe_throttled", + "bansafe_daily_allowance_reached", + "bansafe_accounting_unavailable", + "bansafe_send_outcome_unknown", + "campaigns_not_entitled", + "campaign_throughput_capped", + "campaign_approval_required", + "campaign_approval_stale", + "campaign_variables_missing" + ] + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "nullable": true + }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + }, + "required": ["type", "code", "message", "param", "request_id"] + }, + "data": { + "type": "string", + "nullable": true, + "enum": [null], + "description": "Always null for a failed request." + }, + "docs": { + "type": "string", + "format": "uri" + } + }, + "required": ["error", "data", "docs"] + }, + "TestingPhoneMessageRequest": { + "type": "object", + "properties": { + "to": { + "type": "string", + "pattern": "^\\+[1-9]\\d{7,14}$", + "description": "Another Test number in the same project, in E.164 format." + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "required": ["to", "text"], + "additionalProperties": false + }, + "TestingPhoneMessage": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "to": { + "type": "string" + }, + "messageId": { + "type": "string", + "nullable": true + } + }, + "required": ["session", "to", "messageId"] + }, + "TestingPhoneUnlink": { + "type": "object", + "properties": { + "session": { + "type": "string" + }, + "deviceId": { + "type": "integer" + }, + "unlinked": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["session", "deviceId", "unlinked"] + }, + "TriggerTestEventRequest": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ], + "description": "Fixture name. Each name is the event type it produces." + }, + "overrides": { + "$ref": "#/schemas/TestEventOverrides" + }, + "fromNumber": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "message.received only. Send a simulated text message from this other connected Test number in the same project instead of generating an event." + } + }, + "required": ["event"], + "additionalProperties": false + }, + "TestEventOverrides": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "from": { + "type": "string", + "pattern": "^\\+[1-9]\\d{7,14}$" + }, + "pushName": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "mediaType": { + "type": "string", + "enum": ["image", "video", "audio", "document", "sticker"] + }, + "caption": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "ackStatus": { + "type": "string", + "enum": ["delivered", "read", "played", "error"] + }, + "messageId": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "failureReason": { + "type": "string", + "enum": [ + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "send_failed", + "blocked_by_safety" + ] + }, + "video": { + "type": "boolean" + }, + "durationSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 86400 + }, + "callEndReason": { + "type": "string", + "enum": [ + "user_hangup", + "timeout", + "lost_connection", + "rejected", + "call_restricted" + ] + }, + "restrictionActive": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["CONNECTING", "CONNECTED", "DISCONNECTED"] + }, + "statusReason": { + "type": "string", + "enum": [ + "SCAN_QR", + "AUTO_RECONNECT", + "FAILED", + "MANUAL_STOP", + "QR_TIMEOUT", + "LOGGED_OUT", + "TEMPORARY_BAN", + "STREAM_ERROR" + ] + }, + "templateName": { + "type": "string", + "pattern": "^[a-z0-9_]{1,512}$" + }, + "templateStatus": { + "type": "string", + "enum": ["APPROVED", "REJECTED"] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "additionalProperties": false + }, + "TriggerTestEventResponse": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "session": { + "type": "string" + }, + "delivery": { + "type": "string", + "enum": ["generated", "simulated"] + }, + "eventId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "source": { + "type": "string", + "enum": ["test", "runtime"] + } + }, + "required": ["event", "session", "delivery", "eventId", "source"] + }, + "TestEventFixtureList": { + "type": "object", + "properties": { + "fixtures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "description": { + "type": "string" + }, + "overrides": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "description", "overrides"] + } + } + }, + "required": ["fixtures"] + } + }, + "components": { + "headers": { + "X-RateLimit-Limit": { + "description": "Team request quota per rolling minute, when the per-team request limiter applies.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "X-RateLimit-Remaining": { + "description": "Requests left in the per-team quota; zero when this limiter rejects the request.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp in seconds when the oldest counted request leaves the rolling window, when the per-team request limiter applies. Follow Retry-After on a rejected request.", + "schema": { + "type": "integer", + "minimum": 0 + } + } + } + } +} diff --git a/contracts/testing-events.json b/contracts/testing-events.json index 270ee3d0..07543c9c 100644 --- a/contracts/testing-events.json +++ b/contracts/testing-events.json @@ -1,56 +1,324 @@ { "repository": "polymorfa/polymorfa", - "commit": "6839976e7ad54e044c4d789fd296edc44772a907", + "commit": "eda77760b56095a771c55e5d2df18be23e985377", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "79543bf719017598c3e2497004d761056e919b3200abdaae1560893542816cc7", - "schemas": { - "TestEventFixtureList": { - "type": "object", - "properties": { - "fixtures": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": [ - "message.received", - "message.ack", - "message.failed", - "call.received", - "call.missed", - "call.ended", - "session.status", - "session.restriction_updated", - "template.status" - ] + "sourceSha256": "5b51b9933e2f002e2f845ee184eb243d62093d8acb4c4101fb4996cc2df79d85", + "operations": { + "triggerTestEvent": { + "method": "POST", + "path": "/messaging/{session}/test-events", + "operation": { + "operationId": "triggerTestEvent", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Trigger a test event for a Test number", + "x-required-scope": "sandbox:write", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Test Number ID." + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[\\x21-\\x7e]{1,255}$", + "description": "Optional. Repeating a request with the same key and body reuses the same event ID, so a retry after a failed or uncertain response never creates a second event or duplicate webhook deliveries." + }, + "required": false, + "name": "idempotency-key", + "in": "header" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TriggerTestEventRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Test event accepted for delivery", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TriggerTestEventResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "description": { - "type": "string" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "overrides": { - "type": "array", - "items": { - "type": "string" + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Invalid fixture or override", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" } } + } + }, + "403": { + "description": "Test numbers access required, or a client token was used", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "required": ["name", "description", "overrides"] + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "429": { + "description": "Test event rate limit reached", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "rate_limit_error", + "code": "rate_limit_exceeded", + "message": "The request limit was reached. Follow Retry-After when supplied.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#rate-limit-exceeded" + } + } + } + }, + "502": { + "description": "Test event delivery unavailable. Retry with the same Idempotency-Key to avoid a second event.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } } - } - }, - "required": ["fixtures"] + }, + "x-mint": { + "content": "Linked Device API sandbox:write" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" + } }, + "listTestEventFixtures": { + "method": "GET", + "path": "/messaging/test-events/fixtures", + "operation": { + "operationId": "listTestEventFixtures", + "tags": ["Testing"], + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List test event fixtures", + "x-required-scope": "sandbox:read", + "responses": { + "200": { + "description": "Available fixtures and the overrides each accepts", + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/TestEventFixtureList" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Read scope required; client tokens are not permitted", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + } + }, + "x-mint": { + "content": "sandbox:read" + }, + "description": "**Required scope:** `sandbox:read`" + } + } + }, + "schemas": { "TriggerTestEventRequest": { "type": "object", "properties": { - "session": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Test number session name in the project." - }, "event": { "type": "string", "enum": [ @@ -69,52 +337,16 @@ "overrides": { "$ref": "#/schemas/TestEventOverrides" }, - "fromSession": { + "fromNumber": { "type": "string", "minLength": 1, "maxLength": 128, "description": "message.received only. Send a simulated text message from this other connected Test number in the same project instead of generating an event." } }, - "required": ["session", "event"], + "required": ["event"], "additionalProperties": false }, - "TriggerTestEventResponse": { - "type": "object", - "properties": { - "event": { - "type": "string", - "enum": [ - "message.received", - "message.ack", - "message.failed", - "call.received", - "call.missed", - "call.ended", - "session.status", - "session.restriction_updated", - "template.status" - ] - }, - "session": { - "type": "string" - }, - "delivery": { - "type": "string", - "enum": ["generated", "simulated"] - }, - "eventId": { - "type": "string", - "nullable": true, - "format": "uuid" - }, - "source": { - "type": "string", - "enum": ["test", "runtime"] - } - }, - "required": ["event", "session", "delivery", "eventId", "source"] - }, "TestEventOverrides": { "type": "object", "properties": { @@ -212,6 +444,262 @@ } }, "additionalProperties": false + }, + "TriggerTestEventResponse": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "session": { + "type": "string" + }, + "delivery": { + "type": "string", + "enum": ["generated", "simulated"] + }, + "eventId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "source": { + "type": "string", + "enum": ["test", "runtime"] + } + }, + "required": ["event", "session", "delivery", "eventId", "source"] + }, + "PublicError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "billing_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "enum": [ + "zero_knowledge_session", + "invalid_parameter", + "invalid_credential", + "entitlement_required", + "cloud_template_authority_restricted", + "gate_limit_reached", + "billing_credit_required", + "addon_required", + "safe_mode_not_entitled", + "warmup_plan_not_entitled", + "method_not_allowed", + "permission_denied", + "missing_scope", + "resource_not_found", + "hms_not_enabled", + "media_fetch_failed", + "resource_gone", + "feature_unavailable", + "stream_cursor_invalid", + "stream_cursor_expired", + "stream_connection_limit_reached", + "state_conflict", + "team_deletion_scheduled", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_unavailable", + "hybrid_group_linked_devices_unavailable", + "hybrid_transport_unavailable", + "hybrid_target_reference_unavailable", + "send_outcome_unknown", + "idempotency_conflict", + "idempotency_in_progress", + "idempotency_completed", + "idempotency_outcome_unknown", + "call_claimed", + "call_not_ringing", + "connection_limit", + "unsupported_for_connection", + "call_permission_required", + "invalid_sip_trunk", + "calls_disabled", + "call_recipient_opted_out", + "call_destination_blocked", + "call_permission_request_limited", + "call_permission_granted", + "order_status_transition_invalid", + "order_cancellation_failed", + "call_opt_out_limit", + "number_restricted", + "sip_trunk_in_use", + "sip_trunk_revision_conflict", + "sip_trunk_limit", + "sip_unavailable", + "voice_not_enabled", + "provider_credential_invalid", + "provider_unavailable", + "asset_not_ready", + "voice_asset_in_use", + "voice_asset_revision_conflict", + "voice_unavailable", + "rate_limit_exceeded", + "internal_error", + "operation_not_supported", + "upstream_failure", + "service_unavailable", + "credential_verification_unavailable", + "session_not_ready", + "command_dispatch_failed", + "result_unknown", + "parent_bsuid_not_supported", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "marketing_disabled_on_cloud_api", + "whatsapp_account_restricted", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "whatsapp_groups_ineligible", + "whatsapp_group_creation_paused", + "whatsapp_group_limit_reached", + "whatsapp_group_full", + "whatsapp_group_suspended", + "whatsapp_group_has_no_participants", + "bansafe_suspended", + "bansafe_org_suspended", + "bansafe_cold_blocked", + "bansafe_cold_held", + "bansafe_throttled", + "bansafe_daily_allowance_reached", + "bansafe_accounting_unavailable", + "bansafe_send_outcome_unknown", + "campaigns_not_entitled", + "campaign_throughput_capped", + "campaign_approval_required", + "campaign_approval_stale", + "campaign_variables_missing" + ] + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "nullable": true + }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + }, + "required": ["type", "code", "message", "param", "request_id"] + }, + "data": { + "type": "string", + "nullable": true, + "enum": [null], + "description": "Always null for a failed request." + }, + "docs": { + "type": "string", + "format": "uri" + } + }, + "required": ["error", "data", "docs"] + }, + "TestEventFixtureList": { + "type": "object", + "properties": { + "fixtures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "message.received", + "message.ack", + "message.failed", + "call.received", + "call.missed", + "call.ended", + "session.status", + "session.restriction_updated", + "template.status" + ] + }, + "description": { + "type": "string" + }, + "overrides": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "description", "overrides"] + } + } + }, + "required": ["fixtures"] + } + }, + "components": { + "headers": { + "X-RateLimit-Limit": { + "description": "Team request quota per rolling minute, when the per-team request limiter applies.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "X-RateLimit-Remaining": { + "description": "Requests left in the per-team quota; zero when this limiter rejects the request.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp in seconds when the oldest counted request leaves the rolling window, when the per-team request limiter applies. Follow Retry-After on a rejected request.", + "schema": { + "type": "integer", + "minimum": 0 + } + } } } } diff --git a/contracts/testing-phone.json b/contracts/testing-phone.json index 81aaa5f7..b3facd6a 100644 --- a/contracts/testing-phone.json +++ b/contracts/testing-phone.json @@ -1,12 +1,12 @@ { "repository": "polymorfa/polymorfa", - "commit": "2f1510f901aca1d68398a6dc9dbf243348c672c0", + "commit": "eda77760b56095a771c55e5d2df18be23e985377", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "ea165cf5da67985a8f3c944d4771c6e30a77bb3d081396895454db22b66d9221", + "sourceSha256": "5b51b9933e2f002e2f845ee184eb243d62093d8acb4c4101fb4996cc2df79d85", "operations": { "getTestingPhone": { "method": "GET", - "path": "/messaging/testing/{projectId}/numbers/{session}/phone", + "path": "/messaging/{session}/phone", "operation": { "operationId": "getTestingPhone", "tags": ["Testing"], @@ -18,15 +18,6 @@ "summary": "Get a Test number's phone", "x-required-scope": "sandbox:read", "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "projectId", - "in": "path" - }, { "schema": { "type": "string", @@ -45,7 +36,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestingPhoneState" + "$ref": "#/schemas/TestingPhoneState" } } }, @@ -61,38 +52,6 @@ } } }, - "400": { - "description": "The session is not a Test number in the project", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicError" - }, - "example": { - "error": { - "type": "invalid_request_error", - "code": "invalid_parameter", - "message": "Correct the invalid request field.", - "param": null, - "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" - }, - "data": null, - "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" - } - } - } - }, "403": { "description": "Test numbers access required, or a client token was used", "headers": { @@ -109,7 +68,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -126,7 +85,7 @@ } }, "404": { - "description": "Project not found", + "description": "Resource not found", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -141,7 +100,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -173,7 +132,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -191,14 +150,15 @@ } }, "x-mint": { - "content": "sandbox:read" + "content": "Linked Device API sandbox:read" }, - "description": "**Required scope:** `sandbox:read`" + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:read`" } }, "sendTestingPhoneMessage": { "method": "POST", - "path": "/messaging/testing/{projectId}/numbers/{session}/phone/messages", + "path": "/messaging/{session}/phone/messages", "operation": { "operationId": "sendTestingPhoneMessage", "tags": ["Testing"], @@ -210,15 +170,6 @@ "summary": "Send a message from a Test number's phone", "x-required-scope": "sandbox:write", "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "projectId", - "in": "path" - }, { "schema": { "type": "string", @@ -236,7 +187,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestingPhoneMessageRequest" + "$ref": "#/schemas/TestingPhoneMessageRequest" } } } @@ -247,7 +198,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestingPhoneMessage" + "$ref": "#/schemas/TestingPhoneMessage" } } }, @@ -264,7 +215,7 @@ } }, "400": { - "description": "The session or recipient is not a Test number in the project, or the phone is offline", + "description": "Invalid recipient, or the phone is offline", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -279,7 +230,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -311,7 +262,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -328,7 +279,7 @@ } }, "404": { - "description": "Project not found", + "description": "Resource not found", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -343,7 +294,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -375,7 +326,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -407,7 +358,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -425,14 +376,15 @@ } }, "x-mint": { - "content": "sandbox:write" + "content": "Linked Device API sandbox:write" }, - "description": "**Required scope:** `sandbox:write`" + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" } }, "unlinkTestingPhoneDevice": { "method": "POST", - "path": "/messaging/testing/{projectId}/numbers/{session}/phone/devices/{deviceId}/unlink", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", "operation": { "operationId": "unlinkTestingPhoneDevice", "tags": ["Testing"], @@ -444,15 +396,6 @@ "summary": "Unlink a companion from a Test number's phone", "x-required-scope": "sandbox:write", "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid" - }, - "required": true, - "name": "projectId", - "in": "path" - }, { "schema": { "type": "string", @@ -482,7 +425,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestingPhoneUnlink" + "$ref": "#/schemas/TestingPhoneUnlink" } } }, @@ -499,7 +442,7 @@ } }, "400": { - "description": "The session is not a Test number in the project, or the phone is offline", + "description": "The phone is offline", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -514,7 +457,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -546,7 +489,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -578,7 +521,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -610,7 +553,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PublicError" + "$ref": "#/schemas/PublicError" }, "example": { "error": { @@ -628,9 +571,10 @@ } }, "x-mint": { - "content": "sandbox:write" + "content": "Linked Device API sandbox:write" }, - "description": "**Required scope:** `sandbox:write`" + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sandbox:write`" } } }, @@ -677,6 +621,7 @@ "invalid_request_error", "authentication_error", "permission_error", + "billing_error", "conflict_error", "rate_limit_error", "api_error", @@ -686,11 +631,16 @@ "code": { "type": "string", "enum": [ + "zero_knowledge_session", "invalid_parameter", "invalid_credential", "entitlement_required", "cloud_template_authority_restricted", "gate_limit_reached", + "billing_credit_required", + "addon_required", + "safe_mode_not_entitled", + "warmup_plan_not_entitled", "method_not_allowed", "permission_denied", "missing_scope", @@ -703,10 +653,12 @@ "stream_cursor_expired", "stream_connection_limit_reached", "state_conflict", + "team_deletion_scheduled", "hybrid_routing_paused", "hybrid_choice_required", "hybrid_transition_ineligible", "hybrid_automation_unavailable", + "hybrid_group_linked_devices_unavailable", "hybrid_transport_unavailable", "hybrid_target_reference_unavailable", "send_outcome_unknown", @@ -749,13 +701,18 @@ "session_not_ready", "command_dispatch_failed", "result_unknown", + "parent_bsuid_not_supported", "recipient_not_on_whatsapp", "conversation_window_closed", "template_not_approved", "media_too_large", "whatsapp_rate_limited", "new_chat_limit_reached", + "marketing_disabled_on_cloud_api", "whatsapp_account_restricted", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", "whatsapp_groups_ineligible", "whatsapp_group_creation_paused", "whatsapp_group_limit_reached", @@ -858,5 +815,30 @@ }, "required": ["session", "deviceId", "unlinked"] } + }, + "components": { + "headers": { + "X-RateLimit-Limit": { + "description": "Team request quota per rolling minute, when the per-team request limiter applies.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "X-RateLimit-Remaining": { + "description": "Requests left in the per-team quota; zero when this limiter rejects the request.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp in seconds when the oldest counted request leaves the rolling window, when the per-team request limiter applies. Follow Retry-After on a rejected request.", + "schema": { + "type": "integer", + "minimum": 0 + } + } + } } } diff --git a/package-lock.json b/package-lock.json index 2ba15a3a..f36f63dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2290,101 +2290,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "devOptional": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "devOptional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "devOptional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "devOptional": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "devOptional": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "devOptional": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", "devOptional": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } + "license": "BlueOak-1.0.0" }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", @@ -2611,15 +2522,6 @@ "node": ">=8.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@polymorfa/calls": { "resolved": "packages/calls", "link": true @@ -3729,7 +3631,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, + "devOptional": true, "engines": { "node": "18 || 20 || >=22" } @@ -3770,10 +3672,11 @@ "devOptional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "devOptional": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -4421,7 +4324,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4628,12 +4531,6 @@ "stream-shift": "^1.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "devOptional": true - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -5124,22 +5021,6 @@ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -5205,21 +5086,18 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.4.tgz", + "integrity": "sha512-mMiwNKoJZ4HAIRVJxotO6LsNRReA30gQwP9E2/p94tfLv+jygCNpKDjhhSL6OLY6LE371EZm5EAf7RpYuUqlsw==", "devOptional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^11.0.5", + "minipass": "^7.1.3", + "path-scurry": "^3.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5237,31 +5115,18 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "devOptional": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "devOptional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5503,22 +5368,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "devOptional": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } + "dev": true }, "node_modules/js-base64": { "version": "3.9.4", @@ -5706,10 +5556,14 @@ "dev": true }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "devOptional": true + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "devOptional": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/lru.min": { "version": "1.1.5", @@ -5802,6 +5656,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "devOptional": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6050,7 +5905,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "devOptional": true + "devOptional": true, + "license": "BlueOak-1.0.0" }, "node_modules/pako": { "version": "3.0.2", @@ -6093,22 +5949,23 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", "devOptional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6469,16 +6326,21 @@ } }, "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.4.tgz", + "integrity": "sha512-Fk3reQDVPZUMbEm7Zwxz03BWQMW3V5buSk9aU8OckTrO8A1nQtbqvYsAantGNermwsIsXcuOBqcoJ3HuMELctw==", "devOptional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^10.3.7" + "glob": "^14.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -6642,7 +6504,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6654,7 +6516,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" } @@ -6665,18 +6527,6 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6769,21 +6619,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6796,19 +6631,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7241,7 +7063,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -7314,24 +7136,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "devOptional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 0187fb4d..d69351b6 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "miniflare": { "undici": "^7.29.1", "sharp": "^0.35.5" - } + }, + "rimraf": "^6.1.4" } } diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md index eac6ef32..51a17bb3 100644 --- a/packages/dotnet/README.md +++ b/packages/dotnet/README.md @@ -39,7 +39,7 @@ Known webhooks have typed payloads for all 84 names in the pinned TypeScript con `Events.LiveSource` and `EventStreamSource` feed decoded webhook bodies to an application-owned store. Each subscription resumes from its saved cursor and skips events whose body was not retained. Dispose the subscription to stop delivery; await `DisposeAsync` or `Completion` to wait for stream cleanup. `OnError` receives terminal stream or listener failures. -The existing coverage ledger records 442 covered operations. The separate campaign/custody supplement adds 30 source implementations with TCP fixtures; its ledger adoption is pending. Graph and testing-phone supplements retain their separate fixtures. This is source-level contract coverage, not a registry release or hosted acceptance result. +The existing coverage ledger records 445 covered operations. The separate campaign/custody supplement adds 30 source implementations with TCP fixtures; its ledger adoption is pending. Graph and testing-phone supplements retain their separate fixtures. This is source-level contract coverage, not a registry release or hosted acceptance result. `CallsClient` tracks incoming and outgoing calls. `Call` provides answer, join, reject, leave, end, participant controls and typed media frames. Connections authenticate in the first WebSocket frame, refresh expiring credentials, detect missed heartbeats and reconnect within a bounded attempt budget. Media reconnect preserves connection identity and acknowledged preferences. `CallsTokenSource` shares concurrent provider requests while preserving each caller's cancellation. PCM uses the rate announced in `Call.SampleRate`; H.264 frames retain timestamps. Outgoing H.264 uses the protocol's reserved source zero; received source ownership is exposed separately. @@ -1090,3 +1090,14 @@ var state = await client.Passkeys.GetConfirmationAsync(session); Task>> ConfirmExplicitly() => client.Passkeys.ConfirmAsync(session, suppliedConfirmation); ``` + +## Number-scoped Test operations + +The Test phone methods take a Number ID, without a project argument: +~~~csharp +var phone = await client.Testing.GetPhoneAsync("support"); +var testEvent = await client.Testing.TriggerEventAsync("support", new("call.missed", FromNumber: "sales")); +var fixtures = await client.Testing.ListEventFixturesAsync(); +~~~ + +Send-phone-message and unlink-device helpers use the same Number scope. The trigger body contains event, optional overrides and optional fromNumber; it does not contain session or fromSession. The static fixture catalog has no project argument. History-fixture creation remains project-scoped. These operations require a server credential and the API’s Test-number access checks; a helper does not grant access or make a real Number a Test number. diff --git a/packages/dotnet/coverage.json b/packages/dotnet/coverage.json index 3c5dc7f3..95a227e1 100644 --- a/packages/dotnet/coverage.json +++ b/packages/dotnet/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceSdkCommit": "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "sourceSdkCommit": "a7dd02cf1655e386642b9401358b725de4903a60", "operations": [ { "family": "messaging", @@ -717,7 +717,7 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "status": "covered", "sdkMethod": "MessagingClient.Testing.ListEventFixturesAsync", "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" @@ -1328,7 +1328,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "status": "covered", "sdkMethod": "MessagingClient.Testing.TriggerEventAsync", "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" @@ -1336,7 +1336,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "status": "covered", "sdkMethod": "MessagingClient.Testing.CreateHistoryFixtureAsync", "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" @@ -4496,6 +4496,30 @@ "path": "/console/insights/vitals", "status": "excluded", "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority." + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.GetPhoneAsync", + "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.UnlinkPhoneDeviceAsync", + "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.SendPhoneMessageAsync", + "testFile": "tests/Polymorfa.Sdk.Tests/OnboardingTests.cs" } ], "supplements": { diff --git a/packages/dotnet/src/Polymorfa.Sdk/Onboarding.cs b/packages/dotnet/src/Polymorfa.Sdk/Onboarding.cs index 9df71f88..6ddb215f 100644 --- a/packages/dotnet/src/Polymorfa.Sdk/Onboarding.cs +++ b/packages/dotnet/src/Polymorfa.Sdk/Onboarding.cs @@ -12,7 +12,7 @@ public sealed record TestingHistoryMessage(string Id, string SenderPhone, string public sealed record CreateTestingHistoryFixtureRequest(IReadOnlyList Messages); public sealed record TestingHistoryFixtureCreated(string FixtureId); public sealed record TestEventOverrides(string? Text = null, string? From = null, string? PushName = null, string? MediaType = null, string? Caption = null, string? AckStatus = null, string? MessageId = null, string? FailureReason = null, bool? Video = null, decimal? DurationSeconds = null, string? CallEndReason = null, bool? RestrictionActive = null, string? Status = null, string? StatusReason = null, string? TemplateName = null, string? TemplateStatus = null, string? Reason = null); -public sealed record TriggerTestEventRequest(string Session, string Event, TestEventOverrides? Overrides = null, string? FromSession = null); +public sealed record TriggerTestEventRequest(string Event, TestEventOverrides? Overrides = null, string? FromNumber = null); public sealed record TriggerTestEventResponse(string Event, string Session, string Delivery, string? EventId, string Source); public sealed record TestEventFixtureInfo(string Name, string Description, IReadOnlyList Overrides); public sealed record TestEventFixtures(IReadOnlyList Fixtures); @@ -24,15 +24,15 @@ public sealed record UnlinkTestingPhoneDeviceResponse(string Session, int Device public sealed class Testing : Resource { internal Testing(HttpTransport http) : base(http) { } - private static string Path(string projectId) => "/messaging/testing/" + E(projectId); - private static string PhonePath(string projectId, string session) => Path(projectId) + "/numbers/" + E(session) + "/phone"; + private static string Path(string projectId) => "/messaging/projects/" + E(projectId); + private static string PhonePath(string session) => "/messaging/" + E(session) + "/phone"; private Task> Request(HttpMethod method, string path, object? body, RequestOptions? options) { Http.Credential.RequireServer(); return Http.RequestAsync(method, path, body, options); } - public Task> CreateHistoryFixtureAsync(string projectId, CreateTestingHistoryFixtureRequest input, RequestOptions? options = null) => Request(HttpMethod.Post, Path(projectId) + "/history-fixtures", input, options); - public Task> TriggerEventAsync(string projectId, TriggerTestEventRequest input, RequestOptions? options = null) => Request(HttpMethod.Post, Path(projectId) + "/events", input, options); - public Task> ListEventFixturesAsync(string projectId, RequestOptions? options = null) => Request(HttpMethod.Get, Path(projectId) + "/events/fixtures", null, options); - public Task> GetPhoneAsync(string projectId, string session, RequestOptions? options = null) { Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Get, PhonePath(projectId, session), options: options); } - public Task> SendPhoneMessageAsync(string projectId, string session, SendTestingPhoneMessageRequest input, RequestOptions? options = null) { Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Post, PhonePath(projectId, session) + "/messages", ExistingContractCodec.Capture(input), options, status: 202); } - public Task> UnlinkPhoneDeviceAsync(string projectId, string session, int deviceId, RequestOptions? options = null) { if (deviceId is < 1 or > 99) throw new PolymorfaValidationException("deviceId must be a companion ID from 1 to 99.", "invalid_parameter"); Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Post, PhonePath(projectId, session) + "/devices/" + deviceId + "/unlink", options: options); } + public Task> CreateHistoryFixtureAsync(string projectId, CreateTestingHistoryFixtureRequest input, RequestOptions? options = null) => Request(HttpMethod.Post, Path(projectId) + "/test-history-fixtures", input, options); + public Task> TriggerEventAsync(string session, TriggerTestEventRequest input, RequestOptions? options = null) => Request(HttpMethod.Post, "/messaging/" + E(session) + "/test-events", input, options); + public Task> ListEventFixturesAsync(RequestOptions? options = null) => Request(HttpMethod.Get, "/messaging/test-events/fixtures", null, options); + public Task> GetPhoneAsync(string session, RequestOptions? options = null) { Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Get, PhonePath(session), options: options); } + public Task> SendPhoneMessageAsync(string session, SendTestingPhoneMessageRequest input, RequestOptions? options = null) { Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Post, PhonePath(session) + "/messages", ExistingContractCodec.Capture(input), options, status: 202); } + public Task> UnlinkPhoneDeviceAsync(string session, int deviceId, RequestOptions? options = null) { if (deviceId is < 1 or > 99) throw new PolymorfaValidationException("deviceId must be a companion ID from 1 to 99.", "invalid_parameter"); Http.Credential.RequireServer(); return ExistingContractCodec.Request(Http, HttpMethod.Post, PhonePath(session) + "/devices/" + deviceId + "/unlink", options: options); } } public sealed record ProjectObservationPolicy(string ProjectId, string PresenceMode, string TypingMode, string LabelMode, string? QuickReplyMode = null); public sealed record SessionObservationPolicyValues(string PresenceMode, string TypingMode, string LabelMode, string? QuickReplyMode = null); @@ -47,7 +47,7 @@ public sealed record HybridRoutingPolicyScope(string Scope, string? ProjectId = { public static HybridRoutingPolicyScope Team() => new("team"); public static HybridRoutingPolicyScope Project(string projectId) => new("project", projectId); - public static HybridRoutingPolicyScope Number(string projectId, string session) => new("session", projectId, session); + public static HybridRoutingPolicyScope Number(string session) => new("session", projectId, session); } public sealed record HybridRoutingPolicy(string Scope, string Revision, string? Prefer, IReadOnlyList AllowedTransports); public sealed record SetHybridRoutingPolicyRequest(string ExpectedRevision, [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] string? Prefer, IReadOnlyList AllowedTransports); diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineBoundary41Tests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineBoundary41Tests.cs index ded47ae3..57ff276c 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineBoundary41Tests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineBoundary41Tests.cs @@ -11,9 +11,9 @@ internal static async Task RunAsync() var boundaries = new Boundary[] { new("group", "GET", "/messaging/s/groups/g/capabilities", null, "{\"success\":true,\"data\":{\"status\":\"synced\",\"syncedAt\":null,\"checkedAt\":null,\"capabilities\":[]}}", 200, "2026-09-22", async (c, _, o) => { await c.Groups.GetCapabilitiesAsync("s", "g", o); }), - new("phone", "GET", "/messaging/testing/p/numbers/s/phone", null, "{\"session\":\"s\",\"phone\":\"+15550001111\",\"online\":false,\"devices\":[]}", 200, "2026-09-22", async (c, _, o) => { await c.Testing.GetPhoneAsync("p", "s", o); }), - new("send-phone", "POST", "/messaging/testing/p/numbers/s/phone/messages", "{\"to\":\"to\",\"text\":\"text\"}", "{\"session\":\"s\",\"to\":\"to\",\"messageId\":null}", 202, "2026-09-22", async (c, _, o) => { await c.Testing.SendPhoneMessageAsync("p", "s", new("to", "text"), o); }), - new("unlink", "POST", "/messaging/testing/p/numbers/s/phone/devices/1/unlink", null, "{\"session\":\"s\",\"deviceId\":1,\"unlinked\":true}", 200, "2026-09-22", async (c, _, o) => { await c.Testing.UnlinkPhoneDeviceAsync("p", "s", 1, o); }), + new("phone", "GET", "/messaging/s/phone", null, "{\"session\":\"s\",\"phone\":\"+15550001111\",\"online\":false,\"devices\":[]}", 200, "2026-09-22", async (c, _, o) => { await c.Testing.GetPhoneAsync("s", o); }), + new("send-phone", "POST", "/messaging/s/phone/messages", "{\"to\":\"to\",\"text\":\"text\"}", "{\"session\":\"s\",\"to\":\"to\",\"messageId\":null}", 202, "2026-09-22", async (c, _, o) => { await c.Testing.SendPhoneMessageAsync("s", new("to", "text"), o); }), + new("unlink", "POST", "/messaging/s/phone/devices/1/unlink", null, "{\"session\":\"s\",\"deviceId\":1,\"unlinked\":true}", 200, "2026-09-22", async (c, _, o) => { await c.Testing.UnlinkPhoneDeviceAsync("s", 1, o); }), new("logs", "GET", "/platform/projects/p/request-logs", null, "{\"data\":[],\"page\":{\"hasMore\":false,\"nextCursor\":null,\"followCursor\":\"f\"}}", 200, "2026-09-22", async (_, c, o) => { await c.RequestLogs.ListAsync(new("p"), o); }), new("grant", "POST", "/messaging/s/media/upload-grants", "{\"type\":\"image\",\"fileEncSha256\":\"" + encoded + "\"}", "{\"success\":true,\"data\":{\"host\":\"mmg.whatsapp.net\",\"uploadUrl\":\"https://mmg.whatsapp.net/mms/image/" + encoded + "?auth=synthetic\",\"expiresAt\":2000000000}}", 200, "2026-10-14", async (c, _, o) => { var grant = await c.WhatsAppMedia.CreateUploadGrantAsync("s", WhatsAppUploadMediaType.Image, new byte[32], o); Check(grant.ExpiresAt.ToUnixTimeSeconds() == 2000000000, "Expiry units"); }), new("relay", "POST", "/messaging/s/media/uploads?type=image&fileEncSha256=" + encoded, "bytes:AQ==", "{\"success\":true,\"data\":{\"directPath\":\"/x\",\"url\":\"https://mmg.whatsapp.net/x\"}}", 200, "2026-10-14", async (c, _, o) => { using var bytes = new MemoryStream(new byte[] { 1 }); await c.WhatsAppMedia.RelayUploadAsync("s", WhatsAppUploadMediaType.Image, new byte[32], bytes, 1, o); Check(bytes.CanRead, "Borrowed relay input"); }), diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineSeams41Tests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineSeams41Tests.cs index 63a2feb1..4c3d4f4b 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineSeams41Tests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/NineSeams41Tests.cs @@ -11,14 +11,14 @@ internal static async Task RunAsync() await Messaging("GET", "/messaging/s%2Fone/groups/g%40group/capabilities", null, caps, async c => { var v = (await c.Groups.GetCapabilitiesAsync("s/one", "g@group")).Data.Data; Check(v.Capabilities[0].Value is null && v.Capabilities[1].Value == false && v.CheckedAt is null, "Nullable group facts"); }); await Messaging("GET", "/messaging/s/groups/g/capabilities", null, caps.Replace("\"unit\":null,", ""), c => InvalidResponse(async () => { await c.Groups.GetCapabilitiesAsync("s", "g"); })); const string phone = "{\"session\":\"s\",\"phone\":\"+15550001111\",\"online\":false,\"devices\":[{\"deviceId\":99}]}"; - await Messaging("GET", "/messaging/testing/p/numbers/s/phone", null, phone, async c => Check(!(await c.Testing.GetPhoneAsync("p", "s")).Data.Online, "Phone offline")); - await Messaging("POST", "/messaging/testing/p/numbers/s/phone/messages", "{\"to\":\"+15550001111\",\"text\":\"Hello\"}", "{\"session\":\"s\",\"to\":\"+15550001111\",\"messageId\":null}", async c => Check((await c.Testing.SendPhoneMessageAsync("p", "s", new("+15550001111", "Hello"))).Data.MessageId is null, "Accepted nullable messageId"), 202); - await Messaging("POST", "/messaging/testing/p/numbers/s/phone/messages", "{\"to\":\"+15550001111\",\"text\":\"Hello\"}", "{\"session\":\"s\",\"to\":\"+15550001111\"}", c => InvalidResponse(async () => { await c.Testing.SendPhoneMessageAsync("p", "s", new("+15550001111", "Hello")); }), 202); - await Messaging("POST", "/messaging/testing/p/numbers/s/phone/devices/99/unlink", null, "{\"session\":\"s\",\"deviceId\":99,\"unlinked\":true}", async c => Check((await c.Testing.UnlinkPhoneDeviceAsync("p", "s", 99)).Data.Unlinked, "Unlink99")); - await Messaging("POST", "/messaging/testing/p/numbers/s/phone/devices/1/unlink", null, "{\"session\":\"s\",\"deviceId\":1,\"unlinked\":false}", c => InvalidResponse(async () => { await c.Testing.UnlinkPhoneDeviceAsync("p", "s", 1); })); + await Messaging("GET", "/messaging/s/phone", null, phone, async c => Check(!(await c.Testing.GetPhoneAsync("s")).Data.Online, "Phone offline")); + await Messaging("POST", "/messaging/s/phone/messages", "{\"to\":\"+15550001111\",\"text\":\"Hello\"}", "{\"session\":\"s\",\"to\":\"+15550001111\",\"messageId\":null}", async c => Check((await c.Testing.SendPhoneMessageAsync("s", new("+15550001111", "Hello"))).Data.MessageId is null, "Accepted nullable messageId"), 202); + await Messaging("POST", "/messaging/s/phone/messages", "{\"to\":\"+15550001111\",\"text\":\"Hello\"}", "{\"session\":\"s\",\"to\":\"+15550001111\"}", c => InvalidResponse(async () => { await c.Testing.SendPhoneMessageAsync("s", new("+15550001111", "Hello")); }), 202); + await Messaging("POST", "/messaging/s/phone/devices/99/unlink", null, "{\"session\":\"s\",\"deviceId\":99,\"unlinked\":true}", async c => Check((await c.Testing.UnlinkPhoneDeviceAsync("s", 99)).Data.Unlinked, "Unlink99")); + await Messaging("POST", "/messaging/s/phone/devices/1/unlink", null, "{\"session\":\"s\",\"deviceId\":1,\"unlinked\":false}", c => InvalidResponse(async () => { await c.Testing.UnlinkPhoneDeviceAsync("s", 1); })); foreach (var id in new[] { 0, 100 }) { - using var c = new MessagingClient(Key); try { await c.Testing.UnlinkPhoneDeviceAsync("p", "s", id); throw new Exception("Invalid unlink accepted"); } catch (PolymorfaValidationException) { Check(true, "Unlink bounds"); } + using var c = new MessagingClient(Key); try { await c.Testing.UnlinkPhoneDeviceAsync("s", id); throw new Exception("Invalid unlink accepted"); } catch (PolymorfaValidationException) { Check(true, "Unlink bounds"); } } const string log = "{\"id\":\"9007199254740993123\",\"projectId\":\"p\",\"createdAt\":\"2026-10-14T00:00:00Z\",\"method\":\"GET\",\"route\":\"/messaging/:session\",\"status\":200,\"durationMs\":0,\"result\":\"success\",\"source\":\"api\",\"requestId\":null,\"traceId\":null,\"errorCode\":null,\"mcpTool\":null,\"credential\":null}"; foreach (var selected in new[] { Project, "22222222-2222-4222-8222-222222222222", Project }) @@ -41,9 +41,9 @@ internal static async Task RunAsync() await ClientGuardsAsync(c => c.HistorySync.RequestChunkRetryAsync("s", new(HistorySyncType.FULL, 0))); await ClientGuardsAsync(c => c.HistorySync.DeleteChunkAsync("s", new("/x", hash))); await ClientGuardsAsync(c => c.WhatsAppMedia.CreateUploadGrantAsync("s", WhatsAppUploadMediaType.Image, new byte[32])); - await ClientGuardsAsync(c => c.Testing.GetPhoneAsync("p", "s")); - await ClientGuardsAsync(c => c.Testing.SendPhoneMessageAsync("p", "s", new("to", "text"))); - await ClientGuardsAsync(c => c.Testing.UnlinkPhoneDeviceAsync("p", "s", 1)); + await ClientGuardsAsync(c => c.Testing.GetPhoneAsync("s")); + await ClientGuardsAsync(c => c.Testing.SendPhoneMessageAsync("s", new("to", "text"))); + await ClientGuardsAsync(c => c.Testing.UnlinkPhoneDeviceAsync("s", 1)); using (var wire = new WireFixture()) using (var c = new MessagingClient(Credential.ClientToken("pmfa_ct_Zml4dHVyZQ"), Options(wire))) { diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/OnboardingTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/OnboardingTests.cs index 2a0b86ca..2db15c63 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/OnboardingTests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/OnboardingTests.cs @@ -5,20 +5,20 @@ internal static class OnboardingTests public static async Task RunAsync() { await Messaging("POST", "/messaging/cloud-api/embedded-signup", """{"quicklinkId":"q1","projectId":"p1","result":{"code":"fixture-code","wabaId":"waba1","phoneNumberId":"pn1","coexistence":false,"historySync":false}}""", """{"success":true,"data":{"stage":"connected"}}""", async c => ResourceTests.Equal((await c.CloudOnboarding.AdvanceAsync(new("q1", "p1", new("fixture-code", "waba1", "pn1", false, false)))).Data.Data.Stage, "connected")); - await Messaging("POST", "/messaging/testing/p1/history-fixtures", """{"messages":[{"id":"m1","senderPhone":"+15550001111","text":"Hello","timestamp":9007199254740993,"fromMe":false}]}""", """{"fixtureId":"fixture1"}""", async c => ResourceTests.Equal((await c.Testing.CreateHistoryFixtureAsync("p1", new([new("m1", "+15550001111", "Hello", 9007199254740993L, false)]))).Data.FixtureId, "fixture1")); - await Messaging("POST", "/messaging/testing/p1/events", """{"session":"support","event":"message.received","overrides":{"text":"Hello","video":false,"restrictionActive":false},"fromSession":"other"}""", """{"event":"message.received","session":"support","delivery":"simulated","eventId":null,"source":"runtime"}""", async c => ResourceTests.Equal((await c.Testing.TriggerEventAsync("p1", new("support", "message.received", new(Text: "Hello", Video: false, RestrictionActive: false), "other"))).Data.EventId, null)); - await Messaging("GET", "/messaging/testing/p1/events/fixtures", null, """{"fixtures":[{"name":"message.received","description":"Inbound","overrides":["text","from"]}]}""", async c => ResourceTests.Equal((await c.Testing.ListEventFixturesAsync("p1")).Data.Fixtures[0].Overrides[0], "text")); - await Messaging("GET", "/messaging/testing/p1/numbers/support/phone", null, """{"session":"support","phone":"+15550001111","online":false,"devices":[{"deviceId":1}]}""", async c => ResourceTests.True(!(await c.Testing.GetPhoneAsync("p1", "support")).Data.Online)); - await CampaignExistingTests.Messaging("POST", "/messaging/testing/p1/numbers/support/phone/messages", """{"to":"+15550002222","text":"Hello"}""", """{"session":"support","to":"+15550002222","messageId":null}""", async c => ResourceTests.Equal((await c.Testing.SendPhoneMessageAsync("p1", "support", new("+15550002222", "Hello"))).Data.MessageId, null), status: 202); - await Messaging("POST", "/messaging/testing/p1/numbers/support/phone/devices/1/unlink", null, """{"session":"support","deviceId":1,"unlinked":true}""", async c => ResourceTests.True((await c.Testing.UnlinkPhoneDeviceAsync("p1", "support", 1)).Data.Unlinked)); + await Messaging("POST", "/messaging/projects/p1/test-history-fixtures", """{"messages":[{"id":"m1","senderPhone":"+15550001111","text":"Hello","timestamp":9007199254740993,"fromMe":false}]}""", """{"fixtureId":"fixture1"}""", async c => ResourceTests.Equal((await c.Testing.CreateHistoryFixtureAsync("p1", new([new("m1", "+15550001111", "Hello", 9007199254740993L, false)]))).Data.FixtureId, "fixture1")); + await Messaging("POST", "/messaging/support/test-events", """{"event":"message.received","overrides":{"text":"Hello","video":false,"restrictionActive":false},"fromNumber":"other"}""", """{"event":"message.received","session":"support","delivery":"simulated","eventId":null,"source":"runtime"}""", async c => ResourceTests.Equal((await c.Testing.TriggerEventAsync("support", new("message.received", new(Text: "Hello", Video: false, RestrictionActive: false), "other"))).Data.EventId, null)); + await Messaging("GET", "/messaging/test-events/fixtures", null, """{"fixtures":[{"name":"message.received","description":"Inbound","overrides":["text","from"]}]}""", async c => ResourceTests.Equal((await c.Testing.ListEventFixturesAsync()).Data.Fixtures[0].Overrides[0], "text")); + await Messaging("GET", "/messaging/support/phone", null, """{"session":"support","phone":"+15550001111","online":false,"devices":[{"deviceId":1}]}""", async c => ResourceTests.True(!(await c.Testing.GetPhoneAsync("support")).Data.Online)); + await CampaignExistingTests.Messaging("POST", "/messaging/support/phone/messages", """{"to":"+15550002222","text":"Hello"}""", """{"session":"support","to":"+15550002222","messageId":null}""", async c => ResourceTests.Equal((await c.Testing.SendPhoneMessageAsync("support", new("+15550002222", "Hello"))).Data.MessageId, null), status: 202); + await Messaging("POST", "/messaging/support/phone/devices/1/unlink", null, """{"session":"support","deviceId":1,"unlinked":true}""", async c => ResourceTests.True((await c.Testing.UnlinkPhoneDeviceAsync("support", 1)).Data.Unlinked)); await Messaging("GET", "/messaging/projects/p1/observation-policy", null, """{"success":true,"data":{"projectId":"p1","presenceMode":"follow","typingMode":"off","labelMode":"off","quickReplyMode":"follow"}}""", async c => ResourceTests.Equal((await c.ObservationPolicies.RetrieveForProjectAsync("p1")).Data.Data.LabelMode, "off")); await Messaging("GET", "/messaging/support/observation-policy", null, """{"success":true,"data":{"sessionName":"support","projectId":"p1","project":{"presenceMode":"follow","typingMode":"off","labelMode":"off","quickReplyMode":"follow"},"override":{"presenceMode":"inherit","typingMode":"inherit","labelMode":"inherit","quickReplyMode":"off"},"effective":{"presenceMode":"follow","typingMode":"off","labelMode":"off","quickReplyMode":"off"}}}""", async c => ResourceTests.Equal((await c.ObservationPolicies.RetrieveForSessionAsync("support")).Data.Data.Effective.QuickReplyMode, "off")); await Messaging("GET", "/messaging/routing/hybrid?scope=project&projectId=p1", null, """{"success":true,"data":{"scope":"project","revision":"rev1","prefer":null,"allowedTransports":["official_api","linked_devices"]}}""", async c => ResourceTests.Equal((await c.HybridLink.GetPolicyAsync(HybridRoutingPolicyScope.Project("p1"))).Data.Data.Prefer, null)); await Messaging("PUT", "/messaging/routing/hybrid?scope=session&projectId=p1&session=support", """{"expectedRevision":"rev1","prefer":null,"allowedTransports":[]}""", """{"success":true,"data":{"scope":"session","revision":"rev2","prefer":null,"allowedTransports":[]}}""", async c => ResourceTests.Equal((await c.HybridLink.SetPolicyAsync(HybridRoutingPolicyScope.Number("p1", "support"), new("rev1", null, []))).Data.Data.Revision, "rev2")); await Messaging("GET", "/messaging/support/hybrid-link", null, """{"success":true,"data":{"revision":"rev1","paused":false,"connections":[{"kind":"official_api","status":"connected","enabled":true}]}}""", async c => ResourceTests.True(!(await c.HybridLink.StateAsync("support")).Data.Data.Paused)); await Messaging("PUT", "/messaging/support/hybrid-link", """{"expectedRevision":"rev1","paused":false}""", """{"success":true,"data":{"revision":"rev2","paused":false}}""", async c => ResourceTests.Equal((await c.HybridLink.SetPausedAsync("support", new("rev1", false))).Data.Data.Revision, "rev2")); - using var token = new MessagingClient(Credential.ClientToken("pmfa_ct_Zml4dHVyZQ")); try { await token.Testing.GetPhoneAsync("p1", "support"); throw new Exception("Client operated phone"); } catch (PolymorfaConfigurationException) { } - using var server = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k")); try { await server.Testing.UnlinkPhoneDeviceAsync("p1", "support", 0); throw new Exception("Primary device unlink accepted"); } catch (PolymorfaValidationException) { } + using var token = new MessagingClient(Credential.ClientToken("pmfa_ct_Zml4dHVyZQ")); try { await token.Testing.GetPhoneAsync("support"); throw new Exception("Client operated phone"); } catch (PolymorfaConfigurationException) { } + using var server = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k")); try { await server.Testing.UnlinkPhoneDeviceAsync("support", 0); throw new Exception("Primary device unlink accepted"); } catch (PolymorfaValidationException) { } ResourceTests.Equal(HybridLink.GraphTransportHeaders("official_api")["X-Polymorfa-Transport"], "official_api"); ResourceTests.True(!new EmbeddedSignupResult("fixture-code", "waba1", "pn1").ToString().Contains("fixture-code")); Console.WriteLine("PASS trusted onboarding, testing events/history/phone, resolved observation and Hybrid routing policies"); } diff --git a/packages/go/README.md b/packages/go/README.md index d1bd3db0..21f806d6 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -1,6 +1,6 @@ # Polymorfa Go SDK -Handwritten server SDK for Go 1.24 or newer. This source checkout is under development and has not been published. The checked coverage manifest records native request/response evidence for all 439 primary operations supported by TypeScript SDK commit `e24cb176fc1124f5fe852ff6c77902648fd13b4a`, plus three campaign supplements in the ledger. Eight additional Graph and testing-phone routes and the eleven frequency-cap, audience-segment and drip operations in `contracts/native-campaign-supplements.json` have typed wire tests. This evidence describes SDK contracts, not deployed feature availability. +Handwritten server SDK for Go 1.24 or newer. This source checkout is under development and has not been published. The primary ledger records 445 covered operations; mapping, native execution, package verification and deployed availability are separate evidence. The module import is `github.com/polymorfa/sdks/packages/go`. This corrects the proposed `github.com/polymorfa/sdks/go` name to match the actual repository subdirectory. A release from this layout needs a `packages/go/vX.Y.Z` tag. Do not advertise an installation version until that version exists. For local work, use the `replace` directive in `examples/go/send-message/go.mod`. @@ -897,3 +897,16 @@ confirmExplicitly := func() error { } _ = confirmExplicitly ``` + +## Number-scoped Test operations + +The Test phone methods take a Number ID, without a project argument: +~~~go +phone, err := client.Testing().GetPhone(ctx, "support") +event, err := client.Testing().TriggerEvent(ctx, "support", polymorfa.TriggerTestEventRequest{ + Event: "call.missed", FromNumber: "sales", +}) +fixtures, err := client.Testing().ListEventFixtures(ctx) +~~~ + +Send-phone-message and unlink-device helpers use the same Number scope. The trigger body contains event, optional overrides and optional fromNumber; it does not contain session or fromSession. The static fixture catalog has no project argument. History-fixture creation remains project-scoped. These operations require a server credential and the API’s Test-number access checks; a helper does not grant access or make a real Number a Test number. diff --git a/packages/go/cloud_audiences_test.go b/packages/go/cloud_audiences_test.go index 89b2720c..cec37758 100644 --- a/packages/go/cloud_audiences_test.go +++ b/packages/go/cloud_audiences_test.go @@ -23,14 +23,14 @@ var cloudAudienceFixtures = []operationFixture{ v := false return wireData(m.CloudOnboarding().Advance(ctx, EmbeddedSignupRequest{QuickLinkID: "link", ProjectID: "p", Result: &EmbeddedSignupResult{Code: "fixture-code", WABAID: "waba", PhoneNumberID: "phone", Coexistence: &v}})) }}, - {"MessagingClient.Testing.CreateHistoryFixture", "POST", "/messaging/testing/p/history-fixtures", "", `{"messages":[{"id":"message","senderPhone":"+15551234567","text":"Hello","timestamp":123,"fromMe":false}]}`, `{"fixtureId":"fixture"}`, "fixtureId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + {"MessagingClient.Testing.CreateHistoryFixture", "POST", "/messaging/projects/p/test-history-fixtures", "", `{"messages":[{"id":"message","senderPhone":"+15551234567","text":"Hello","timestamp":123,"fromMe":false}]}`, `{"fixtureId":"fixture"}`, "fixtureId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { return wireData(m.Testing().CreateHistoryFixture(ctx, "p", []TestingHistoryMessage{{ID: "message", SenderPhone: "+15551234567", Text: "Hello", Timestamp: 123}})) }}, - {"MessagingClient.Testing.TriggerEvent", "POST", "/messaging/testing/p/events", "", `{"session":"s","event":"message.received","overrides":{"text":"Hello"}}`, `{"event":"message.received","session":"s","delivery":"queued","eventId":"event","source":"test"}`, "eventId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { - return wireData(m.Testing().TriggerEvent(ctx, "p", TriggerTestEventRequest{Session: "s", Event: "message.received", Overrides: &TestEventOverrides{Text: "Hello"}})) + {"MessagingClient.Testing.TriggerEvent", "POST", "/messaging/s/test-events", "", `{"event":"message.received","overrides":{"text":"Hello"}}`, `{"event":"message.received","session":"s","delivery":"queued","eventId":"event","source":"test"}`, "eventId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + return wireData(m.Testing().TriggerEvent(ctx, "s", TriggerTestEventRequest{Event: "message.received", Overrides: &TestEventOverrides{Text: "Hello"}})) }}, - {"MessagingClient.Testing.ListEventFixtures", "GET", "/messaging/testing/p/events/fixtures", "", "", `{"fixtures":[{"name":"message.received","description":"Incoming","overrides":["text"]}]}`, "fixtures.0.description", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { - return wireData(m.Testing().ListEventFixtures(ctx, "p")) + {"MessagingClient.Testing.ListEventFixtures", "GET", "/messaging/test-events/fixtures", "", "", `{"fixtures":[{"name":"message.received","description":"Incoming","overrides":["text"]}]}`, "fixtures.0.description", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + return wireData(m.Testing().ListEventFixtures(ctx)) }}, {"OrganizationClient.Audiences.List", "GET", "/platform/audiences", "", "", `{"data":{"audiences":[{"id":"audience"}]}}`, "data.audiences.0.id", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { return wireData(o.Audiences().List(ctx)) diff --git a/packages/go/coverage.json b/packages/go/coverage.json index 592d8b41..d9e31526 100644 --- a/packages/go/coverage.json +++ b/packages/go/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceSdkCommit": "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "sourceSdkCommit": "a7dd02cf1655e386642b9401358b725de4903a60", "operations": [ { "family": "messaging", @@ -717,7 +717,7 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "status": "covered", "sdkMethod": "MessagingClient.Testing.ListEventFixtures", "testFile": "cloud_audiences_test.go" @@ -1328,7 +1328,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "status": "covered", "sdkMethod": "MessagingClient.Testing.TriggerEvent", "testFile": "cloud_audiences_test.go" @@ -1336,7 +1336,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "status": "covered", "sdkMethod": "MessagingClient.Testing.CreateHistoryFixture", "testFile": "cloud_audiences_test.go" @@ -4496,6 +4496,30 @@ "path": "/console/insights/vitals", "status": "excluded", "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority." + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.GetPhone", + "testFile": "supplements_test.go" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.UnlinkPhoneDevice", + "testFile": "supplements_test.go" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "status": "covered", + "sdkMethod": "MessagingClient.Testing.SendPhoneMessage", + "testFile": "supplements_test.go" } ], "supplements": { diff --git a/packages/go/go41_nine_test.go b/packages/go/go41_nine_test.go index dabd1a42..30a1608d 100644 --- a/packages/go/go41_nine_test.go +++ b/packages/go/go41_nine_test.go @@ -38,14 +38,14 @@ func go41Routes() []go41Route { {"getGroupCapabilities", "GET", "/messaging/session%20%2Fx/groups/group%20%2Fx/capabilities", "", go41Group, 200, false, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { return m.Groups().GetCapabilities(c, "session /x", "group /x", p) }}, - {"getTestingPhone", "GET", "/messaging/testing/project%20%2Fx/numbers/session%20%2Fx/phone", "", go41Phone, 200, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { - return m.Testing().GetPhone(c, "project /x", "session /x", p) + {"getTestingPhone", "GET", "/messaging/session%20%2Fx/phone", "", go41Phone, 200, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { + return m.Testing().GetPhone(c, "session /x", p) }}, - {"sendTestingPhoneMessage", "POST", "/messaging/testing/project%20%2Fx/numbers/session%20%2Fx/phone/messages", `{"to":"+15550000002","text":"hello"}`, go41PhoneSend, 202, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { - return m.Testing().SendPhoneMessage(c, "project /x", "session /x", sdk.SendTestingPhoneMessageRequest{To: "+15550000002", Text: "hello"}, p) + {"sendTestingPhoneMessage", "POST", "/messaging/session%20%2Fx/phone/messages", `{"to":"+15550000002","text":"hello"}`, go41PhoneSend, 202, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { + return m.Testing().SendPhoneMessage(c, "session /x", sdk.SendTestingPhoneMessageRequest{To: "+15550000002", Text: "hello"}, p) }}, - {"unlinkTestingPhoneDevice", "POST", "/messaging/testing/project%20%2Fx/numbers/session%20%2Fx/phone/devices/99/unlink", "", go41Unlink, 200, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { - return m.Testing().UnlinkPhoneDevice(c, "project /x", "session /x", 99, p) + {"unlinkTestingPhoneDevice", "POST", "/messaging/session%20%2Fx/phone/devices/99/unlink", "", go41Unlink, 200, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { + return m.Testing().UnlinkPhoneDevice(c, "session /x", 99, p) }}, {"listProjectRequestLogs", "GET", "/platform/projects/project%20%2Fx/request-logs", "", go41Logs, 200, true, func(c context.Context, m *sdk.MessagingClient, o *sdk.OrganizationClient, p sdk.RequestOptions) (any, error) { return o.RequestLogs().List(c, sdk.ListRequestLogsParams{ProjectID: "project /x"}, p) diff --git a/packages/go/supplements.json b/packages/go/supplements.json index f34b1886..6ae144db 100644 --- a/packages/go/supplements.json +++ b/packages/go/supplements.json @@ -40,21 +40,21 @@ { "sdkMethod": "MessagingClient.Testing.GetPhone", "method": "GET", - "fixturePath": "/messaging/testing/project/numbers/support/phone", + "fixturePath": "/messaging/support/phone", "status": "covered", "testFile": "supplements_test.go" }, { "sdkMethod": "MessagingClient.Testing.SendPhoneMessage", "method": "POST", - "fixturePath": "/messaging/testing/project/numbers/support/phone/messages", + "fixturePath": "/messaging/support/phone/messages", "status": "covered", "testFile": "supplements_test.go" }, { "sdkMethod": "MessagingClient.Testing.UnlinkPhoneDevice", "method": "POST", - "fixturePath": "/messaging/testing/project/numbers/support/phone/devices/1/unlink", + "fixturePath": "/messaging/support/phone/devices/1/unlink", "status": "covered", "testFile": "supplements_test.go" }, diff --git a/packages/go/supplements_test.go b/packages/go/supplements_test.go index 4476543a..7131f0e9 100644 --- a/packages/go/supplements_test.go +++ b/packages/go/supplements_test.go @@ -24,14 +24,14 @@ var supplementFixtures = []operationFixture{ {"MessagingClient.FlowEncryption.Register", "POST", "/graph/whatsapp/v26.0/phone/whatsapp_business_encryption", "", `{"business_public_key":"public-key"}`, `{"success":true}`, "success", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { return wireData(m.FlowEncryption().Register(ctx, "phone", RegisterFlowEncryptionKeyRequest{BusinessPublicKey: "public-key"}, CloudGraphVersion{Version: "v26.0"})) }}, - {"MessagingClient.Testing.GetPhone", "GET", "/messaging/testing/project/numbers/support/phone", "", "", `{"session":"support","phone":"+15551234567","online":false,"devices":[{"deviceId":1}]}`, "devices.0.deviceId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { - return wireData(m.Testing().GetPhone(ctx, "project", "support")) + {"MessagingClient.Testing.GetPhone", "GET", "/messaging/support/phone", "", "", `{"session":"support","phone":"+15551234567","online":false,"devices":[{"deviceId":1}]}`, "devices.0.deviceId", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + return wireData(m.Testing().GetPhone(ctx, "support")) }}, - {"MessagingClient.Testing.SendPhoneMessage", "POST", "/messaging/testing/project/numbers/support/phone/messages", "", `{"to":"+15551234567","text":"Hello"}`, `{"session":"support","to":"+15551234567","messageId":null}`, "to", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { - return wireData(m.Testing().SendPhoneMessage(ctx, "project", "support", SendTestingPhoneMessageRequest{To: "+15551234567", Text: "Hello"})) + {"MessagingClient.Testing.SendPhoneMessage", "POST", "/messaging/support/phone/messages", "", `{"to":"+15551234567","text":"Hello"}`, `{"session":"support","to":"+15551234567","messageId":null}`, "to", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + return wireData(m.Testing().SendPhoneMessage(ctx, "support", SendTestingPhoneMessageRequest{To: "+15551234567", Text: "Hello"})) }}, - {"MessagingClient.Testing.UnlinkPhoneDevice", "POST", "/messaging/testing/project/numbers/support/phone/devices/1/unlink", "", "", `{"session":"support","deviceId":1,"unlinked":true}`, "unlinked", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { - return wireData(m.Testing().UnlinkPhoneDevice(ctx, "project", "support", 1)) + {"MessagingClient.Testing.UnlinkPhoneDevice", "POST", "/messaging/support/phone/devices/1/unlink", "", "", `{"session":"support","deviceId":1,"unlinked":true}`, "unlinked", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { + return wireData(m.Testing().UnlinkPhoneDevice(ctx, "support", 1)) }}, {"MessagingClient.Campaigns.Update", "PATCH", "/messaging/projects/project/campaigns/campaign", "", `{"name":"New","scheduledAt":null,"sendWindow":null}`, campaignFixture, "data.name", func(ctx context.Context, m *MessagingClient, o *OrganizationClient, p *ProjectClient) (any, error) { name := "New" diff --git a/packages/go/testing.go b/packages/go/testing.go index 81c1c2bf..a7169069 100644 --- a/packages/go/testing.go +++ b/packages/go/testing.go @@ -42,10 +42,9 @@ type TestEventOverrides struct { Reason string `json:"reason,omitempty"` } type TriggerTestEventRequest struct { - Session string `json:"session"` - Event string `json:"event"` - Overrides *TestEventOverrides `json:"overrides,omitempty"` - FromSession string `json:"fromSession,omitempty"` + Event string `json:"event"` + Overrides *TestEventOverrides `json:"overrides,omitempty"` + FromNumber string `json:"fromNumber,omitempty"` } type TriggerTestEventResult struct { Event string `json:"event"` @@ -94,17 +93,17 @@ func (r *Testing) CreateHistoryFixture(ctx context.Context, project string, mess } return request[struct { FixtureID string `json:"fixtureId"` - }](ctx, r.t, "POST", "/messaging/testing/"+escaped(project)+"/history-fixtures", nil, struct { + }](ctx, r.t, "POST", "/messaging/projects/"+escaped(project)+"/test-history-fixtures", nil, struct { Messages []TestingHistoryMessage `json:"messages"` }{messages}, options(o)) } -func (r *Testing) TriggerEvent(ctx context.Context, project string, b TriggerTestEventRequest, o ...RequestOptions) (Response[TriggerTestEventResult], error) { +func (r *Testing) TriggerEvent(ctx context.Context, session string, b TriggerTestEventRequest, o ...RequestOptions) (Response[TriggerTestEventResult], error) { if err := serverOnly(r.t); err != nil { return Response[TriggerTestEventResult]{}, err } - return request[TriggerTestEventResult](ctx, r.t, "POST", "/messaging/testing/"+escaped(project)+"/events", nil, b, options(o)) + return request[TriggerTestEventResult](ctx, r.t, "POST", "/messaging/"+escaped(session)+"/test-events", nil, b, options(o)) } -func (r *Testing) ListEventFixtures(ctx context.Context, project string, o ...RequestOptions) (Response[struct { +func (r *Testing) ListEventFixtures(ctx context.Context, o ...RequestOptions) (Response[struct { Fixtures []TestEventFixtureInfo `json:"fixtures"` }], error) { if err := serverOnly(r.t); err != nil { @@ -114,31 +113,31 @@ func (r *Testing) ListEventFixtures(ctx context.Context, project string, o ...Re } return request[struct { Fixtures []TestEventFixtureInfo `json:"fixtures"` - }](ctx, r.t, "GET", "/messaging/testing/"+escaped(project)+"/events/fixtures", nil, nil, options(o)) + }](ctx, r.t, "GET", "/messaging/test-events/fixtures", nil, nil, options(o)) } -func phonePath(project, session string) string { - return "/messaging/testing/" + escaped(project) + "/numbers/" + escaped(session) + "/phone" +func phonePath(session string) string { + return "/messaging/" + escaped(session) + "/phone" } -func (r *Testing) GetPhone(ctx context.Context, project, session string, o ...RequestOptions) (Response[TestingPhone], error) { +func (r *Testing) GetPhone(ctx context.Context, session string, o ...RequestOptions) (Response[TestingPhone], error) { if err := serverOnly(r.t); err != nil { return Response[TestingPhone]{}, err } - return endpointBare[TestingPhone](ctx, r.t, "GET", phonePath(project, session), nil, nil, options(o), 200) + return endpointBare[TestingPhone](ctx, r.t, "GET", phonePath(session), nil, nil, options(o), 200) } -func (r *Testing) SendPhoneMessage(ctx context.Context, project, session string, b SendTestingPhoneMessageRequest, o ...RequestOptions) (Response[SendTestingPhoneMessageResult], error) { +func (r *Testing) SendPhoneMessage(ctx context.Context, session string, b SendTestingPhoneMessageRequest, o ...RequestOptions) (Response[SendTestingPhoneMessageResult], error) { if err := serverOnly(r.t); err != nil { return Response[SendTestingPhoneMessageResult]{}, err } - return endpointBare[SendTestingPhoneMessageResult](ctx, r.t, "POST", phonePath(project, session)+"/messages", nil, b, appendOnce(options(o)), 202) + return endpointBare[SendTestingPhoneMessageResult](ctx, r.t, "POST", phonePath(session)+"/messages", nil, b, appendOnce(options(o)), 202) } -func (r *Testing) UnlinkPhoneDevice(ctx context.Context, project, session string, id int, o ...RequestOptions) (Response[UnlinkTestingPhoneDeviceResult], error) { +func (r *Testing) UnlinkPhoneDevice(ctx context.Context, session string, id int, o ...RequestOptions) (Response[UnlinkTestingPhoneDeviceResult], error) { if err := serverOnly(r.t); err != nil { return Response[UnlinkTestingPhoneDeviceResult]{}, err } if id < 1 || id > 99 { return Response[UnlinkTestingPhoneDeviceResult]{}, validation("deviceId must be between 1 and 99.") } - return endpointBare[UnlinkTestingPhoneDeviceResult](ctx, r.t, "POST", phonePath(project, session)+"/devices/"+strconv.Itoa(id)+"/unlink", nil, nil, options(o), 200) + return endpointBare[UnlinkTestingPhoneDeviceResult](ctx, r.t, "POST", phonePath(session)+"/devices/"+strconv.Itoa(id)+"/unlink", nil, nil, options(o), 200) } type EmbeddedSignupResult struct { diff --git a/packages/php/README.md b/packages/php/README.md index 0ca1c39b..f3717cd6 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -1040,3 +1040,14 @@ $state = $client->passkeys->getConfirmation($session); // Invoke only after an explicit caller decision based on the current state. $confirmExplicitly = fn () => $client->passkeys->confirm($session, $suppliedConfirmation); ``` + +## Number-scoped Test operations + +The Test phone methods take a Number ID, without a project argument: +~~~php +$phone = $client->testing->getPhone('support'); +$event = $client->testing->triggerEvent('support', ['event' => 'call.missed', 'fromNumber' => 'sales']); +$fixtures = $client->testing->listEventFixtures(); +~~~ + +Send-phone-message and unlink-device helpers use the same Number scope. The trigger body contains event, optional overrides and optional fromNumber; it does not contain session or fromSession. The static fixture catalog has no project argument. History-fixture creation remains project-scoped. These operations require a server credential and the API’s Test-number access checks; a helper does not grant access or make a real Number a Test number. diff --git a/packages/php/coverage.json b/packages/php/coverage.json index 4811c5ba..d7e5e5eb 100644 --- a/packages/php/coverage.json +++ b/packages/php/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceSdkCommit": "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "sourceSdkCommit": "a7dd02cf1655e386642b9401358b725de4903a60", "operations": [ { "family": "messaging", @@ -717,7 +717,7 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "status": "covered", "sdkMethod": "MessagingClient::testing.listEventFixtures", "testFile": "tests/onboarding.php" @@ -1333,7 +1333,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "status": "covered", "sdkMethod": "MessagingClient::testing.triggerEvent", "testFile": "tests/onboarding.php" @@ -1341,7 +1341,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "status": "covered", "sdkMethod": "MessagingClient::testing.createHistoryFixture", "testFile": "tests/onboarding.php" @@ -4633,6 +4633,30 @@ "status": "excluded", "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority.", "milestone": "excluded-contract" + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "status": "covered", + "sdkMethod": "MessagingClient::testing.getPhone", + "testFile": "tests/onboarding.php" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "status": "covered", + "sdkMethod": "MessagingClient::testing.unlinkPhoneDevice", + "testFile": "tests/onboarding.php" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "status": "covered", + "sdkMethod": "MessagingClient::testing.sendPhoneMessage", + "testFile": "tests/onboarding.php" } ], "supplements": { diff --git a/packages/php/src/OnboardingModels.php b/packages/php/src/OnboardingModels.php index 88827338..0a6690d5 100644 --- a/packages/php/src/OnboardingModels.php +++ b/packages/php/src/OnboardingModels.php @@ -17,7 +17,7 @@ * @phpstan-type HistoryMessage array{id:string,senderPhone:string,text:string,timestamp:int|float,fromMe:bool} * @phpstan-type Fixture 'message.received'|'message.ack'|'message.failed'|'call.received'|'call.missed'|'call.ended'|'session.status'|'session.restriction_updated'|'template.status' * @phpstan-type Overrides array{text?:string,from?:string,pushName?:string,mediaType?:'image'|'video'|'audio'|'document'|'sticker',caption?:string,ackStatus?:'delivered'|'read'|'played'|'error',messageId?:string,failureReason?:'invalid_recipient'|'session_not_connected'|'ack_timeout'|'send_failed'|'blocked_by_safety',video?:bool,durationSeconds?:int|float,callEndReason?:'user_hangup'|'timeout'|'lost_connection'|'rejected'|'call_restricted',restrictionActive?:bool,status?:'CONNECTING'|'CONNECTED'|'DISCONNECTED',statusReason?:'SCAN_QR'|'AUTO_RECONNECT'|'FAILED'|'MANUAL_STOP'|'QR_TIMEOUT'|'LOGGED_OUT'|'TEMPORARY_BAN'|'STREAM_ERROR',templateName?:string,templateStatus?:'APPROVED'|'REJECTED',reason?:string} - * @phpstan-type Trigger array{session:string,event:Fixture,overrides?:Overrides,fromSession?:string} + * @phpstan-type Trigger array{event:Fixture,overrides?:Overrides,fromNumber?:string} * @phpstan-type TriggerResult array{event:Fixture,session:string,delivery:'generated'|'simulated',eventId:?string,source:'test'|'runtime'} * @phpstan-type Phone array{session:string,phone:string,online:bool,devices:list} */ diff --git a/packages/php/src/Resources/Testing.php b/packages/php/src/Resources/Testing.php index bd1dd171..764f68fa 100644 --- a/packages/php/src/Resources/Testing.php +++ b/packages/php/src/Resources/Testing.php @@ -20,48 +20,44 @@ final class Testing extends Resource public function createHistoryFixture(string $projectId, array $input, ?RequestOptions $options = null): ApiResponse { $this->server(); - /** @var ApiResponse */ return $this->request('POST', $this->base($projectId).'/history-fixtures', $input, options:$options); + /** @var ApiResponse */ return $this->request('POST', '/messaging/projects/'.self::segment($projectId).'/test-history-fixtures', $input, options:$options); } /** @param Trigger $input * @return ApiResponse */ - public function triggerEvent(string $projectId, array $input, ?RequestOptions $options = null): ApiResponse + public function triggerEvent(string $session, array $input, ?RequestOptions $options = null): ApiResponse { $this->server(); - /** @var ApiResponse */ return $this->request('POST', $this->base($projectId).'/events', $input, options:$options); + /** @var ApiResponse */ return $this->request('POST', '/messaging/'.self::segment($session).'/test-events', $input, options:$options); } /** @return ApiResponse}>}> */ - public function listEventFixtures(string $projectId, ?RequestOptions $options = null): ApiResponse + public function listEventFixtures(?RequestOptions $options = null): ApiResponse { $this->server(); - /** @var ApiResponse}>}> */ return $this->request('GET', $this->base($projectId).'/events/fixtures', options:$options); + /** @var ApiResponse}>}> */ return $this->request('GET', '/messaging/test-events/fixtures', options:$options); } /** @return ApiResponse */ - public function getPhone(string $projectId, string $session, ?RequestOptions $options = null): ApiResponse + public function getPhone(string $session, ?RequestOptions $options = null): ApiResponse { $this->server(); - /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'GET', $this->phone($projectId, $session), null, $options, 'testingPhone'); + /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'GET', $this->phone($session), null, $options, 'testingPhone'); } /** @param array{to:string,text:string} $input * @return ApiResponse */ - public function sendPhoneMessage(string $projectId, string $session, array $input, ?RequestOptions $options = null): ApiResponse + public function sendPhoneMessage(string $session, array $input, ?RequestOptions $options = null): ApiResponse { $this->server(); - /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'POST', $this->phone($projectId, $session).'/messages', $input, $options, 'testingSend', 202); + /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'POST', $this->phone($session).'/messages', $input, $options, 'testingSend', 202); } /** @return ApiResponse */ - public function unlinkPhoneDevice(string $projectId, string $session, int $deviceId, ?RequestOptions $options = null): ApiResponse + public function unlinkPhoneDevice(string $session, int $deviceId, ?RequestOptions $options = null): ApiResponse { $this->server(); if ($deviceId < 1 || $deviceId > 99) { throw new ValidationException('deviceId must be a companion device ID from 1 to 99.'); - } /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'POST', $this->phone($projectId, $session).'/devices/'.$deviceId.'/unlink', null, $options, 'testingUnlink'); + } /** @var ApiResponse */ return \Polymorfa\ExistingContract::bare($this->transport, 'POST', $this->phone($session).'/devices/'.$deviceId.'/unlink', null, $options, 'testingUnlink'); } - private function base(string $projectId): string + private function phone(string $session): string { - return '/messaging/testing/'.self::segment($projectId); - } - private function phone(string $projectId, string $session): string - { - return $this->base($projectId).'/numbers/'.self::segment($session).'/phone'; + return '/messaging/'.self::segment($session).'/phone'; } } diff --git a/packages/php/tests/existing_contract_edges.php b/packages/php/tests/existing_contract_edges.php index e7cd127a..075d042d 100644 --- a/packages/php/tests/existing_contract_edges.php +++ b/packages/php/tests/existing_contract_edges.php @@ -47,13 +47,13 @@ $ecNoIO(fn () => $ecMessaging->historySync->requestChunkRetry('s', $bad)); } foreach ([0,100] as $device) { - $ecNoIO(fn () => $ecMessaging->testing->unlinkPhoneDevice($ecProject, 's', $device), Polymorfa\ValidationException::class); + $ecNoIO(fn () => $ecMessaging->testing->unlinkPhoneDevice('s', $device), Polymorfa\ValidationException::class); } $ecNoIO(fn () => $ecOrg->requestLogs->list()); $ecNoIO(fn () => $ecA->requestLogs->list(['projectId' => $ecBProject]), Polymorfa\ValidationException::class); $ecNoIO(fn () => $ecA->requestLogs->list(['cursor' => 'next','traceId' => 'trace']), Polymorfa\ValidationException::class); $ecBrowser = new Polymorfa\MessagingClient($ecPrincipals[2], baseUrl:$ecUrl); -foreach ([fn () => $ecBrowser->testing->getPhone($ecProject, 's'),fn () => $ecBrowser->testing->sendPhoneMessage($ecProject, 's', ['to' => 'x','text' => 'y']),fn () => $ecBrowser->testing->unlinkPhoneDevice($ecProject, 's', 1),fn () => $ecBrowser->historySync->requestChunkRetry('s', ['syncType' => 'FULL','chunkOrder' => 0]),fn () => $ecBrowser->historySync->deleteChunk('s', '/file', str_repeat('h', 32)),fn () => $ecBrowser->whatsappMedia->createUploadGrant('s', 'image', str_repeat('h', 32)),fn () => $ecBrowser->whatsappMedia->uploadTransport('direct', new Polymorfa\UploadWhatsAppMediaOptions('image'))] as $invoke) { +foreach ([fn () => $ecBrowser->testing->getPhone('s'),fn () => $ecBrowser->testing->sendPhoneMessage('s', ['to' => 'x','text' => 'y']),fn () => $ecBrowser->testing->unlinkPhoneDevice('s', 1),fn () => $ecBrowser->historySync->requestChunkRetry('s', ['syncType' => 'FULL','chunkOrder' => 0]),fn () => $ecBrowser->historySync->deleteChunk('s', '/file', str_repeat('h', 32)),fn () => $ecBrowser->whatsappMedia->createUploadGrant('s', 'image', str_repeat('h', 32)),fn () => $ecBrowser->whatsappMedia->uploadTransport('direct', new Polymorfa\UploadWhatsAppMediaOptions('image'))] as $invoke) { $ecNoIO($invoke); } $opens = 0; diff --git a/packages/php/tests/existing_contract_nine.php b/packages/php/tests/existing_contract_nine.php index 255c01ac..79940108 100644 --- a/packages/php/tests/existing_contract_nine.php +++ b/packages/php/tests/existing_contract_nine.php @@ -14,12 +14,12 @@ ecCheck($r->data['data']['capabilities'][0]['value'] === null && $r->data['data']['capabilities'][1]['value'] === false, 'Group nullable capability and Client support'); } $ecPhone = ['session' => 's','phone' => '+15555555555','online' => false,'devices' => [['deviceId' => 1],['deviceId' => 99]]]; -$ecTestingPath = '/messaging/testing/'.$ecProject.'/numbers/s/phone'; +$ecTestingPath = '/messaging/s/phone'; $ecNine = [ ['getGroupCapabilities','GET','/messaging/s/groups/g/capabilities',null,['success' => true,'data' => $ecCapabilities],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->groups->getCapabilities('s', 'g', $o),200,'2026-09-22'], - ['getTestingPhone','GET',$ecTestingPath,null,$ecPhone,fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->getPhone($ecProject, 's', $o),200,'2026-09-22'], - ['sendTestingPhoneMessage','POST',$ecTestingPath.'/messages',['to' => '+15555555555','text' => 'hello'],['session' => 's','to' => '+15555555555','messageId' => null],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->sendPhoneMessage($ecProject, 's', ['to' => '+15555555555','text' => 'hello'], $o),202,'2026-09-22'], - ['unlinkTestingPhoneDevice','POST',$ecTestingPath.'/devices/99/unlink',null,['session' => 's','deviceId' => 99,'unlinked' => true],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->unlinkPhoneDevice($ecProject, 's', 99, $o),200,'2026-09-22'], + ['getTestingPhone','GET',$ecTestingPath,null,$ecPhone,fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->getPhone('s', $o),200,'2026-09-22'], + ['sendTestingPhoneMessage','POST',$ecTestingPath.'/messages',['to' => '+15555555555','text' => 'hello'],['session' => 's','to' => '+15555555555','messageId' => null],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->sendPhoneMessage('s', ['to' => '+15555555555','text' => 'hello'], $o),202,'2026-09-22'], + ['unlinkTestingPhoneDevice','POST',$ecTestingPath.'/devices/99/unlink',null,['session' => 's','deviceId' => 99,'unlinked' => true],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->testing->unlinkPhoneDevice('s', 99, $o),200,'2026-09-22'], ['retryHistoryChunk','POST','/messaging/s/history/chunks/retry',['syncType' => 'FULL','chunkOrder' => 0,'regenerate' => false],['success' => true,'data' => ['requestId' => 'native-retry','status' => 'requested']],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->historySync->requestChunkRetry('s', ['syncType' => 'FULL','chunkOrder' => 0,'regenerate' => false], $o),200,'2026-10-14'], ['deleteHistoryMedia','POST','/messaging/s/history/media/delete',['directPath' => '/mms/history/file','fileEncSha256' => '//////////////////////////////////////////8=','encHandle' => 'handle','companionMetaNonce' => 'nonce'],['success' => true,'data' => ['status' => 'deleted']],fn (?Polymorfa\RequestOptions $o = null) => $ecMessaging->historySync->deleteChunk('s', '/mms/history/file', str_repeat("\xff", 32), 'handle', 'nonce', $o),200,'2026-10-14'], ]; diff --git a/packages/php/tests/existing_contract_types.php b/packages/php/tests/existing_contract_types.php index e3acbe39..4bb19fa5 100644 --- a/packages/php/tests/existing_contract_types.php +++ b/packages/php/tests/existing_contract_types.php @@ -39,9 +39,9 @@ public static function publicInputs(Polymorfa\Client $org, Polymorfa\MessagingCl self::delivery($config['effective']['historySync']['delivery']); } $messages->groups->getCapabilities('s', 'g')->data['data']['capabilities']; - $messages->testing->getPhone($project, 's')->data['online']; - $messages->testing->sendPhoneMessage($project, 's', ['to' => '+15555555555','text' => 'hello'])->data['messageId']; - $messages->testing->unlinkPhoneDevice($project, 's', 1)->data['unlinked']; + $messages->testing->getPhone('s')->data['online']; + $messages->testing->sendPhoneMessage('s', ['to' => '+15555555555','text' => 'hello'])->data['messageId']; + $messages->testing->unlinkPhoneDevice('s', 1)->data['unlinked']; $org->project($project)->requestLogs->list()->items; $messages->whatsappMedia->relayUpload('s', new Polymorfa\EncryptedMediaInfo('image', str_repeat('k', 32), str_repeat('h', 32), str_repeat('e', 32), 1, 26), static fn (?Polymorfa\CancellationToken $c): Psr\Http\Message\StreamInterface => GuzzleHttp\Psr7\Utils::streamFor('cipher')); $messages->whatsappMedia->createUploadGrant('s', 'sticker', str_repeat('h', 32)); diff --git a/packages/php/tests/onboarding.php b/packages/php/tests/onboarding.php index 1aef0c57..42bea22e 100644 --- a/packages/php/tests/onboarding.php +++ b/packages/php/tests/onboarding.php @@ -17,14 +17,14 @@ ['GET','/messaging/projects/project/observation-policy',null,$envelope($projectPolicy),fn ($c) => $c->observationPolicies->retrieveForProject('project')], ['GET','/messaging/session/observation-policy',null,$envelope($sessionPolicy),fn ($c) => $c->observationPolicies->retrieveForSession('session')], ['POST','/messaging/cloud-api/embedded-signup',['quicklinkId' => 'link','result' => ['code' => 'synthetic_fixture','wabaId' => 'waba','phoneNumberId' => 'phone','coexistence' => false]],$envelope(['stage' => 'complete']),fn ($c) => $c->cloudOnboarding->advance(['quicklinkId' => 'link','result' => ['code' => 'synthetic_fixture','wabaId' => 'waba','phoneNumberId' => 'phone','coexistence' => false]])], - ['POST','/messaging/testing/project/history-fixtures',['messages' => [['id' => 'message','senderPhone' => '+15555555555','text' => 'Fixture','timestamp' => 1,'fromMe' => false]]],['fixtureId' => 'fixture'],fn ($c) => $c->testing->createHistoryFixture('project', ['messages' => [['id' => 'message','senderPhone' => '+15555555555','text' => 'Fixture','timestamp' => 1,'fromMe' => false]]])], - ['POST','/messaging/testing/project/events',['session' => 'session','event' => 'message.received','fromSession' => 'sender','overrides' => ['text' => 'Fixture']],$trigger,fn ($c) => $c->testing->triggerEvent('project', ['session' => 'session','event' => 'message.received','fromSession' => 'sender','overrides' => ['text' => 'Fixture']])], - ['GET','/messaging/testing/project/events/fixtures',null,$fixtures,fn ($c) => $c->testing->listEventFixtures('project')], - ['GET','/messaging/testing/project/numbers/session/phone',null,['session' => 'session','phone' => '+15555555555','online' => false,'devices' => [['deviceId' => 1]]],fn ($c) => $c->testing->getPhone('project', 'session')], - ['POST','/messaging/testing/project/numbers/session/phone/messages',['to' => '+15555555555','text' => 'Fixture'],['session' => 'session','to' => '+15555555555','messageId' => null],fn ($c) => $c->testing->sendPhoneMessage('project', 'session', ['to' => '+15555555555','text' => 'Fixture']),null,['status' => 202]], - ['POST','/messaging/testing/project/numbers/session/phone/devices/1/unlink',null,['session' => 'session','deviceId' => 1,'unlinked' => true],fn ($c) => $c->testing->unlinkPhoneDevice('project', 'session', 1)], + ['POST','/messaging/projects/project/test-history-fixtures',['messages' => [['id' => 'message','senderPhone' => '+15555555555','text' => 'Fixture','timestamp' => 1,'fromMe' => false]]],['fixtureId' => 'fixture'],fn ($c) => $c->testing->createHistoryFixture('project', ['messages' => [['id' => 'message','senderPhone' => '+15555555555','text' => 'Fixture','timestamp' => 1,'fromMe' => false]]])], + ['POST','/messaging/session/test-events',['event' => 'message.received','fromNumber' => 'sender','overrides' => ['text' => 'Fixture']],$trigger,fn ($c) => $c->testing->triggerEvent('session', ['event' => 'message.received','fromNumber' => 'sender','overrides' => ['text' => 'Fixture']])], + ['GET','/messaging/test-events/fixtures',null,$fixtures,fn ($c) => $c->testing->listEventFixtures()], + ['GET','/messaging/session/phone',null,['session' => 'session','phone' => '+15555555555','online' => false,'devices' => [['deviceId' => 1]]],fn ($c) => $c->testing->getPhone('session')], + ['POST','/messaging/session/phone/messages',['to' => '+15555555555','text' => 'Fixture'],['session' => 'session','to' => '+15555555555','messageId' => null],fn ($c) => $c->testing->sendPhoneMessage('session', ['to' => '+15555555555','text' => 'Fixture']),null,['status' => 202]], + ['POST','/messaging/session/phone/devices/1/unlink',null,['session' => 'session','deviceId' => 1,'unlinked' => true],fn ($c) => $c->testing->unlinkPhoneDevice('session', 1)], ], fn ($credential, $url) => new Polymorfa\MessagingClient($credential, baseUrl:$url)); -raises(fn () => (new Polymorfa\MessagingClient($credential))->testing->unlinkPhoneDevice('project', 'session', 0), Polymorfa\ValidationException::class); +raises(fn () => (new Polymorfa\MessagingClient($credential))->testing->unlinkPhoneDevice('session', 0), Polymorfa\ValidationException::class); $history = []; new Polymorfa\SystemClient(baseUrl:'http://api.fixture.invalid', http:mocked([], $history)); nativeCases([ diff --git a/packages/python/README.md b/packages/python/README.md index ee75fb9a..6d7e412f 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -910,3 +910,14 @@ async def confirm_explicitly(): # Call only after an explicit decision based on the current state. return await client.passkeys.confirm(session, supplied_confirmation) ``` + +## Number-scoped Test operations + +The Test phone methods take a Number ID, without a project argument: +~~~python +phone = await client.testing.get_phone("support") +event = await client.testing.trigger_event("support", {"event": "call.missed", "fromNumber": "sales"}) +fixtures = await client.testing.list_event_fixtures() +~~~ + +Send-phone-message and unlink-device helpers use the same Number scope. The trigger body contains event, optional overrides and optional fromNumber; it does not contain session or fromSession. The static fixture catalog has no project argument. History-fixture creation remains project-scoped. These operations require a server credential and the API’s Test-number access checks; a helper does not grant access or make a real Number a Test number. diff --git a/packages/python/coverage.json b/packages/python/coverage.json index c44bf566..596e16c1 100644 --- a/packages/python/coverage.json +++ b/packages/python/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceSdkCommit": "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "sourceSdkCommit": "a7dd02cf1655e386642b9401358b725de4903a60", "operations": [ { "family": "messaging", @@ -717,7 +717,7 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "status": "covered", "sdkMethod": "AsyncMessagingClient.testing.list_event_fixtures", "testFile": "tests/test_onboarding.py" @@ -1333,7 +1333,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "status": "covered", "sdkMethod": "AsyncMessagingClient.testing.trigger_event", "testFile": "tests/test_onboarding.py" @@ -1341,7 +1341,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "status": "covered", "sdkMethod": "AsyncMessagingClient.testing.create_history_fixture", "testFile": "tests/test_onboarding.py" @@ -4633,6 +4633,30 @@ "status": "excluded", "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority.", "milestone": "excluded-contract" + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "status": "covered", + "sdkMethod": "AsyncMessagingClient.testing.get_phone", + "testFile": "tests/test_onboarding.py" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "status": "covered", + "sdkMethod": "AsyncMessagingClient.testing.unlink_phone_device", + "testFile": "tests/test_onboarding.py" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "status": "covered", + "sdkMethod": "AsyncMessagingClient.testing.send_phone_message", + "testFile": "tests/test_onboarding.py" } ], "supplements": { diff --git a/packages/python/src/polymorfa/onboarding.py b/packages/python/src/polymorfa/onboarding.py index b690a555..fd6ecc2d 100644 --- a/packages/python/src/polymorfa/onboarding.py +++ b/packages/python/src/polymorfa/onboarding.py @@ -120,10 +120,9 @@ class EventOverrides(TypedDict, total=False): class TriggerEvent(TypedDict): - session: str event: TestEvent overrides: NotRequired[EventOverrides] - fromSession: NotRequired[str] + fromNumber: NotRequired[str] class TriggeredEvent(TypedDict): @@ -200,13 +199,13 @@ async def create_history_fixture( self._server() return await self._request( "POST", - f"/messaging/testing/{segment(project_id)}/history-fixtures", + f"/messaging/projects/{segment(project_id)}/test-history-fixtures", body=body, options=options, ) async def trigger_event( - self, project_id: str, body: TriggerEvent, *, options: RequestOptions = O + self, session: str, body: TriggerEvent, *, options: RequestOptions = O ) -> ApiResponse[TriggeredEvent]: self._server() wire: dict[str, object] = dict(body) @@ -216,31 +215,29 @@ async def trigger_event( overrides["from"] = overrides.pop("from_") wire["overrides"] = overrides return await self._request( - "POST", f"/messaging/testing/{segment(project_id)}/events", body=wire, options=options + "POST", f"/messaging/{segment(session)}/test-events", body=wire, options=options ) async def list_event_fixtures( - self, project_id: str, *, options: RequestOptions = O + self, *, options: RequestOptions = O ) -> ApiResponse[EventFixtures]: self._server() - return await self._request( - "GET", f"/messaging/testing/{segment(project_id)}/events/fixtures", options=options - ) + return await self._request("GET", "/messaging/test-events/fixtures", options=options) @acknowledged() async def get_phone( - self, project_id: str, session: str, *, options: RequestOptions = O + self, session: str, *, options: RequestOptions = O ) -> ApiResponse[TestingPhone]: self._server() return await self._request( "GET", - f"/messaging/testing/{segment(project_id)}/numbers/{segment(session)}/phone", + f"/messaging/{segment(session)}/phone", options=options, ) @acknowledged(202) async def send_phone_message( - self, project_id: str, session: str, body: PhoneMessage, *, options: RequestOptions = O + self, session: str, body: PhoneMessage, *, options: RequestOptions = O ) -> ApiResponse[PhoneMessageResult]: self._server() data = V.fields(body, ["to", "text"], ["to", "text"]) @@ -248,14 +245,14 @@ async def send_phone_message( V.text(data["text"], "text") return await self._request( "POST", - f"/messaging/testing/{segment(project_id)}/numbers/{segment(session)}/phone/messages", + f"/messaging/{segment(session)}/phone/messages", body=V.capture(body), options=options, ) @acknowledged() async def unlink_phone_device( - self, project_id: str, session: str, device_id: int, *, options: RequestOptions = O + self, session: str, device_id: int, *, options: RequestOptions = O ) -> ApiResponse[UnlinkedDevice]: self._server() if ( @@ -266,6 +263,6 @@ async def unlink_phone_device( raise ValidationError("device_id must be a companion device ID from 1 to 99.") return await self._request( "POST", - f"/messaging/testing/{segment(project_id)}/numbers/{segment(session)}/phone/devices/{device_id}/unlink", + f"/messaging/{segment(session)}/phone/devices/{device_id}/unlink", options=options, ) diff --git a/packages/python/tests/test_existing_contract_41.py b/packages/python/tests/test_existing_contract_41.py index 0eb0fb4c..836f0f4b 100644 --- a/packages/python/tests/test_existing_contract_41.py +++ b/packages/python/tests/test_existing_contract_41.py @@ -523,16 +523,14 @@ def respond(request, raw, count): ): o = RequestOptions(api_version=version) assert (await scoped.groups.get_capabilities("a/b", "g/1", options=o)).data["data"] == caps - assert (await c.testing.get_phone("p/1", "a/b", options=o)).data["online"] is False + assert (await c.testing.get_phone("a/b", options=o)).data["online"] is False assert ( await c.testing.send_phone_message( - "p/1", "a/b", {"to": "+15557654321", "text": "test"}, options=o + "a/b", {"to": "+15557654321", "text": "test"}, options=o ) ).data["messageId"] is None for device in (1, 99): - assert (await c.testing.unlink_phone_device("p/1", "a/b", device, options=o)).data[ - "unlinked" - ] + assert (await c.testing.unlink_phone_device("a/b", device, options=o)).data["unlinked"] for sync in ( "INITIAL_BOOTSTRAP", "INITIAL_STATUS_V3", @@ -560,7 +558,7 @@ def respond(request, raw, count): is None ) assert calls[0][1] == "/messaging/a%2Fb/groups/g%2F1/capabilities" - assert calls[1][1] == "/messaging/testing/p%2F1/numbers/a%2Fb/phone" + assert calls[1][1] == "/messaging/a%2Fb/phone" assert calls[1][3] == calls[3][3] == calls[4][3] == b"" assert json.loads(calls[2][3]) == {"to": "+15557654321", "text": "test"} assert [x[2]["polymorfa-version"] for x in calls[:5]] == [version or "2026-09-22"] * 5 @@ -568,7 +566,7 @@ def respond(request, raw, count): before = len(calls) for device in (0, 100, True): with pytest.raises(ValidationError): - await c.testing.unlink_phone_device("p", "s", device) + await c.testing.unlink_phone_device("s", device) for bad in ( {"chunkMessageId": None}, {"regenerate": None}, @@ -580,7 +578,7 @@ def respond(request, raw, count): "s", {"syncType": "RECENT", "chunkOrder": 0} | bad ) for action in ( - lambda: scoped.testing.get_phone("p", "s"), + lambda: scoped.testing.get_phone("s"), lambda: scoped.history_sync.request_chunk_retry( "s", {"syncType": "RECENT", "chunkOrder": 0} ), @@ -605,11 +603,11 @@ async def test_nine_malformed_success_is_safe(operation, bad): await ( c.groups.get_capabilities("s", "g") if operation == "group" - else c.testing.get_phone("p", "s") + else c.testing.get_phone("s") if operation == "phone" - else c.testing.send_phone_message("p", "s", {"to": "t", "text": "x"}) + else c.testing.send_phone_message("s", {"to": "t", "text": "x"}) if operation == "send" - else c.testing.unlink_phone_device("p", "s", 1) + else c.testing.unlink_phone_device("s", 1) if operation == "unlink" else c.history_sync.request_chunk_retry( "s", {"syncType": "RECENT", "chunkOrder": 0} @@ -933,7 +931,7 @@ async def test_no_key_uncertain_writes_are_once(operation): if operation == "audience" else c.audiences.add_members(ID, {"members": []}) if operation == "members" - else m.testing.send_phone_message("p", "s", {"to": "t", "text": "x"}) + else m.testing.send_phone_message("s", {"to": "t", "text": "x"}) ) assert len(calls) == 1 and "idempotency-key" not in calls[0][2] @@ -1009,11 +1007,11 @@ async def hold(request): action = ( m.groups.get_capabilities("s", "g") if operation == "group" - else m.testing.get_phone("p", "s") + else m.testing.get_phone("s") if operation == "phone" - else m.testing.send_phone_message("p", "s", {"to": "t", "text": "x"}) + else m.testing.send_phone_message("s", {"to": "t", "text": "x"}) if operation == "send" - else m.testing.unlink_phone_device("p", "s", 1) + else m.testing.unlink_phone_device("s", 1) if operation == "unlink" else c.request_logs.list(project_id=PROJECT) if operation == "logs" diff --git a/packages/python/tests/test_onboarding.py b/packages/python/tests/test_onboarding.py index ac0d3687..e5813689 100644 --- a/packages/python/tests/test_onboarding.py +++ b/packages/python/tests/test_onboarding.py @@ -44,16 +44,15 @@ ), ( "POST", - "/messaging/testing/project_1/history-fixtures", + "/messaging/projects/project_1/test-history-fixtures", HISTORY, {"fixtureId": "fixture_1"}, lambda c: c.testing.create_history_fixture("project_1", HISTORY), ), ( "POST", - "/messaging/testing/project_1/events", + "/messaging/support/test-events", { - "session": "support", "event": "message.received", "overrides": { "text": "Hello", @@ -71,9 +70,8 @@ "source": "test", }, lambda c: c.testing.trigger_event( - "project_1", + "support", { - "session": "support", "event": "message.received", "overrides": { "text": "Hello", @@ -87,8 +85,8 @@ ), ( "POST", - "/messaging/testing/project_1/events", - {"session": "support", "event": "message.received", "fromSession": "sales"}, + "/messaging/support/test-events", + {"event": "message.received", "fromNumber": "sales"}, { "event": "message.received", "session": "support", @@ -97,12 +95,12 @@ "source": "runtime", }, lambda c: c.testing.trigger_event( - "project_1", {"session": "support", "event": "message.received", "fromSession": "sales"} + "support", {"event": "message.received", "fromNumber": "sales"} ), ), ( "GET", - "/messaging/testing/project_1/events/fixtures", + "/messaging/test-events/fixtures", None, { "fixtures": [ @@ -114,11 +112,11 @@ for name in TEST_EVENT_FIXTURES ] }, - lambda c: c.testing.list_event_fixtures("project_1"), + lambda c: c.testing.list_event_fixtures(), ), ( "GET", - "/messaging/testing/project_1/numbers/support/phone", + "/messaging/support/phone", None, { "session": "support", @@ -126,23 +124,21 @@ "online": True, "devices": [{"deviceId": 1}, {"deviceId": 2}], }, - lambda c: c.testing.get_phone("project_1", "support"), + lambda c: c.testing.get_phone("support"), ), ( "POST", - "/messaging/testing/project_1/numbers/support/phone/messages", + "/messaging/support/phone/messages", {"to": "+15551234568", "text": "Hello"}, {"session": "support", "to": "+15551234568", "messageId": None}, - lambda c: c.testing.send_phone_message( - "project_1", "support", {"to": "+15551234568", "text": "Hello"} - ), + lambda c: c.testing.send_phone_message("support", {"to": "+15551234568", "text": "Hello"}), ), ( "POST", - "/messaging/testing/project_1/numbers/support/phone/devices/2/unlink", + "/messaging/support/phone/devices/2/unlink", None, {"session": "support", "deviceId": 2, "unlinked": True}, - lambda c: c.testing.unlink_phone_device("project_1", "support", 2), + lambda c: c.testing.unlink_phone_device("support", 2), ), ] @@ -183,7 +179,7 @@ async def test_unlink_companion_bounds_and_keyed_fixture_retry(): ) as client: for device_id in [0, 100, True, 1.5]: with pytest.raises(ValidationError): - await client.testing.unlink_phone_device("project_1", "support", device_id) + await client.testing.unlink_phone_device("support", device_id) requests = [] def handler(request): @@ -214,8 +210,8 @@ def handler(request): http_transport=httpx.MockTransport(handler), ) as client: result = await client.testing.trigger_event( - "project_1", - {"session": "support", "event": "call.missed"}, + "support", + {"event": "call.missed"}, options=RequestOptions(idempotency_key="event-retry", max_network_retries=1), ) assert result.data["eventId"] == "event_2" and result.metadata.attempts == 2 diff --git a/packages/rust/README.md b/packages/rust/README.md index 3abed547..c288ecb3 100644 --- a/packages/rust/README.md +++ b/packages/rust/README.md @@ -992,3 +992,18 @@ async fn confirm_explicitly(client: &polymorfa_sdk::MessagingClient, session: &s # Ok(()) # } ``` + +## Number-scoped Test operations + +The Test phone methods take a Number ID, without a project argument: +~~~rust +let phone = client.testing().get_phone("support", RequestOptions::default()).await?; +let event = client.testing().trigger_event("support", &TriggerTestEventRequest { + event: TestEventFixture::CallMissed, + overrides: None, + from_number: Some("sales".into()), +}, RequestOptions::default()).await?; +let fixtures = client.testing().list_event_fixtures(RequestOptions::default()).await?; +~~~ + +Send-phone-message and unlink-device helpers use the same Number scope. The trigger body contains event, optional overrides and optional fromNumber; it does not contain session or fromSession. The static fixture catalog has no project argument. History-fixture creation remains project-scoped. These operations require a server credential and the API’s Test-number access checks; a helper does not grant access or make a real Number a Test number. diff --git a/packages/rust/coverage.json b/packages/rust/coverage.json index 264c2d4f..2582013c 100644 --- a/packages/rust/coverage.json +++ b/packages/rust/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceSdkCommit": "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "sourceSdkCommit": "a7dd02cf1655e386642b9401358b725de4903a60", "operations": [ { "family": "messaging", @@ -717,7 +717,7 @@ { "family": "messaging", "method": "GET", - "path": "/messaging/testing/{projectId}/events/fixtures", + "path": "/messaging/test-events/fixtures", "status": "covered", "sdkMethod": "MessagingClient.testing.list_event_fixtures", "testFile": "tests/onboarding.rs" @@ -1333,7 +1333,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/events", + "path": "/messaging/{session}/test-events", "status": "covered", "sdkMethod": "MessagingClient.testing.trigger_event", "testFile": "tests/onboarding.rs" @@ -1341,7 +1341,7 @@ { "family": "messaging", "method": "POST", - "path": "/messaging/testing/{projectId}/history-fixtures", + "path": "/messaging/projects/{projectId}/test-history-fixtures", "status": "covered", "sdkMethod": "MessagingClient.testing.create_history_fixture", "testFile": "tests/onboarding.rs" @@ -4633,6 +4633,30 @@ "status": "excluded", "reason": "Dashboard-only operational error vitals require a live Console identity; server API keys do not grant this authority.", "milestone": "not-server-sdk" + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/phone", + "status": "covered", + "sdkMethod": "MessagingClient.testing.get_phone", + "testFile": "tests/onboarding.rs" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/devices/{deviceId}/unlink", + "status": "covered", + "sdkMethod": "MessagingClient.testing.unlink_phone_device", + "testFile": "tests/onboarding.rs" + }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/phone/messages", + "status": "covered", + "sdkMethod": "MessagingClient.testing.send_phone_message", + "testFile": "tests/onboarding.rs" } ], "supplements": { diff --git a/packages/rust/src/onboarding.rs b/packages/rust/src/onboarding.rs index 685e521d..f6e1f4ce 100644 --- a/packages/rust/src/onboarding.rs +++ b/packages/rust/src/onboarding.rs @@ -69,12 +69,11 @@ overrides!(text:String,from:String,push_name:String,media_type:TestMediaType,cap #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TriggerTestEventRequest { - pub session: String, pub event: TestEventFixture, #[serde(skip_serializing_if = "Option::is_none")] pub overrides: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub from_session: Option, + pub from_number: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -159,14 +158,10 @@ impl CloudOnboarding<'_> { } } fn project_path(project: &str) -> String { - format!("/messaging/testing/{}", encode(project)) + format!("/messaging/projects/{}", encode(project)) } -fn phone_path(project: &str, session: &str) -> String { - format!( - "{}/numbers/{}/phone", - project_path(project), - encode(session) - ) +fn phone_path(session: &str) -> String { + format!("/messaging/{}/phone", encode(session)) } impl Testing<'_> { pub async fn create_history_fixture( @@ -179,7 +174,7 @@ impl Testing<'_> { self.0 .request( Method::POST, - &format!("{}/history-fixtures", project_path(project)), + &format!("{}/test-history-fixtures", project_path(project)), &[], Some(body), options, @@ -188,7 +183,7 @@ impl Testing<'_> { } pub async fn trigger_event( &self, - project: &str, + session: &str, body: &TriggerTestEventRequest, options: RequestOptions, ) -> Result> { @@ -196,7 +191,7 @@ impl Testing<'_> { self.0 .request( Method::POST, - &format!("{}/events", project_path(project)), + &format!("/messaging/{}/test-events", encode(session)), &[], Some(body), options, @@ -205,30 +200,22 @@ impl Testing<'_> { } pub async fn list_event_fixtures( &self, - project: &str, options: RequestOptions, ) -> Result> { self.0.server()?; - self.0 - .get( - &format!("{}/events/fixtures", project_path(project)), - options, - ) - .await + self.0.get("/messaging/test-events/fixtures", options).await } pub async fn get_phone( &self, - project: &str, session: &str, options: RequestOptions, ) -> Result> { self.0.server()?; - let response = self.0.get(&phone_path(project, session), options).await?; + let response = self.0.get(&phone_path(session), options).await?; phone_response(response, 200, true) } pub async fn send_phone_message( &self, - project: &str, session: &str, body: &SendTestingPhoneMessageRequest, options: RequestOptions, @@ -238,7 +225,7 @@ impl Testing<'_> { .0 .request( Method::POST, - &format!("{}/messages", phone_path(project, session)), + &format!("{}/messages", phone_path(session)), &[], Some(body), options, @@ -248,7 +235,6 @@ impl Testing<'_> { } pub async fn unlink_phone_device( &self, - project: &str, session: &str, device: u8, options: RequestOptions, @@ -265,7 +251,7 @@ impl Testing<'_> { .0 .request::<_, ()>( Method::POST, - &format!("{}/devices/{device}/unlink", phone_path(project, session)), + &format!("{}/devices/{device}/unlink", phone_path(session)), &[], None, options, diff --git a/packages/rust/tests/adoption41.rs b/packages/rust/tests/adoption41.rs index ed2ff20a..78417be4 100644 --- a/packages/rust/tests/adoption41.rs +++ b/packages/rust/tests/adoption41.rs @@ -1287,7 +1287,7 @@ async fn group_testing_and_request_log_actual_public_seams_keep_principals_and_e let m = support::messaging(wire.base.clone()); let phone = m .testing() - .get_phone("p /", "s /", Default::default()) + .get_phone("s /", Default::default()) .await .unwrap(); assert!(!phone.data.online); @@ -1295,7 +1295,6 @@ async fn group_testing_and_request_log_actual_public_seams_keep_principals_and_e let sent = m .testing() .send_phone_message( - "p /", "s /", &SendTestingPhoneMessageRequest { to: "+15550002222".into(), @@ -1313,7 +1312,7 @@ async fn group_testing_and_request_log_actual_public_seams_keep_principals_and_e for device in [1, 99] { assert!( m.testing() - .unlink_phone_device("p /", "s /", device, Default::default()) + .unlink_phone_device("s /", device, Default::default()) .await .unwrap() .data @@ -1330,27 +1329,26 @@ async fn group_testing_and_request_log_actual_public_seams_keep_principals_and_e wire.check( 1, "GET", - "/messaging/testing/p%20%2F/numbers/s%20%2F/phone", + "/messaging/s%20%2F/phone", json!(null), "2026-09-22", ); wire.check( 2, "POST", - "/messaging/testing/p%20%2F/numbers/s%20%2F/phone/messages", + "/messaging/s%20%2F/phone/messages", json!({"to":"+15550002222","text":"Synthetic test"}), "2026-10-14", ); assert!(!wire.captured()[2].headers.contains_key("idempotency-key")); assert!(scoped .testing() - .get_phone("p", "s", Default::default()) + .get_phone("s", Default::default()) .await .is_err()); assert!(scoped .testing() .send_phone_message( - "p", "s", &SendTestingPhoneMessageRequest { to: "x".into(), @@ -1362,14 +1360,14 @@ async fn group_testing_and_request_log_actual_public_seams_keep_principals_and_e .is_err()); assert!(scoped .testing() - .unlink_phone_device("p", "s", 1, Default::default()) + .unlink_phone_device("s", 1, Default::default()) .await .is_err()); assert_eq!(wire.captured().len(), 5); for device in [0, 100] { assert_eq!( m.testing() - .unlink_phone_device("p", "s", device, Default::default()) + .unlink_phone_device("s", device, Default::default()) .await .unwrap_err() .kind, @@ -1448,7 +1446,7 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { let w = Wire::raw(vec![(200, "fixture".into(), vec![])]).await; let e = support::messaging(w.base.clone()) .testing() - .get_phone("p", "s", Default::default()) + .get_phone("s", Default::default()) .await .unwrap_err(); assert_eq!(e.code.as_deref(), Some("invalid_response")); @@ -1462,13 +1460,12 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { let error = match kind { 0 => m .testing() - .get_phone("p", "s", Default::default()) + .get_phone("s", Default::default()) .await .unwrap_err(), 1 => m .testing() .send_phone_message( - "p", "s", &SendTestingPhoneMessageRequest { to: "x".into(), @@ -1480,7 +1477,7 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { .unwrap_err(), _ => m .testing() - .unlink_phone_device("p", "s", 1, Default::default()) + .unlink_phone_device("s", 1, Default::default()) .await .unwrap_err(), }; @@ -1507,7 +1504,7 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { ); assert_eq!( m.testing() - .get_phone("p", "s", options.clone()) + .get_phone("s", options.clone()) .await .unwrap_err() .kind, @@ -1516,7 +1513,6 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { assert_eq!( m.testing() .send_phone_message( - "p", "s", &SendTestingPhoneMessageRequest { to: "x".into(), @@ -1531,7 +1527,7 @@ async fn public_seam_malformed_errors_and_cancel_are_not_successes() { ); assert_eq!( m.testing() - .unlink_phone_device("p", "s", 1, options.clone()) + .unlink_phone_device("s", 1, options.clone()) .await .unwrap_err() .kind, @@ -1799,7 +1795,6 @@ async fn regression41_testing_message_requires_its_nullable_id() { assert!(support::messaging(w.base.clone()) .testing() .send_phone_message( - "p", "s", &polymorfa_sdk::onboarding::SendTestingPhoneMessageRequest { to: "phone".into(), @@ -1819,7 +1814,7 @@ async fn regression41_unlink_requires_the_acknowledged_true_outcome() { .await; assert!(support::messaging(w.base.clone()) .testing() - .unlink_phone_device("p", "s", 1, Default::default()) + .unlink_phone_device("s", 1, Default::default()) .await .is_err()); } diff --git a/packages/rust/tests/onboarding.rs b/packages/rust/tests/onboarding.rs index c98f748a..09d995d1 100644 --- a/packages/rust/tests/onboarding.rs +++ b/packages/rust/tests/onboarding.rs @@ -30,7 +30,7 @@ async fn embedded_signup_and_simulated_event_history_resources_native_wire() { wire_messaging!( client, "POST", - "/messaging/testing/project/history-fixtures", + "/messaging/projects/project/test-history-fixtures", Some( json!({"messages":[{"id":"message","senderPhone":"+15551234567","text":"History","timestamp":1000,"fromMe":false}]}) ), @@ -52,15 +52,14 @@ async fn embedded_signup_and_simulated_event_history_resources_native_wire() { wire_messaging!( client, "POST", - "/messaging/testing/project/events", + "/messaging/support/test-events", Some( - json!({"session":"support","event":"message.received","overrides":{"text":"Hello","mediaType":"image","caption":"Caption"},"fromSession":"other"}) + json!({"event":"message.received","overrides":{"text":"Hello","mediaType":"image","caption":"Caption"},"fromNumber":"other"}) ), json!({"event":"message.received","session":"support","delivery":"simulated","eventId":null,"source":"runtime"}), client.testing().trigger_event( - "project", + "support", &TriggerTestEventRequest { - session: "support".into(), event: TestEventFixture::MessageReceived, overrides: Some(TestEventOverrides { text: Some("Hello".into()), @@ -68,7 +67,7 @@ async fn embedded_signup_and_simulated_event_history_resources_native_wire() { caption: Some("Caption".into()), ..Default::default() }), - from_session: Some("other".into()) + from_number: Some("other".into()) }, RequestOptions::default() ) @@ -76,12 +75,12 @@ async fn embedded_signup_and_simulated_event_history_resources_native_wire() { wire_messaging!( client, "GET", - "/messaging/testing/project/events/fixtures", + "/messaging/test-events/fixtures", None, json!({"fixtures":[{"name":"message.received","description":"Inbound message","overrides":["text","from","mediaType","caption"]},{"name":"session.status","description":"Status","overrides":["status","statusReason"]}]}), client .testing() - .list_event_fixtures("project", RequestOptions::default()) + .list_event_fixtures(RequestOptions::default()) ); } #[tokio::test] @@ -89,22 +88,21 @@ async fn simulated_phone_supplemental_native_wire_and_invalid_device_guard() { wire_messaging!( client, "GET", - "/messaging/testing/project/numbers/support/phone", + "/messaging/support/phone", None, json!({"session":"support","phone":"+15551234567","online":true,"devices":[{"deviceId":1},{"deviceId":2}]}), client .testing() - .get_phone("project", "support", RequestOptions::default()) + .get_phone("support", RequestOptions::default()) ); wire_messaging_status!( 202, client, "POST", - "/messaging/testing/project/numbers/support/phone/messages", + "/messaging/support/phone/messages", Some(json!({"to":"+15551234568","text":"Hello"})), json!({"session":"support","to":"+15551234568","messageId":"wamessage"}), client.testing().send_phone_message( - "project", "support", &SendTestingPhoneMessageRequest { to: "+15551234568".into(), @@ -116,19 +114,19 @@ async fn simulated_phone_supplemental_native_wire_and_invalid_device_guard() { wire_messaging!( client, "POST", - "/messaging/testing/project/numbers/support/phone/devices/2/unlink", + "/messaging/support/phone/devices/2/unlink", None, json!({"session":"support","deviceId":2,"unlinked":true}), client .testing() - .unlink_phone_device("project", "support", 2, RequestOptions::default()) + .unlink_phone_device("support", 2, RequestOptions::default()) ); let client = support::messaging("http://127.0.0.1:1".into()); for device in [0, 100] { assert_eq!( client .testing() - .unlink_phone_device("project", "support", device, RequestOptions::default()) + .unlink_phone_device("support", device, RequestOptions::default()) .await .unwrap_err() .kind, diff --git a/packages/typescript/README.md b/packages/typescript/README.md index ad93da86..b382ce7f 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -3028,486 +3028,26 @@ project can trigger 30 test events per minute (`PolymorfaRateLimitError`). ```ts import { TEST_EVENT_FIXTURES } from "@polymorfa/sdk"; -const result = await messaging.testing.triggerEvent(projectId, { - session: "my-test-number", +const result = await messaging.testing.triggerEvent("my-test-number", { event: "message.received", // one of TEST_EVENT_FIXTURES overrides: { text: "hi", from: "+15550100001" }, }); console.log(result.data.eventId); // Rare events: failed delivery, ban warning, incoming call, template rejection. -await messaging.testing.triggerEvent(projectId, { - session: "my-test-number", +await messaging.testing.triggerEvent("my-test-number", { event: "template.status", overrides: { templateStatus: "REJECTED", reason: "INVALID_FORMAT" }, }); -const { data } = await messaging.testing.listEventFixtures(projectId); +const { data } = await messaging.testing.listEventFixtures(); ``` -Set `fromSession` on a `message.received` request to send a simulated text +Set `fromNumber` on a `message.received` request to send a simulated text from another connected Test number in the same project instead; the response has `delivery: "simulated"` and the event arrives as ordinary Test number -activity. Both methods require an team key or project token with -`sandbox:write` (trigger) or `sandbox:read` (list) and Test numbers access. - -Use `session.restriction_updated` with `{ restrictionActive: false }` to -test a restriction ending, or `true` to test one starting. The `call.ended` -fixture accepts `callEndReason: "call_restricted"` for a restricted call. - -Pass `{ idempotencyKey }` as the third argument to `triggerEvent` to retry -safely. Repeating the request with the same key and body reuses the same event -ID, so a retry after an uncertain response never creates a second event or -duplicate webhook deliveries. With a key, the SDK also retries network and -5xx failures. - -### Test number phone - -Each Test number has a simulated phone that acts as its main device. Use it -to see which companions are linked, to send a message as the phone, or to -unlink a companion as a person would from Linked devices. - -```ts -const { data: phone } = await messaging.testing.getPhone( - projectId, - "my-test-number", -); -console.log(phone.online, phone.devices); // [{ deviceId: 33 }, ...] - -// Sent over the simulated network to another connected Test number in the -// same project. The sender's companions receive the sent copy. -await messaging.testing.sendPhoneMessage(projectId, "my-test-number", { - to: "+15550100002", - text: "hello from the phone", -}); - -await messaging.testing.unlinkPhoneDevice(projectId, "my-test-number", 74); -``` - -`getPhone` needs `sandbox:read`; `sendPhoneMessage` and `unlinkPhoneDevice` -need `sandbox:write`. All three need an team key or project token -and Test numbers access. Real numbers, an offline phone and recipients outside -the project are refused with a `PolymorfaValidationError`. Phone messages share -the 30-per-minute project limit with test events. Device 0 is the phone and -cannot be unlinked. - -Trusted servers continue an issued Meta Cloud API invitation with -`messaging.cloudOnboarding.advance({ quicklinkId, projectId, result })`. -`result` contains the Embedded Signup authorization code, selected WABA and phone -IDs, and Coexistence/history choices. This method does not create a session or -accept Meta app secrets. Its progress response is not proof that messaging is -ready; inspect the QuickLink status. - -## Contract update notes - -The typed webhook catalog includes `session.restriction_updated` with -`type`, `active`, `enforcementType`, `expiresAt`, and `observedAt`. -`session.logged_out` requires a numeric `code` and a `reason` of `banned`, -`device_removed`, or `unknown`. Test event requests support the restriction -fixture with `restrictionActive` and the call-end reason `call_restricted`. - -`Client.callRetention` covers the team call-retention settings. `Client.calls` -covers the three public call analytics and export operations. `Client.voice` -covers the Voice audio and credential operations. `Client.callPolicy` and -`Client.callOptOuts` cover consent controls. The contract snapshot is pinned -to API Hybrid Link source commit `2dd0c1563b1fc4e6525f708afc12e0685110cfe0`, -now merged into API `dev` by PR #260. - -## Functions - -Use a project client with `functions:read`, `functions:manage` or -`functions:invoke`, according to the operation. Your organization must be enabled -for Functions and the selected execution region must be available. Client tokens -cannot access this control plane. - -```ts -const functions = client.project(projectId).functions; -const created = await functions.create({ name: "Order lookup" }); -const deployed = await functions.deployments.create(created.data.id, { - deploymentId: crypto.randomUUID(), - language: "typescript", - region: configuredRegion, - compatibilityDate: "2026-09-22", - source: `export default { - handler() { return Response.json({ status: "ok" }); } - };`, - egressOrigins: [], - secretVersionIds: [], -}); -const result = await functions.invocations.create( - created.data.id, - { - deploymentId: deployed.data.id, - trigger: "test", - request: { - method: "POST", - url: "https://function.polymorfa.invalid/test", - headers: {}, - bodyBase64: "e30=", - }, - }, - { idempotencyKey: crypto.randomUUID() }, -); -``` - -`deployments.promote` selects the default deployment and requires -`expectedRevision`. `update` and `delete` also require the current revision. -`secrets.create` returns version metadata only; pin its ID in a new deployment. -`secrets.revoke` prevents subsequent invocations from using that version. -`deployments.list` returns deployment metadata without source code. Use -`deployments.retrieve` with a deployment ID to read its source. - -Mutations and invocations have no automatic network retries. Keep the original -idempotency key when checking an interrupted invocation. Replays return a receipt -without the original response. An `unknown` outcome can mean an external effect -occurred; reconcile it before choosing a new key. Request/response bodies and -customer log output are not retained. List responses contain `items` and -`nextCursor`; pass that cursor as `before` to read the next page. - -## WhatsApp business analytics - -Source builds expose `client.analytics.get({ projectId, sessionId, start, end })`. -Requires `sessions:read`; project-bound clients cannot read another project. -A team owner or admin enables Analytics in the Console. Disabled results contain -no business aggregates. - -```ts -const { data } = await client.analytics.get({ sessionId: numberId }); -console.log(data.summary?.engagement.replyRate); -console.log(data.summary?.calls.answerRate); -console.log(data.summary?.calls.followUp); -console.log(data.callSeries); -``` - -Messaging results include completed 24-hour receipt/reply cohorts, response -queues, coarse metadata comparisons and observed phone/customer activity. -Calls include outcomes, direction breakdowns, measured pickup and length timings, -reported quality, failure categories and completed missed-call follow-up windows. -Call results do not require messaging telemetry. Preserve `null` measurements; -missing reports do not imply healthy quality. Disabling Analytics clears message -and engagement aggregates and hides the call view; operational call history is -retained separately. [Measurement definitions](https://docs.polymorfa.com/console/analytics). -These source-build methods have not been published in a package release. - -## Analytics metrics export - -Source builds expose `client.analytics.metrics` on root and project clients: - -```ts -const { data, metadata } = await client.analytics.metrics({ - windowHours: 24, - format: "openmetrics", - segments: true, -}); -``` - -`data` is the metrics text; `metadata` retains HTTP status, request ID and headers. -The method validates UUID filters and the 1–168-hour window. It uses `sessions:read` -and the same Analytics opt-in. All metrics are gauges over completed UTC hours; -never apply `rate()` or `increase()`. Disabled exports contain enablement and window -metadata only. [Collector setup and definitions](https://docs.polymorfa.com/console/analytics-collectors). - -## Spending limits and funding order - -```ts -const limits = await platform.billing.getLimits({ projectId }); -const number = limits.data.data.budgets.find((b) => b.scope === "number")!; -await platform.billing.setLimit("number", number.resourceId, { - limitCredits: 1000, - expectedRevision: number.revision, -}); -const priorities = await platform.billing.getPriorities(); -await platform.billing.reorderPriorities({ - scope: "project", - resourceIds: priorities.data.data.projects.map((p) => p.id).reverse(), - expectedRevision: priorities.data.data.revision, -}); -``` - -`setPriority(scope, resourceId, {priority, expectedRevision})` changes one rank. -For number order use `scope: "number"`, `projectId` and all its real number UUIDs. -Orders are complete and highest first; saves are atomic and stale revisions -return a conflict. Limits reset monthly at 00:00 UTC on the first day. Null -removes a cap; zero blocks new charges. Paid windows remain intact. These -methods do not grant permissions, paid access or deployed availability. -Publication of this source revision is separate from API deployment. - -For one customer or number, read and save both controls without loading the -organization directory: - -```typescript -const { - data: { data: current }, -} = await platform.billing.getResourceControls("customer", customerId); -await platform.billing.setResourceControls("customer", customerId, { - limitCredits: 1500, - priority: 25, - expectedBudgetRevision: current.budget.revision, - expectedPriorityRevision: current.priorityRevision, -}); -``` - -Both revisions are required; a conflict leaves both settings unchanged. Project -priority takes precedence. Numbers inherit customer priority; a higher number -priority overrides it within the project. A customer's assigned numbers share -its monthly cap. `getLimits({scope:"project"})` and -`getPriorities({scope:"project"})` avoid downloading number/customer directories. -Real initial QuickLink creation accepts `billingControls:{limitCredits:250, -priority:30}` before pairing admission, with team `billing:manage` authority. -Testing and supplementary connections reject these fields. Package publication -and API deployment remain separate gates. - -Read one project's funding controls and save its combined customer and number -order: - -```typescript -const { - data: { data: controls }, -} = await platform.billing.getPriorities({ projectId }); -const resources = [ - ...controls.customers.map((customer) => ({ - scope: "customer" as const, - resourceId: customer.id, - priority: customer.priority, - })), - ...controls.numbers.map((number) => ({ - scope: "number" as const, - resourceId: number.id, - priority: number.priority, - })), -].sort((first, second) => second.priority - first.priority); -await platform.billing.reorderPriorities({ - scope: "resource", - projectId, - resources: resources.map(({ scope, resourceId }) => ({ scope, resourceId })), - expectedRevision: controls.revision, -}); -``` - -Pass every active customer and real number, highest priority first. Duplicate, -incomplete, or stale orders fail without partial changes. This operation never -bypasses a spending cap. - -### Group capabilities (enrolled beta) - -`messaging.groups.getCapabilities(session, groupId)` reads the linked number's -stored group configuration. It requires `groups:read`, current group membership, -and enrollment in the number-capabilities beta. `unknown` returns null values -until a successful group configuration sync for the current linked account. -The read does not open the chat or synchronize configuration. - -The three feature keys are `polls.endTime`, `polls.hideVoters`, and -`polls.creatorEdit`. Each combines the number's gate with the group gate. They -describe WhatsApp configuration; they do not grant permission to edit polls. - -`session.capabilities_updated` carries `SessionCapabilitiesUpdatedPayload`: -the canonical number capability view and `changedKeys`. Routine checks and -configuration changes that do not change capabilities produce no event. Treat -the event ID as the deduplication key. Eligibility is checked before publication -and delivery; withdrawn events wait for re-enablement and expire after seven days. -These additions require an API and SDK release containing this contract. - -### Customer-owned media and history (development contract) - -The media upload helpers and `@polymorfa/sdk/history` require the 2026-10-14 API -contract. Their presence in a development build does not establish deployed -availability. `messaging.whatsappMedia.upload()` encrypts media locally and -supports encrypted relay or a short-lived direct upload grant. The direct path -preserves the exact grant URL and keeps API credentials off the media host. -`toWhatsAppMediaSendDescriptor()` constructs the standard message descriptor; -standard descriptor sending includes the media key in the API request. Use the -customer-owned Signal service for message stanza encryption under customer key -custody. - -`downloadHistoryChunk()` verifies, decrypts, inflates and decodes chunks locally; -inline notifications skip the download. Set `maxBytes` and `maxInflatedBytes` -for application limits. Protobuf integer values and unknown fields survive -processing. The helper does not download a notification automatically. - -History decoding accepts exactly one gzip, zlib or raw-deflate stream on Node.js -20 and later, browsers and Workers. It validates wrapper checksums and rejects -truncated streams, dictionaries, concatenated streams and trailing bytes, -including zero padding. It uses the pinned pako runtime dependency and does not -require `DecompressionStream`. Keep the MIT and Zlib notices from -`THIRD_PARTY_NOTICES` when bundling the history entry point. - -`maxBytes` defaults to 256 MiB for downloads and inline payloads; -`maxInflatedBytes` defaults to 512 MiB. Both limits must be positive safe -integers. Inflation uses input windows of at most 8 KiB and output windows of -at most 64 KiB. It snapshots compressed input before yielding. Joining the -output temporarily needs another output-sized allocation; these ceilings do -not promise a small memory footprint. - -Pass `signal` to `downloadHistoryChunk()` to cancel downloads and local decoding, -including inline payloads. Cancellation is checked between bounded calls and -output copies, with task yields after at most 32 continuing calls or copies. -It cannot interrupt a synchronous call or protobuf parsing and has no hard -latency guarantee. `decodeHistorySyncPayload()` retains its existing options -without a signal. Invalid compressed data reports `media_invalid_ciphertext`, -size overflow reports `media_too_large` and download cancellation reports -`request_cancelled`. Internal dependency or allocation failures remain distinct. -No decoded chunk is returned before full compressed-stream validation. - -After verifying and storing a chunk, `messaging.historySync.deleteChunk()` asks -the linked device to delete its CDN blob. Its `fileEncSha256` is the encrypted -chunk's 32-byte SHA-256 encoded as standard base64, including the trailing `=`. -In Node.js, use `Buffer.from(digest).toString("base64")`. The upload grant's -base64url hash representation does not apply to this deletion request. Deletion -does not remove your local copy and is never performed automatically by the -download helper. - -The Node-only `@polymorfa/sdk/s3` entry point provides `S3MediaStore`. Its -`archive()` verifies a private local spool before it writes the plaintext to -S3-compatible storage, then removes the spool on every outcome. Use -`scratchDirectory` to select that private directory. `uploadToWhatsApp()` reads -an object twice with a matching ETag; both passes must see the same object. -Install the optional `@aws-sdk/client-s3` peer for S3 and `@bufbuild/protobuf` -for history decoding. Never log media keys or upload-grant URLs. - -### Storing projected history descriptors - -With `@polymorfa/storage`, use `decodeHistoryDescriptor` for projected media or -inline notifications. `decodeHistory` handles archives only. The descriptor -callback must return a non-null storage projection; authorized download, -full-schema decoding and custody remain application responsibilities. The default -`historyDescriptorMode: "decode"` refuses undecoded descriptors before persistence, -checkpoint or ACK. Explicit `"metadata-only"` skips decoders and consumes progress -and deduplication state, so ordinary replay cannot import content later. See the -storage package README for the callback example, native fallback identities and -checkpoint/ACK ordering. Packages remain unpublished by this change. - -## Campaign contact frequency caps (beta) - -`client.campaigns.getFrequencyCap()` reads team limits and their revision. -`client.campaigns.updateFrequencyCap({ maxPer24Hours: 2, maxPer7Days: 5, revision })` -replaces both windows. Send both limits as null to remove them. A stale revision -raises `PolymorfaConflictError`; read and reconcile before trying again. A new -limit requires beta access and can return `403 feature_unavailable`. -Campaign create/update accepts `frequencyCap`; null removes a campaign cap. -Recipient reads expose `failureReason` and accept `reason: "frequency_cap"`. -Analytics adds `frequencyCap.skippedRecipientCount`. Raising limits never retries -settled skips. `contracts/campaign-frequency-cap.json` pins the merged API source; -package publication and API availability remain separate release gates. - -### Audience segments and drip sequences (source support, publication pending) - -`platform.audienceSegments` supports `list`, `retrieve`, `create`, `update`, -`delete`, `preview` (saved rules) and `previewRules` (unsaved rules). Updates -and deletion require the observed `expectedRevision`. Team API keys require -`campaigns:read` for reads and saved previews, or `campaigns:manage` for writes -and unsaved previews. Project tokens cannot manage segments. Both audience -betas must be available for create, update and previews (`403 feature_unavailable`). -Missing authority returns `403 permission_denied`. Choose a segment with -`platform.campaigns.create({ projectId, name, segmentId })`; launch freezes -`segmentSnapshot` (revision, evaluation time, matched count). A deleted segment -cannot relaunch an already launched campaign (`409 state_conflict`). A bounded -segment evaluation timeout also returns `409 state_conflict`: simplify the -rules and retry. Revision conflicts require a fresh read and reconciliation. -No mutation is automatically retried by the segment resource. - -Both campaign APIs accept nullable `drip` create settings and a partial drip -PATCH. Omitted PATCH fields preserve stored values. To close continuous enrollment, -patch only `{ drip: { enrollment: "audience" } }`; unchanged exit flags are -preserved, including after beta withdrawal. `enrollmentOpen` and `pauseReason` -are nullable read fields; enrollment additionally requires running/paused state. -`campaigns.enroll` posts one phone and optional variables; a previously enrolled -phone returns `created: false`, including contacts who finished or exited. -Closed or non-drip enrollment returns `409 state_conflict`. - -Analytics exposes `drip.steps` counters. Recipient `sequenceError` includes -`replied`, `link_clicked`, `opted_out` and open-ended send failure codes. -Later messages accept a step `sendWindow` with one to seven weekdays and one -to four hour ranges. `delayAfterSec` is at most 2,592,000 for drips (86,400 for -ordinary sequences) and must be a whole multiple of `delayUnit` when supplied. -Tracked-link GETs, including scanner/preview requests, trigger enabled exits; -HEAD requests do not. Server beta, sender, consent, opt-out and funding checks -remain authoritative. These additions are not proof of registry availability. - -### Event-stream validation and limits - -Project event streams retain the previous resume cursor until the base64 payload -and webhook envelope validate. Malformed server data follows the existing -reconnect policy. A frame has a 4 MiB UTF-8 byte budget, including incomplete -lines, all `data:` lines, field syntax and comments. CRLF counts as one line -terminator; a blank line resets the budget. Oversized frames reconnect from the -previous validated cursor. Storage still commits the event, saves its checkpoint -with CAS, then manually acknowledges it. These changes are unreleased source -behavior. - -## Campaign webhook metadata (unreleased source) - -Automatic `campaign.paused` payloads can include open-string `reason` and -`code`; manual pauses omit both. `pausedAt` is Unix seconds. -`campaign.recipient_skipped` can include nonnegative `messageIndex` (including -zero) and a UUID `followUpId`. `skippedAt` is Unix milliseconds. Missing optional -fields remain absent. Do not infer a pause cause from an unknown future code. - -```ts -// event is a verified WebhookEvent from constructWebhookDelivery. -if (event.event === "campaign.paused") - console.log(event.payload.code, event.payload.pausedAt); // seconds -if (event.event === "campaign.recipient_skipped") - console.log( - event.payload.messageIndex, - event.payload.followUpId, - event.payload.skippedAt, - ); // milliseconds -``` - -These are verified-event handling fragments, not complete receivers. Preserve -whole-body signature verification before decoding and deduplicate batch members. -See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). -Package publication and matching API environment acceptance remain separate gates. - -### Call settings exclusion end (Unreleased) - -Call-settings GET and PUT responses include the read-only -`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. -While `simultaneousCalls` is on, null means the exclusion has no end yet. -When it is off, a future timestamp is 30 days after the latest period ended. -A ban is excluded when the setting was on at any time in the 30 days before it, -so until that timestamp a new ban is not refunded. At that timestamp the earlier -period no longer excludes a ban. Null while off -means no recent period was reported. Neither null nor an elapsed timestamp -guarantees coverage; all other Ban Insurance conditions still apply. - -The server calculates this value. Do not send it in an update or calculate it -from the current switch alone. This addition is unreleased; source preparation -does not establish API deployment or package publication. - -## Direct passkey relay (unreleased) - -Use server credentials with `sessions:manage` for every operation, including reads. -The API also checks live Number ownership, direct-pairing access, relay enrollment -and the current attempt. These methods accept ceremony data supplied by an -external participant. They do not run WebAuthn, store credentials, poll, or confirm -a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. - -Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server -owns ceremony options and ignores the deprecated `userVerification` and -`timeoutMs` request fields. An optional expected `challengeB64Url` must match the -current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; -do not convert between base64 and base64url. Omit optional fields when absent. - -Challenge issuance, assertion submission and explicit confirmation each make one -attempt, even with caller retry options or an idempotency key. If the outcome is -uncertain, read current challenge/confirmation state and reconcile it before a new -write. Assertion acceptance is not pairing completion. Forwarded API failures keep -their status and open error code; the SDK does not invent passkey error codes. - -The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 -units and optional user handles allow 86. Other opaque assertion fields share the -body limit. The API validates encodings, decoded bounds and challenge binding. - -The matching API source is accepted. Package qualification and publication are -still required before using a released package. - -```ts -const ticket = await client.passkeys.issueChallenge(session); -await client.passkeys.getChallenge(session, ticket.data.data.ticket); -await client.passkeys.submitAssertion(session, suppliedAssertion); -const state = await client.passkeys.getConfirmation(session); -// Invoke only after the caller verifies the current state and makes its decision. -const confirmExplicitly = () => - client.passkeys.confirm(session, suppliedConfirmation); -``` +need `sandbox:write`. All three need a team key or project token +and Test numbers access. Real Numbers return `404 resource_not_found` +(`PolymorfaNotFoundError`). An offline phone and invalid recipients return +`400` (`PolymorfaValidationError`); a Test key hides unknown, real, and +other-project recipients with `404 resource_not_found`. Phone messages share diff --git a/packages/typescript/src/messaging/onboarding.ts b/packages/typescript/src/messaging/onboarding.ts index f13543c8..46885d5d 100644 --- a/packages/typescript/src/messaging/onboarding.ts +++ b/packages/typescript/src/messaging/onboarding.ts @@ -67,7 +67,7 @@ export class TestingResource { assertServerCredential(this.credentialType); return this.transport.request({ method: "POST", - path: `/messaging/testing/${encodeURIComponent(projectId)}/history-fixtures`, + path: `/messaging/projects/${encodeURIComponent(projectId)}/test-history-fixtures`, body: input, ...options, }); @@ -75,22 +75,22 @@ export class TestingResource { /** * Fire a named, signed test event for a Test number. Generated events reach - * webhooks and event history with `source: "test"`. With `fromSession`, a + * webhooks and event history with `source: "test"`. With `fromNumber`, a * `message.received` request sends a simulated message from another Test - * number instead. Real numbers are refused with a 400 error. Pass + * number instead. Real numbers are refused with a 404 resource_not_found error. Pass * `options.idempotencyKey` to make retries safe: repeating the request with * the same key and body reuses the same event ID, and the SDK then retries * network and 5xx failures. */ triggerEvent( - projectId: string, + session: string, input: TriggerTestEventRequest, options: RequestOptions = {}, ): Promise> { assertServerCredential(this.credentialType); return this.transport.request({ method: "POST", - path: `/messaging/testing/${encodeURIComponent(projectId)}/events`, + path: `/messaging/${encodeURIComponent(session)}/test-events`, body: input, ...options, }); @@ -98,23 +98,21 @@ export class TestingResource { /** List test-event fixtures and the overrides each accepts. */ listEventFixtures( - projectId: string, options: RequestOptions = {}, ): Promise> { assertServerCredential(this.credentialType); return this.transport.request({ method: "GET", - path: `/messaging/testing/${encodeURIComponent(projectId)}/events/fixtures`, + path: `/messaging/test-events/fixtures`, ...options, }); } /** * Read a Test number's simulated phone: whether it is online and which - * companions are linked to it. Real numbers are refused with a 400 error. + * companions are linked to it. Real numbers are refused with a 404 resource_not_found error. */ getPhone( - projectId: string, session: string, options: RequestOptions = {}, ): Promise> { @@ -123,7 +121,7 @@ export class TestingResource { this.transport, { method: "GET", - path: phonePath(projectId, session), + path: phonePath(session), ...existingOptions(options), }, "phone", @@ -138,7 +136,6 @@ export class TestingResource { * normal inbound message. Counts toward the Testing rate limit. */ sendPhoneMessage( - projectId: string, session: string, input: SendTestingPhoneMessageRequest, options: RequestOptions = {}, @@ -148,7 +145,7 @@ export class TestingResource { this.transport, { method: "POST", - path: `${phonePath(projectId, session)}/messages`, + path: `${phonePath(session)}/messages`, body: input, ...existingOptions(options), }, @@ -163,7 +160,6 @@ export class TestingResource { * cannot be unlinked. */ unlinkPhoneDevice( - projectId: string, session: string, deviceId: number, options: RequestOptions = {}, @@ -177,7 +173,7 @@ export class TestingResource { this.transport, { method: "POST", - path: `${phonePath(projectId, session)}/devices/${deviceId}/unlink`, + path: `${phonePath(session)}/devices/${deviceId}/unlink`, ...existingOptions(options), }, "unlink", @@ -185,8 +181,8 @@ export class TestingResource { ); } } -function phonePath(projectId: string, session: string): string { - return `/messaging/testing/${encodeURIComponent(projectId)}/numbers/${encodeURIComponent(session)}/phone`; +function phonePath(session: string): string { + return `/messaging/${encodeURIComponent(session)}/phone`; } function assertServerCredential(type: MessagingCredential["type"]): void { if (type === "clientToken") diff --git a/packages/typescript/src/messaging/testing-configuration.ts b/packages/typescript/src/messaging/testing-configuration.ts index 167da4bf..9234ef82 100644 --- a/packages/typescript/src/messaging/testing-configuration.ts +++ b/packages/typescript/src/messaging/testing-configuration.ts @@ -95,15 +95,13 @@ export interface TestEventOverrides { } export interface TriggerTestEventRequest { - /** Test number session name in the project. */ - session: string; event: TestEventFixture; overrides?: TestEventOverrides; /** * message.received only: deliver a real simulated message from this other * Test number in the same project instead of a generated event. */ - fromSession?: string; + fromNumber?: string; } export interface TriggerTestEventResponse { diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index fbb83a13..ba3620fc 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -211,6 +211,31 @@ describe("reconciled coverage evidence", () => { } }); + it("keeps the six Test-route overrides bound to their accepted source", () => { + const pin = JSON.parse( + readFileSync( + repositoryFile("contracts/test-number-dropin-routes.json"), + "utf8", + ), + ); + expect(pin.repository).toBe("polymorfa/polymorfa"); + expect(pin.commit).toBe("eda77760b56095a771c55e5d2df18be23e985377"); + const overrides = ledger.operations.filter( + (operation) => operation.sourceCommit === pin.commit, + ); + expect(overrides.map((operation) => operation.operationId).sort()).toEqual([ + "createTestingHistoryFixture", + "getTestingPhone", + "listTestEventFixtures", + "sendTestingPhoneMessage", + "triggerTestEvent", + "unlinkTestingPhoneDevice", + ]); + expect( + overrides.every((operation) => operation.typescript.status === "covered"), + ).toBe(true); + }); + it("pins Analytics to the same full Platform source and snapshot hash", () => { const source = JSON.parse( readFileSync(repositoryFile("contracts/source.json"), "utf8"), diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index 6e013cf7..de79f65e 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -174,8 +174,8 @@ describe("coverage checker", () => { expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ sourceCommit: "171a9683ff45d11a4b5d3917dc428ad2722b0d99", - total: 579, - covered: 442, + total: 582, + covered: 445, partial: 0, missing: 0, excluded: 137, diff --git a/packages/typescript/test/nine-41-public.test.ts b/packages/typescript/test/nine-41-public.test.ts index 3a208bac..871b5f3b 100644 --- a/packages/typescript/test/nine-41-public.test.ts +++ b/packages/typescript/test/nine-41-public.test.ts @@ -43,34 +43,28 @@ it("checks bare testing-phone results and actual202 through native HTTP", async credential: { type: "projectToken", value: PROJECT_TOKEN }, baseUrl: server.url, }); - expect((await m.testing.getPhone(projectId, "s/a")).data).toEqual( - fixtures[0], - ); + expect((await m.testing.getPhone("s/a")).data).toEqual(fixtures[0]); expect( ( - await m.testing.sendPhoneMessage(projectId, "s/a", { + await m.testing.sendPhoneMessage("s/a", { to: "+15551234568", text: "x", }) ).metadata.status, ).toBe(202); - expect( - (await m.testing.unlinkPhoneDevice(projectId, "s/a", 99)).data, - ).toEqual(fixtures[2]); + expect((await m.testing.unlinkPhoneDevice("s/a", 99)).data).toEqual( + fixtures[2], + ); expect(server.requests.map((r) => [r.method, r.path, r.body])).toEqual([ - ["GET", `/messaging/testing/${projectId}/numbers/s%2Fa/phone`, ""], + ["GET", `/messaging/s%2Fa/phone`, ""], [ "POST", - `/messaging/testing/${projectId}/numbers/s%2Fa/phone/messages`, + `/messaging/s%2Fa/phone/messages`, '{"to":"+15551234568","text":"x"}', ], - [ - "POST", - `/messaging/testing/${projectId}/numbers/s%2Fa/phone/devices/99/unlink`, - "", - ], + ["POST", `/messaging/s%2Fa/phone/devices/99/unlink`, ""], ]); - await expect(m.testing.getPhone(projectId, "s/a")).rejects.toMatchObject({ + await expect(m.testing.getPhone("s/a")).rejects.toMatchObject({ code: "invalid_response", metadata: { headers: { "x-trace-id": "a".repeat(32) } }, }); diff --git a/packages/typescript/test/onboarding.test.ts b/packages/typescript/test/onboarding.test.ts index aaec3dde..3b57afa3 100644 --- a/packages/typescript/test/onboarding.test.ts +++ b/packages/typescript/test/onboarding.test.ts @@ -39,7 +39,7 @@ it("uploads fixture content separately and advances an existing QuickLink withou expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual(input); expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual(signup); expect(String(fetcher.mock.calls[0]?.[0])).toContain( - "/messaging/testing/project-a/history-fixtures", + "/messaging/projects/project-a/test-history-fixtures", ); expect(String(fetcher.mock.calls[1]?.[0])).toContain( "/messaging/cloud-api/embedded-signup", diff --git a/packages/typescript/test/testing-events.test.ts b/packages/typescript/test/testing-events.test.ts index 76736d14..21c3cb18 100644 --- a/packages/typescript/test/testing-events.test.ts +++ b/packages/typescript/test/testing-events.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { MessagingClient, PolymorfaRateLimitError, - PolymorfaValidationError, + PolymorfaNotFoundError, TEST_EVENT_FIXTURES, type TriggerTestEventRequest, } from "../src/index.js"; @@ -50,16 +50,13 @@ it("triggers a typed test event with overrides", async () => { }; const { client: messaging, fetcher } = client(json(accepted, 202)); const input: TriggerTestEventRequest = { - session: "test-a", event: "message.received", overrides: { text: "hi", from: "+15550100001" }, }; - const result = await messaging.testing.triggerEvent("project a", input); + const result = await messaging.testing.triggerEvent("test-a", input); expect(result.data).toEqual(accepted); const [url, init] = fetcher.mock.calls[0]!; - expect(String(url)).toBe( - "https://api.example/messaging/testing/project%20a/events", - ); + expect(String(url)).toBe("https://api.example/messaging/test-a/test-events"); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toEqual(input); }); @@ -71,12 +68,12 @@ it("lists fixtures", async () => { overrides: [], })); const { client: messaging, fetcher } = client(json({ fixtures })); - const result = await messaging.testing.listEventFixtures("project-a"); + const result = await messaging.testing.listEventFixtures(); expect(result.data.fixtures.map((f) => f.name)).toEqual([ ...TEST_EVENT_FIXTURES, ]); expect(String(fetcher.mock.calls[0]![0])).toBe( - "https://api.example/messaging/testing/project-a/events/fixtures", + "https://api.example/messaging/test-events/fixtures", ); expect(fetcher.mock.calls[0]![1]?.method).toBe("GET"); }); @@ -121,12 +118,11 @@ it.each([true, false])( }; const { client: messaging, fetcher } = client(json(accepted, 202)); const input: TriggerTestEventRequest = { - session: "test-a", event: "session.restriction_updated", overrides: { restrictionActive }, }; expect( - (await messaging.testing.triggerEvent("project-a", input)).data, + (await messaging.testing.triggerEvent("test-a", input)).data, ).toEqual(accepted); expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual(input); }, @@ -137,11 +133,10 @@ it("sends a call restriction outcome supported by the API fixture", async () => json({ event: "call.ended" }, 202), ); const input: TriggerTestEventRequest = { - session: "test-a", event: "call.ended", overrides: { callEndReason: "call_restricted" }, }; - await messaging.testing.triggerEvent("project-a", input); + await messaging.testing.triggerEvent("test-a", input); expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual(input); }); @@ -151,22 +146,20 @@ it("surfaces a real-session refusal as an invalid request", async () => { { error: { type: "invalid_request_error", - code: "invalid_parameter", - message: - "Test events are available only for Test numbers. This session is not a Test number in the project, or it does not exist.", + code: "resource_not_found", + message: "Resource not found", param: null, }, data: null, }, - 400, + 404, ), ); await expect( - messaging.testing.triggerEvent("project-a", { - session: "real", + messaging.testing.triggerEvent("real", { event: "call.received", }), - ).rejects.toBeInstanceOf(PolymorfaValidationError); + ).rejects.toBeInstanceOf(PolymorfaNotFoundError); }); it("rejects client tokens before transport", () => { @@ -177,12 +170,11 @@ it("rejects client tokens before transport", () => { fetch: fetcher, }); expect(() => - messaging.testing.triggerEvent("project-a", { - session: "test-a", + messaging.testing.triggerEvent("test-a", { event: "session.status", }), ).toThrow("requires an team key or project token"); - expect(() => messaging.testing.listEventFixtures("project-a")).toThrow( + expect(() => messaging.testing.listEventFixtures()).toThrow( "requires an team key or project token", ); expect(fetcher).not.toHaveBeenCalled(); @@ -203,8 +195,8 @@ it("surfaces the per-project rate limit", async () => { ); await expect( messaging.testing.triggerEvent( - "project-a", - { session: "test-a", event: "call.missed" }, + "test-a", + { event: "call.missed" }, { maxNetworkRetries: 0 }, ), ).rejects.toBeInstanceOf(PolymorfaRateLimitError); @@ -231,8 +223,8 @@ it("sends an optional Idempotency-Key and retries safely with it", async () => { fetch: fetcher, }); const result = await messaging.testing.triggerEvent( - "project-a", - { session: "test-a", event: "message.received" }, + "test-a", + { event: "message.received" }, { idempotencyKey: "trigger-1", maxNetworkRetries: 1 }, ); expect(result.data).toEqual(accepted); @@ -255,8 +247,7 @@ it("omits Idempotency-Key when none is given", async () => { 202, ), ); - await messaging.testing.triggerEvent("project-a", { - session: "test-a", + await messaging.testing.triggerEvent("test-a", { event: "session.status", }); const init = fetcher.mock.calls[0]![1]; @@ -266,12 +257,10 @@ it("omits Idempotency-Key when none is given", async () => { it("serializes restriction fixtures and restricted call endings", async () => { for (const input of [ { - session: "test-a", event: "session.restriction_updated", overrides: { restrictionActive: true }, }, { - session: "test-a", event: "call.ended", overrides: { callEndReason: "call_restricted" }, }, @@ -280,7 +269,7 @@ it("serializes restriction fixtures and restricted call endings", async () => { json( { event: input.event, - session: input.session, + session: "test-a", delivery: "generated", eventId: null, source: "test", @@ -288,7 +277,7 @@ it("serializes restriction fixtures and restricted call endings", async () => { 202, ), ); - await messaging.testing.triggerEvent("project-a", input); + await messaging.testing.triggerEvent("test-a", input); expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual(input); } }); diff --git a/packages/typescript/test/testing-phone.test.ts b/packages/typescript/test/testing-phone.test.ts index efe3cfe2..9ae6581c 100644 --- a/packages/typescript/test/testing-phone.test.ts +++ b/packages/typescript/test/testing-phone.test.ts @@ -4,6 +4,7 @@ import { MessagingClient, PolymorfaConfigurationError, PolymorfaValidationError, + PolymorfaNotFoundError, type TestingPhone, } from "../src/index.js"; import { ORGANIZATION_API_KEY } from "./support/credentials.js"; @@ -39,12 +40,10 @@ it("reads a Test number's phone", async () => { devices: [{ deviceId: 33 }, { deviceId: 74 }], }; const { client: messaging, fetcher } = client(json(phone)); - const result = await messaging.testing.getPhone("project-a", "test a"); + const result = await messaging.testing.getPhone("test a"); expect(result.data).toEqual(phone); const [url, init] = fetcher.mock.calls[0]!; - expect(String(url)).toBe( - "https://api.example/messaging/testing/project-a/numbers/test%20a/phone", - ); + expect(String(url)).toBe("https://api.example/messaging/test%20a/phone"); expect(init?.method).toBe("GET"); }); @@ -52,15 +51,11 @@ it("sends a message from the phone", async () => { const sent = { session: "test-a", to: "+15550100002", messageId: "3EB0AA" }; const { client: messaging, fetcher } = client(json(sent, 202)); const input = { to: "+15550100002", text: "hello" }; - const result = await messaging.testing.sendPhoneMessage( - "project-a", - "test-a", - input, - ); + const result = await messaging.testing.sendPhoneMessage("test-a", input); expect(result.data).toEqual(sent); const [url, init] = fetcher.mock.calls[0]!; expect(String(url)).toBe( - "https://api.example/messaging/testing/project-a/numbers/test-a/phone/messages", + "https://api.example/messaging/test-a/phone/messages", ); expect(init?.method).toBe("POST"); expect(JSON.parse(String(init?.body))).toEqual(input); @@ -69,30 +64,26 @@ it("sends a message from the phone", async () => { it("unlinks a companion", async () => { const unlinked = { session: "test-a", deviceId: 74, unlinked: true }; const { client: messaging, fetcher } = client(json(unlinked)); - const result = await messaging.testing.unlinkPhoneDevice( - "project-a", - "test-a", - 74, - ); + const result = await messaging.testing.unlinkPhoneDevice("test-a", 74); expect(result.data).toEqual(unlinked); const [url, init] = fetcher.mock.calls[0]!; expect(String(url)).toBe( - "https://api.example/messaging/testing/project-a/numbers/test-a/phone/devices/74/unlink", + "https://api.example/messaging/test-a/phone/devices/74/unlink", ); expect(init?.method).toBe("POST"); }); it.each([0, 100, 1.5])("refuses device ID %s before sending", (deviceId) => { const { client: messaging, fetcher } = client(json({})); - expect(() => - messaging.testing.unlinkPhoneDevice("project-a", "test-a", deviceId), - ).toThrow(PolymorfaValidationError); + expect(() => messaging.testing.unlinkPhoneDevice("test-a", deviceId)).toThrow( + PolymorfaValidationError, + ); expect(fetcher).not.toHaveBeenCalled(); }); it("refuses client tokens", () => { const { client: messaging, fetcher } = client(json({}), "clientToken"); - expect(() => messaging.testing.getPhone("project-a", "test-a")).toThrow( + expect(() => messaging.testing.getPhone("test-a")).toThrow( PolymorfaConfigurationError, ); expect(fetcher).not.toHaveBeenCalled(); @@ -110,7 +101,7 @@ it("surfaces an offline phone as a validation error", async () => { ), ); await expect( - messaging.testing.sendPhoneMessage("project-a", "test-a", { + messaging.testing.sendPhoneMessage("test-a", { to: "+15550100002", text: "hi", }), @@ -134,15 +125,15 @@ it("matches the pinned Testing phone contract", () => { const route = (operationId: string) => contract.operations[operationId]!; expect(route("getTestingPhone")).toMatchObject({ method: "GET", - path: "/messaging/testing/{projectId}/numbers/{session}/phone", + path: "/messaging/{session}/phone", }); expect(route("sendTestingPhoneMessage")).toMatchObject({ method: "POST", - path: "/messaging/testing/{projectId}/numbers/{session}/phone/messages", + path: "/messaging/{session}/phone/messages", }); expect(route("unlinkTestingPhoneDevice")).toMatchObject({ method: "POST", - path: "/messaging/testing/{projectId}/numbers/{session}/phone/devices/{deviceId}/unlink", + path: "/messaging/{session}/phone/devices/{deviceId}/unlink", }); const fields = (name: string) => Object.keys(contract.schemas[name]!.properties).sort(); @@ -161,3 +152,15 @@ it("matches the pinned Testing phone contract", () => { "unlinked", ]); }); + +it("hides a real Number with resource_not_found", async () => { + const { client: messaging } = client( + json( + { error: { code: "resource_not_found", message: "Resource not found" } }, + 404, + ), + ); + await expect( + messaging.testing.getPhone("real-number"), + ).rejects.toBeInstanceOf(PolymorfaNotFoundError); +}); diff --git a/scripts/check-native-coverage.mjs b/scripts/check-native-coverage.mjs index 5b48651d..427b286e 100644 --- a/scripts/check-native-coverage.mjs +++ b/scripts/check-native-coverage.mjs @@ -35,7 +35,7 @@ for (const language of languages) { assert.equal(manifest.schemaVersion, 1); assert.equal( manifest.sourceSdkCommit, - "e24cb176fc1124f5fe852ff6c77902648fd13b4a", + "a7dd02cf1655e386642b9401358b725de4903a60", ); assert.ok(Array.isArray(manifest.operations)); const key = (entry) => `${entry.family}|${entry.method}|${entry.path}`; diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 707322ad..57fdcb85 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "ff82061b9f872f1f39bc1fca3584df83209c4c52"; +export const sourceCommit = "16c330d388fdc67a370332d8235db927cf6323cb"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript",