From 2c42799882e3b59c9d1d70582449b5a58b3c58ac Mon Sep 17 00:00:00 2001 From: Bill Madeira Date: Wed, 27 May 2026 12:18:39 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v1.4.0=20=E2=80=94=20100%=20documented?= =?UTF-8?q?=20endpoint=20coverage,=20live-verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full re-audit against https://api.assinafy.com.br/v1/docs surfaced whole resource families the SDK never exposed. Each new endpoint was verified end-to-end against the production API before release. Added: - TagResource ($client->tags()) — workspace tag CRUD (+force delete) - FieldResource ($client->fields()) — field-definition CRUD, validate, validate-multiple, GET /field-types - SignerDocumentResource ($client->signerDocuments()) — signer-facing current/list/sign-multiple/decline-multiple/download - DocumentResource document tags: listTags/appendTags/replaceTags/detachTag - AssignmentResource: whatsappNotifications(), sequential-signing `step` pass-through, NOTIFICATION_* constants - SignerSessionResource: currentDocument (GET /sign), sign, decline - WebhookResource: eventTypes, dispatches (paginated/filtered), retryDispatch + all 15 subscribable event-type constants - HttpClientInterface::delete() query support (tag ?force) Changed: - WebhookResource::deactivate() now uses the dedicated PUT /accounts/{id}/webhooks/inactivate endpoint - DocumentResource::assertArtifact() promoted to public static (shared with SignerDocumentResource::download(), DRY) Tests/docs: - 116 unit tests (was 77), PHPStan level 8 clean, PSR-12 clean - 17 live integration tests (4 new: tags, fields, doc-tags, webhook discovery); fixed webhook round-trip to self-clean when no prior config - README/EXAMPLES/ARCHITECTURE/CHANGELOG updated; fixed stale webhooks()->delete() example; version 1.3.0 -> 1.4.0 --- ARCHITECTURE.md | 18 +- CHANGELOG.md | 51 ++++++ README.md | 88 +++++++++- docs/EXAMPLES.md | 69 +++++++- src/AssinafyClient.php | 33 ++++ src/Configuration.php | 2 +- src/Http/GuzzleHttpClient.php | 6 +- src/Http/HttpClientInterface.php | 17 +- src/Resources/AssignmentResource.php | 23 +++ src/Resources/DocumentResource.php | 81 ++++++++- src/Resources/FieldResource.php | 164 ++++++++++++++++++ src/Resources/SignerDocumentResource.php | 134 ++++++++++++++ src/Resources/SignerSessionResource.php | 66 +++++++ src/Resources/TagResource.php | 114 ++++++++++++ src/Resources/WebhookResource.php | 106 +++++++---- tests/Integration/LiveApiTest.php | 107 +++++++++++- tests/Unit/AssinafyClientTest.php | 7 + .../Unit/Resources/AssignmentResourceTest.php | 25 +++ tests/Unit/Resources/DocumentResourceTest.php | 41 +++++ tests/Unit/Resources/FieldResourceTest.php | 124 +++++++++++++ .../Resources/SignerDocumentResourceTest.php | 102 +++++++++++ .../Resources/SignerSessionResourceTest.php | 49 ++++++ tests/Unit/Resources/TagResourceTest.php | 104 +++++++++++ tests/Unit/Resources/WebhookResourceTest.php | 57 ++++-- tests/Unit/Support/FakeHttpClient.php | 4 +- 25 files changed, 1511 insertions(+), 81 deletions(-) create mode 100644 src/Resources/FieldResource.php create mode 100644 src/Resources/SignerDocumentResource.php create mode 100644 src/Resources/TagResource.php create mode 100644 tests/Unit/Resources/FieldResourceTest.php create mode 100644 tests/Unit/Resources/SignerDocumentResourceTest.php create mode 100644 tests/Unit/Resources/TagResourceTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 231c5c0..42be38d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,9 +26,12 @@ assinafy-php-sdk/ │ │ ├── SignerResource.php │ │ ├── AssignmentResource.php │ │ ├── TemplateResource.php +│ │ ├── TagResource.php +│ │ ├── FieldResource.php │ │ ├── WebhookResource.php │ │ ├── AuthResource.php -│ │ └── SignerSessionResource.php +│ │ ├── SignerSessionResource.php +│ │ └── SignerDocumentResource.php │ └── Support/ # Helper classes │ └── WebhookVerifier.php ├── tests/ # PHPUnit test suites @@ -69,13 +72,16 @@ $client->documents()->upload(...); Resource classes encapsulate domain logic for each API resource: -- **DocumentResource**: Document operations, including artifacts, public endpoints, and template-driven creation +- **DocumentResource**: Document operations, including artifacts, public endpoints, document tags, and template-driven creation - **SignerResource**: Workspace signer CRUD -- **AssignmentResource**: Signature requests (virtual + collect), cost estimation, resend, expiration reset +- **AssignmentResource**: Signature requests (virtual + collect), cost estimation, resend, expiration reset, WhatsApp notification history - **TemplateResource**: Template listing and retrieval -- **WebhookResource**: Webhook subscription upsert / read / delete +- **TagResource**: Workspace tag CRUD +- **FieldResource**: Field-definition CRUD, value validation, and the global type catalog +- **WebhookResource**: Webhook subscription upsert / read / inactivate, plus dispatch history, retry, and event-type discovery - **AuthResource**: Login, social login, API-key lifecycle, password reset / change -- **SignerSessionResource**: Signer-facing endpoints authenticated with a `signer-access-code` +- **SignerSessionResource**: Signer-facing session endpoints authenticated with a `signer-access-code` (identity, signature image, sign/decline) +- **SignerDocumentResource**: Signer-facing document list / sign-multiple / decline-multiple / download, authenticated with a `signer-access-code` **Pattern**: Each resource extends `AbstractResource` and follows the same structure. @@ -172,7 +178,7 @@ class CustomHttpClient implements HttpClientInterface { } abstract class AbstractResource { protected function extractData(array $response): array { } - protected function normalizeId(array $data): array { } + protected function accountPath(string $suffix = ''): string { } } ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a900cbe..a9e42df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-05-27 + +Complete coverage pass against `https://api.assinafy.com.br/v1/docs`. A full re-read of +the live documentation surfaced several whole resource families the SDK had never exposed; +each new endpoint below was verified end-to-end against the production API before release. + +### Added + +- **`TagResource`** (`$client->tags()`) — workspace tag management: + `GET/POST /accounts/{id}/tags`, `PUT/DELETE /accounts/{id}/tags/{tag_id}` (with `force` + detach-and-delete). +- **`FieldResource`** (`$client->fields()`) — field-definition management and validation: + `POST/GET /accounts/{id}/fields`, `GET/PUT/DELETE /accounts/{id}/fields/{field_id}`, + `POST …/fields/{id}/validate`, `POST …/fields/validate-multiple` (both usable as an + authenticated user or, with a `signer-access-code`, as a signer), and the global + `GET /field-types` catalog. +- **`SignerDocumentResource`** (`$client->signerDocuments()`) — signer-facing document + endpoints authenticated by `signer-access-code`: `GET /signers/{id}/document`, + `GET /signers/{id}/documents`, `PUT /signers/documents/sign-multiple`, + `PUT /signers/documents/decline-multiple`, and + `GET /signers/{id}/documents/{id}/download/{artifact_name}`. +- **`DocumentResource` document tags** — `listTags()`, `appendTags()`, `replaceTags()`, + `detachTag()` covering `GET/POST/PUT /accounts/{id}/documents/{id}/tags` and + `DELETE …/tags/{tag_id}`. +- **`AssignmentResource::whatsappNotifications()`** — `GET /documents/{id}/assignments/{id}/whatsapp-notifications`. +- **`AssignmentResource` sequential signing** — signer entries now pass through the + documented `step` field; added `NOTIFICATION_EMAIL` / `NOTIFICATION_WHATSAPP` constants. +- **`SignerSessionResource`** signer-facing signing actions — `currentDocument()` + (`GET /sign`), `sign()` (`POST /documents/{id}/assignments/{id}`), and `decline()` + (`PUT /documents/{id}/assignments/{id}/reject`). +- **`WebhookResource`** dispatch + discovery endpoints — `eventTypes()` + (`GET /webhooks/event-types`), `dispatches()` (`GET /accounts/{id}/webhooks`, paginated + with `event`/`delivered`/`from`/`to` filters), and `retryDispatch()` + (`POST /accounts/{id}/webhooks/{dispatch_id}/retry`). Added constants for all 15 + subscribable event types. +- **Query-string parameter** on `HttpClientInterface::delete()` — supports the tag + `?force=true` flag. Backward-compatible: the new `$query` arg is the third positional + and defaults to `[]`. +- **4 new live integration tests** covering the tag, field, document-tag, and webhook + discovery endpoints (all credit-free). + +### Changed + +- **`WebhookResource::deactivate()`** now calls the dedicated `PUT /accounts/{id}/webhooks/inactivate` + endpoint (verified live) instead of re-`PUT`ting the subscription with `is_active: false`. + The server preserves the URL / email / events, so `activate()` still restores them. +- **`DocumentResource::assertArtifact()`** promoted to `public static` so + `SignerDocumentResource::download()` validates artifact names through the same list (DRY). + +[1.4.0]: https://github.com/assinafy/php-sdk/releases/tag/v1.4.0 + ## [1.3.0] - 2026-05-12 Second pass against `https://api.assinafy.com.br/v1/docs` plus a full live verification diff --git a/README.md b/README.md index 0e64427..6ac18a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Modern, framework-agnostic PHP SDK for the [Assinafy](https://assinafy.com.br) digital signature API (`https://api.assinafy.com.br/v1`). Built with PSR standards and SOLID principles. -The SDK covers **every documented endpoint** in `https://api.assinafy.com.br/v1/docs` plus the webhook subscription endpoints, and is verified against the live API by an integration test suite. +The SDK covers **100% of the documented endpoints** in `https://api.assinafy.com.br/v1/docs` — documents and document tags, signers, signer sessions and signer documents, assignments, templates, workspace tags, field definitions, authentication, and the full webhooks surface (subscription, dispatch history, retry, event types). Every endpoint is verified against the live API by an integration test suite. ## Features @@ -139,6 +139,10 @@ Every endpoint exposed by the documented API is reachable through the SDK. Resou | `sendToken($documentId, $recipient, $channel)` | `PUT /public/documents/{id}/send-token` | | `createFromTemplate($templateId, $signers, $options)` | `POST /accounts/{id}/templates/{id}/documents` | | `estimateCostFromTemplate($templateId, $signers)` | `POST /accounts/{id}/templates/{id}/documents/estimate-cost` | +| `listTags($documentId)` | `GET /accounts/{id}/documents/{id}/tags` | +| `appendTags($documentId, $tagNames)` | `POST /accounts/{id}/documents/{id}/tags` | +| `replaceTags($documentId, $tagNames)` | `PUT /accounts/{id}/documents/{id}/tags` | +| `detachTag($documentId, $tagId)` | `DELETE /accounts/{id}/documents/{id}/tags/{tag_id}` | | `waitUntilReady($documentId, $maxWait, $pollInterval)` | polls `GET /documents/{id}` | | `isFullySigned($documentId)` | derived from `GET /documents/{id}` | | `getSigningProgress($documentId)` | derived from `GET /documents/{id}` | @@ -204,16 +208,19 @@ $client->signers()->delete($signer['id']); | `resend($documentId, $assignmentId, $signerId)` | `PUT /documents/{id}/assignments/{id}/signers/{id}/resend` | | `estimateResendCost($documentId, $assignmentId, $signerId)` | `POST /documents/{id}/assignments/{id}/signers/{id}/estimate-resend-cost` | | `resetExpiration($documentId, $assignmentId, $expiresAt)` | `PUT /documents/{id}/assignments/{id}/reset-expiration` | +| `whatsappNotifications($documentId, $assignmentId)` | `GET /documents/{id}/assignments/{id}/whatsapp-notifications` | ```php use Assinafy\SDK\Resources\AssignmentResource; // Virtual assignment (no input fields). Signers may be ID strings or full objects. +// Add `step` to a signer for sequential signing (signers in the same step sign in +// parallel; the next step is notified only once the previous step has all signed). $assignment = $client->assignments()->create( documentId: $documentId, signers: [ - $signerId1, - ['id' => $signerId2, 'verification_method' => AssignmentResource::VERIFICATION_WHATSAPP], + ['id' => $signerId1, 'step' => 1], + ['id' => $signerId2, 'verification_method' => AssignmentResource::VERIFICATION_WHATSAPP, 'step' => 2], ], method: AssignmentResource::METHOD_VIRTUAL, options: [ @@ -247,18 +254,60 @@ foreach ($template['roles'] as $role) { } ``` +### Tags — `$client->tags()` + +Workspace-scoped labels that can be attached to documents and templates. Tag names are unique per workspace (case-insensitive). + +| Method | Endpoint | +| --- | --- | +| `list($search)` | `GET /accounts/{id}/tags` | +| `create($name, $color)` | `POST /accounts/{id}/tags` | +| `update($tagId, $data)` | `PUT /accounts/{id}/tags/{id}` | +| `delete($tagId, $force)` | `DELETE /accounts/{id}/tags/{id}` | + +```php +$tag = $client->tags()->create('Contracts', 'ff8800'); +$client->tags()->update($tag['id'], ['name' => 'Sales Contracts']); +$client->tags()->delete($tag['id'], force: true); // detach from everything, then delete +``` + +### Field definitions — `$client->fields()` + +Reusable inputs (text, CPF, e-mail, date, …) that can be placed on a document for `collect` assignments. + +| Method | Endpoint | +| --- | --- | +| `create($type, $name, $options)` | `POST /accounts/{id}/fields` | +| `list($includeInactive, $includeStandard)` | `GET /accounts/{id}/fields` | +| `get($fieldId)` | `GET /accounts/{id}/fields/{id}` | +| `update($fieldId, $data)` | `PUT /accounts/{id}/fields/{id}` | +| `delete($fieldId)` | `DELETE /accounts/{id}/fields/{id}` | +| `validate($fieldId, $value, $signerAccessCode)` | `POST /accounts/{id}/fields/{id}/validate` | +| `validateMultiple($values, $signerAccessCode)` | `POST /accounts/{id}/fields/validate-multiple` | +| `types()` | `GET /field-types` | + +```php +$field = $client->fields()->create('cpf', 'Taxpayer ID'); +$check = $client->fields()->validate($field['id'], '400.676.228-36'); // ['success' => true, …] +$types = $client->fields()->types(); // [['type' => 'text', 'name' => 'Text'], …] +``` + ### Webhooks — `$client->webhooks()` | Method | Endpoint | | --- | --- | | `register($url, $email, $events, $isActive)` | `PUT /accounts/{id}/webhooks/subscriptions` | | `get()` | `GET /accounts/{id}/webhooks/subscriptions` | -| `deactivate()` | `PUT …/subscriptions` with `is_active: false` | -| `activate()` | `PUT …/subscriptions` with `is_active: true` | +| `deactivate()` | `PUT /accounts/{id}/webhooks/inactivate` | +| `activate()` | re-sends the stored config via `PUT …/subscriptions` with `is_active: true` | +| `eventTypes()` | `GET /webhooks/event-types` | +| `dispatches($filters)` | `GET /accounts/{id}/webhooks` | +| `retryDispatch($dispatchId)` | `POST /accounts/{id}/webhooks/{dispatch_id}/retry` | -> The v1 API has no `DELETE` route for webhook subscriptions (it returns 404). The -> way to stop receiving events is `deactivate()` — the configuration is preserved -> so you can `activate()` again later. `is_active` is required in the request body. +> The v1 API has no `DELETE` route for webhook subscriptions. The way to stop +> receiving events is `deactivate()` (the dedicated `inactivate` endpoint) — the +> configuration is preserved so you can `activate()` again later. `register()` +> requires `url`, `email`, `events`, and `is_active`. ```php use Assinafy\SDK\Resources\WebhookResource; @@ -268,6 +317,12 @@ $client->webhooks()->register( email: 'admin@your-domain.com', events: WebhookResource::DEFAULT_EVENTS, ); + +// Inspect and replay delivery history +$history = $client->webhooks()->dispatches(['delivered' => 'false']); +foreach ($history['data'] as $dispatch) { + $client->webhooks()->retryDispatch($dispatch['id']); +} ``` ### Authentication — `$client->auth()` @@ -302,6 +357,21 @@ Endpoints authenticated with a signer's `signer-access-code` (not the workspace | `confirmData($documentId, $accessCode, $data)` | `PUT /documents/{id}/signers/confirm-data` | | `uploadSignature($accessCode, $type, $bytes, $mime)` | `POST /signature` | | `downloadSignature($accessCode, $type)` | `GET /signature/{type}` | +| `currentDocument($accessCode)` | `GET /sign` | +| `sign($documentId, $assignmentId, $accessCode, $fields)` | `POST /documents/{id}/assignments/{id}` | +| `decline($documentId, $assignmentId, $accessCode, $reason)` | `PUT /documents/{id}/assignments/{id}/reject` | + +### Signer documents (signer-facing) — `$client->signerDocuments()` + +Document list/sign/decline/download for a signer, authenticated by `signer-access-code`. + +| Method | Endpoint | +| --- | --- | +| `current($signerId, $accessCode)` | `GET /signers/{id}/document` | +| `list($signerId, $accessCode, $filters)` | `GET /signers/{id}/documents` | +| `signMultiple($accessCode, $documentIds)` | `PUT /signers/documents/sign-multiple` | +| `declineMultiple($accessCode, $documentIds, $reason)` | `PUT /signers/documents/decline-multiple` | +| `download($signerId, $documentId, $accessCode, $artifact)` | `GET /signers/{id}/documents/{id}/download/{artifact_name}` | ## Webhook signature verification @@ -371,7 +441,7 @@ vendor/bin/phpstan analyse vendor/bin/phpcs ``` -**Current status**: PSR-12 compliant · PHPStan level 8 (zero errors) · 73 unit tests + 6 live integration tests · PHP 7.4 – 8.4 compatible. +**Current status**: PSR-12 compliant · PHPStan level 8 (zero errors) · 116 unit tests + 17 live integration tests · PHP 7.4 – 8.4 compatible. ## License diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index cce4333..52d53fa 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -201,7 +201,17 @@ $client->webhooks()->register( ); $current = $client->webhooks()->get(); -$client->webhooks()->delete(); + +// Stop / resume delivery (the v1 API has no DELETE for subscriptions) +$client->webhooks()->deactivate(); // PUT /accounts/{id}/webhooks/inactivate +$client->webhooks()->activate(); // re-sends stored config with is_active: true + +// Discover subscribable events and inspect / replay delivery history +$types = $client->webhooks()->eventTypes(); // GET /webhooks/event-types +$history = $client->webhooks()->dispatches(['delivered' => 'false']); // GET /accounts/{id}/webhooks +foreach ($history['data'] as $dispatch) { + $client->webhooks()->retryDispatch($dispatch['id']); // POST …/webhooks/{id}/retry +} ``` ## 12. Webhook receiver @@ -261,9 +271,64 @@ $session->uploadSignature( // Download the stored signature $png = $session->downloadSignature($accessCode, 'signature'); + +// View, sign (collect method) and decline as the signer +$current = $session->currentDocument($accessCode); // GET /sign +$session->sign($documentId, $assignmentId, $accessCode, [ // POST /documents/{id}/assignments/{id} + ['itemId' => 'i1', 'fieldId' => 'f1', 'pageId' => 'p1', 'value' => 'Signed by John'], +]); +$session->decline($documentId, $assignmentId, $accessCode, 'I do not agree with clause 2.'); +``` + +## 15. Signer documents (signer-facing list / bulk sign / download) + +```php +$docs = $client->signerDocuments(); + +$current = $docs->current($signerId, $accessCode); // GET /signers/{id}/document +$list = $docs->list($signerId, $accessCode, ['status' => 'pending_signature']); + +$docs->signMultiple($accessCode, ['doc1', 'doc2']); // PUT /signers/documents/sign-multiple +$docs->declineMultiple($accessCode, ['doc3'], 'Unfavorable terms.'); + +$pdf = $docs->download($signerId, 'doc1', $accessCode, \Assinafy\SDK\Resources\DocumentResource::ARTIFACT_ORIGINAL); +``` + +## 16. Workspace tags + +```php +$tag = $client->tags()->create('Contracts', 'ff8800'); // POST /accounts/{id}/tags +$client->tags()->list('contract'); // GET /accounts/{id}/tags?search=contract +$client->tags()->update($tag['id'], ['name' => 'Sales Contracts']); +$client->tags()->delete($tag['id'], force: true); // detach from everything, then delete +``` + +## 17. Document tags + +```php +$client->documents()->appendTags($documentId, ['Urgent', 'Q1-2026']); // POST …/documents/{id}/tags +$client->documents()->listTags($documentId); // GET …/documents/{id}/tags +$client->documents()->replaceTags($documentId, ['Signed']); // PUT …/documents/{id}/tags +$client->documents()->detachTag($documentId, $tagId); // DELETE …/tags/{tag_id} +``` + +## 18. Field definitions and validation + +```php +$field = $client->fields()->create('cpf', 'Taxpayer ID'); // POST /accounts/{id}/fields +$client->fields()->list(includeStandard: true); // GET /accounts/{id}/fields +$client->fields()->update($field['id'], ['name' => 'CPF']); + +// Validate a value — as an authenticated user, or as a signer via the access code +$result = $client->fields()->validate($field['id'], '400.676.228-36'); // ['success' => true, …] +$client->fields()->validateMultiple([ + ['field_id' => $field['id'], 'value' => '400.676.228-36'], +], $signerAccessCode); + +$types = $client->fields()->types(); // GET /field-types ``` -## 15. Error handling +## 19. Error handling ```php use Assinafy\SDK\Exceptions\ApiException; diff --git a/src/AssinafyClient.php b/src/AssinafyClient.php index 82e089c..475297d 100644 --- a/src/AssinafyClient.php +++ b/src/AssinafyClient.php @@ -9,8 +9,11 @@ use Assinafy\SDK\Resources\AssignmentResource; use Assinafy\SDK\Resources\AuthResource; use Assinafy\SDK\Resources\DocumentResource; +use Assinafy\SDK\Resources\FieldResource; +use Assinafy\SDK\Resources\SignerDocumentResource; use Assinafy\SDK\Resources\SignerResource; use Assinafy\SDK\Resources\SignerSessionResource; +use Assinafy\SDK\Resources\TagResource; use Assinafy\SDK\Resources\TemplateResource; use Assinafy\SDK\Resources\WebhookResource; use Assinafy\SDK\Support\WebhookVerifier; @@ -27,9 +30,12 @@ class AssinafyClient private ?SignerResource $signers = null; private ?AssignmentResource $assignments = null; private ?TemplateResource $templates = null; + private ?TagResource $tags = null; + private ?FieldResource $fields = null; private ?WebhookResource $webhooks = null; private ?AuthResource $auth = null; private ?SignerSessionResource $signerSession = null; + private ?SignerDocumentResource $signerDocuments = null; private ?WebhookVerifier $webhookVerifier = null; public function __construct( @@ -110,6 +116,24 @@ public function templates(): TemplateResource return $this->templates; } + public function tags(): TagResource + { + if ($this->tags === null) { + $this->tags = new TagResource($this->httpClient, $this->config, $this->logger); + } + + return $this->tags; + } + + public function fields(): FieldResource + { + if ($this->fields === null) { + $this->fields = new FieldResource($this->httpClient, $this->config, $this->logger); + } + + return $this->fields; + } + public function webhooks(): WebhookResource { if ($this->webhooks === null) { @@ -137,6 +161,15 @@ public function signerSession(): SignerSessionResource return $this->signerSession; } + public function signerDocuments(): SignerDocumentResource + { + if ($this->signerDocuments === null) { + $this->signerDocuments = new SignerDocumentResource($this->httpClient, $this->config, $this->logger); + } + + return $this->signerDocuments; + } + public function webhookVerifier(): WebhookVerifier { if ($this->webhookVerifier === null) { diff --git a/src/Configuration.php b/src/Configuration.php index 1200dc2..0505f09 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -6,7 +6,7 @@ class Configuration { - public const SDK_VERSION = '1.3.0'; + public const SDK_VERSION = '1.4.0'; public const DEFAULT_BASE_URL = 'https://api.assinafy.com.br/v1'; public const SANDBOX_BASE_URL = 'https://sandbox.assinafy.com.br/v1'; diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 0e4a419..e9e14df 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -58,11 +58,11 @@ public function put(string $uri, array $data = [], array $headers = [], array $q ], $query)); } - public function delete(string $uri, array $headers = []): Response + public function delete(string $uri, array $headers = [], array $query = []): Response { - return $this->request('DELETE', $uri, [ + return $this->request('DELETE', $uri, $this->withOptionalQuery([ 'headers' => $headers, - ]); + ], $query)); } public function uploadFile(string $uri, string $filePath, array $data = [], array $headers = []): Response diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 85231ab..8a11de6 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -13,23 +13,26 @@ interface HttpClientInterface public function get(string $uri, array $params = [], array $headers = []): Response; /** - * @param array $data JSON body - * @param array $headers - * @param array $query optional query-string parameters (e.g. `signer-access-code`) + * @param array $data JSON body — a string-keyed object or, for + * endpoints that expect a JSON array, a list + * @param array $headers + * @param array $query optional query-string parameters (e.g. `signer-access-code`) */ public function post(string $uri, array $data = [], array $headers = [], array $query = []): Response; /** - * @param array $data JSON body - * @param array $headers - * @param array $query optional query-string parameters (e.g. `signer-access-code`) + * @param array $data JSON body — a string-keyed object or, for + * endpoints that expect a JSON array, a list + * @param array $headers + * @param array $query optional query-string parameters (e.g. `signer-access-code`) */ public function put(string $uri, array $data = [], array $headers = [], array $query = []): Response; /** * @param array $headers + * @param array $query optional query-string parameters (e.g. `force`) */ - public function delete(string $uri, array $headers = []): Response; + public function delete(string $uri, array $headers = [], array $query = []): Response; /** * @param array $data diff --git a/src/Resources/AssignmentResource.php b/src/Resources/AssignmentResource.php index 6e4c86e..8e6df68 100644 --- a/src/Resources/AssignmentResource.php +++ b/src/Resources/AssignmentResource.php @@ -19,6 +19,9 @@ class AssignmentResource extends AbstractResource public const VERIFICATION_EMAIL = 'Email'; public const VERIFICATION_WHATSAPP = 'Whatsapp'; + public const NOTIFICATION_EMAIL = 'Email'; + public const NOTIFICATION_WHATSAPP = 'Whatsapp'; + /** * Create an assignment (signature request). * `POST /documents/{document_id}/assignments` @@ -119,6 +122,22 @@ public function resetExpiration(string $documentId, string $assignmentId, string return $this->extractData($response->getData() ?? []); } + /** + * List the WhatsApp notification messages sent for an assignment, with the rendered + * header/body/buttons exactly as the signer sees them. + * `GET /documents/{document_id}/assignments/{assignment_id}/whatsapp-notifications` + * + * @return array> + */ + public function whatsappNotifications(string $documentId, string $assignmentId): array + { + $response = $this->httpClient->get( + "documents/{$documentId}/assignments/{$assignmentId}/whatsapp-notifications" + ); + + return $this->extractData($response->getData() ?? []); + } + private function assertMethod(string $method): void { if (!in_array($method, [self::METHOD_VIRTUAL, self::METHOD_COLLECT], true)) { @@ -174,6 +193,10 @@ private function normalizeSigners(array $signers): array $entry['notification_methods'] = $signer['notification_methods']; } + if (isset($signer['step'])) { + $entry['step'] = $signer['step']; + } + if (isset($signer['role_id'])) { $entry['role_id'] = $signer['role_id']; } diff --git a/src/Resources/DocumentResource.php b/src/Resources/DocumentResource.php index 0718b68..074e9f3 100644 --- a/src/Resources/DocumentResource.php +++ b/src/Resources/DocumentResource.php @@ -127,7 +127,7 @@ public function delete(string $documentId): array */ public function download(string $documentId, string $artifact = self::ARTIFACT_CERTIFICATED): string { - $this->assertArtifact($artifact); + self::assertArtifact($artifact); $response = $this->httpClient->get("documents/{$documentId}/download/{$artifact}"); @@ -228,6 +228,77 @@ public function sendToken( return $this->extractData($response->getData() ?? []); } + /** + * List the tags currently attached to a document. + * `GET /accounts/{account_id}/documents/{document_id}/tags` + * + * @return array> + */ + public function listTags(string $documentId): array + { + $response = $this->httpClient->get($this->accountPath("documents/{$documentId}/tags")); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Replace the document's entire tag set with the given names. + * `PUT /accounts/{account_id}/documents/{document_id}/tags` + * + * Names that don't yet exist in the workspace are created automatically + * (case-insensitive). An empty array detaches all tags. + * + * @param array $tagNames + * @return array> the document's resulting tag set + */ + public function replaceTags(string $documentId, array $tagNames): array + { + $response = $this->httpClient->put( + $this->accountPath("documents/{$documentId}/tags"), + ['tags' => array_values($tagNames)] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Attach tags to a document without removing existing ones (idempotent). + * `POST /accounts/{account_id}/documents/{document_id}/tags` + * + * Unknown names are auto-created. + * + * @param array $tagNames + * @return array> the document's resulting tag set + * + * @throws ValidationException when no tag names are provided + */ + public function appendTags(string $documentId, array $tagNames): array + { + if ($tagNames === []) { + throw new ValidationException('At least one tag name is required'); + } + + $response = $this->httpClient->post( + $this->accountPath("documents/{$documentId}/tags"), + ['tags' => array_values($tagNames)] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Detach a single tag from a document (the tag itself is not deleted). + * `DELETE /accounts/{account_id}/documents/{document_id}/tags/{tag_id}` + */ + public function detachTag(string $documentId, string $tagId): array + { + $response = $this->httpClient->delete( + $this->accountPath("documents/{$documentId}/tags/{$tagId}") + ); + + return $this->extractData($response->getData() ?? []); + } + /** * Create a document from a template. * `POST /accounts/{account_id}/templates/{template_id}/documents` @@ -370,7 +441,13 @@ private function validateUpload(string $filePath): void } } - private function assertArtifact(string $artifact): void + /** + * Assert that `$artifact` is one of the documented artifact names. Shared with + * {@see SignerDocumentResource::download()} so both download paths validate identically. + * + * @throws ValidationException on an unknown artifact name + */ + public static function assertArtifact(string $artifact): void { $allowed = [ self::ARTIFACT_ORIGINAL, diff --git a/src/Resources/FieldResource.php b/src/Resources/FieldResource.php new file mode 100644 index 0000000..b2e5713 --- /dev/null +++ b/src/Resources/FieldResource.php @@ -0,0 +1,164 @@ + $options optional `regex`, `is_required`, `is_active` + * + * @throws ValidationException when type or name is empty + */ + public function create(string $type, string $name, array $options = []): array + { + if ($type === '') { + throw new ValidationException('Field type is required', ['type' => $type]); + } + if ($name === '') { + throw new ValidationException('Field name is required', ['name' => $name]); + } + + $payload = array_merge(['type' => $type, 'name' => $name], $options); + + $response = $this->httpClient->post($this->accountPath('fields'), $payload); + + return $this->extractData($response->getData() ?? []); + } + + /** + * List field definitions. + * `GET /accounts/{account_id}/fields` + * + * @return array> + */ + public function list(bool $includeInactive = false, bool $includeStandard = false): array + { + $params = []; + if ($includeInactive) { + $params['include_inactive'] = 'true'; + } + if ($includeStandard) { + $params['include_standard'] = 'true'; + } + + $response = $this->httpClient->get($this->accountPath('fields'), $params); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Retrieve a single field definition. + * `GET /accounts/{account_id}/fields/{field_id}` + */ + public function get(string $fieldId): array + { + $response = $this->httpClient->get($this->accountPath("fields/{$fieldId}")); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Update a field definition. + * `PUT /accounts/{account_id}/fields/{field_id}` + * + * @param array $data subset of `{ type, name, regex, is_required, is_active }` + */ + public function update(string $fieldId, array $data): array + { + $response = $this->httpClient->put($this->accountPath("fields/{$fieldId}"), $data); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Delete a field definition. A field already used in a document cannot be deleted. + * `DELETE /accounts/{account_id}/fields/{field_id}` + */ + public function delete(string $fieldId): array + { + $response = $this->httpClient->delete($this->accountPath("fields/{$fieldId}")); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Validate a single input value against a field definition. + * `POST /accounts/{account_id}/fields/{field_id}/validate` + * + * @param string|null $signerAccessCode pass when validating as a signer rather than + * as an authenticated account user + */ + public function validate(string $fieldId, string $value, ?string $signerAccessCode = null): array + { + $response = $this->httpClient->post( + $this->accountPath("fields/{$fieldId}/validate"), + ['value' => $value], + [], + $this->accessCodeQuery($signerAccessCode) + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Validate several input values at once. + * `POST /accounts/{account_id}/fields/validate-multiple` + * + * @param array $values + * @param string|null $signerAccessCode pass when + * validating as a signer rather than as an authenticated account user + * @return array> + */ + public function validateMultiple(array $values, ?string $signerAccessCode = null): array + { + $response = $this->httpClient->post( + $this->accountPath('fields/validate-multiple'), + $values, + [], + $this->accessCodeQuery($signerAccessCode) + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * List the field types supported by the platform. + * `GET /field-types` (not account-scoped). + * + * @return array + */ + public function types(): array + { + $response = $this->httpClient->get('field-types'); + + return $this->extractData($response->getData() ?? []); + } + + /** + * @return array + */ + private function accessCodeQuery(?string $signerAccessCode): array + { + return $signerAccessCode !== null ? ['signer-access-code' => $signerAccessCode] : []; + } +} diff --git a/src/Resources/SignerDocumentResource.php b/src/Resources/SignerDocumentResource.php new file mode 100644 index 0000000..1976b5f --- /dev/null +++ b/src/Resources/SignerDocumentResource.php @@ -0,0 +1,134 @@ +httpClient->get( + "signers/{$signerId}/document", + ['signer-access-code' => $accessCode] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * List the signer's documents. + * `GET /signers/{signer_id}/documents?signer-access-code={code}` + * + * @param array $filters optional `status`, `method`, `search`, `sort` + * @return array> + */ + public function list(string $signerId, string $accessCode, array $filters = []): array + { + $params = array_merge(['signer-access-code' => $accessCode], $filters); + + $response = $this->httpClient->get("signers/{$signerId}/documents", $params); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Sign several virtual-method documents in one call. + * `PUT /signers/documents/sign-multiple?signer-access-code={code}` + * + * @param array $documentIds + * + * @throws ValidationException when no document IDs are provided + */ + public function signMultiple(string $accessCode, array $documentIds): array + { + $this->assertDocumentIds($documentIds); + + $response = $this->httpClient->put( + 'signers/documents/sign-multiple', + ['document_ids' => array_values($documentIds)], + [], + ['signer-access-code' => $accessCode] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Decline several documents in one call. + * `PUT /signers/documents/decline-multiple?signer-access-code={code}` + * + * @param array $documentIds + * + * @throws ValidationException when no document IDs or no reason is provided + */ + public function declineMultiple(string $accessCode, array $documentIds, string $reason): array + { + $this->assertDocumentIds($documentIds); + + if ($reason === '') { + throw new ValidationException('A decline reason is required'); + } + + $response = $this->httpClient->put( + 'signers/documents/decline-multiple', + ['document_ids' => array_values($documentIds), 'decline_reason' => $reason], + [], + ['signer-access-code' => $accessCode] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Download one of the signer's document artifacts (raw binary body). + * `GET /signers/{signer_id}/documents/{document_id}/download/{artifact_name}?signer-access-code={code}` + * + * @param string $artifact one of the {@see DocumentResource} `ARTIFACT_*` constants + */ + public function download( + string $signerId, + string $documentId, + string $accessCode, + string $artifact = DocumentResource::ARTIFACT_ORIGINAL + ): string { + DocumentResource::assertArtifact($artifact); + + $response = $this->httpClient->get( + "signers/{$signerId}/documents/{$documentId}/download/{$artifact}", + ['signer-access-code' => $accessCode] + ); + + return $response->getBody(); + } + + /** + * @param array $documentIds + */ + private function assertDocumentIds(array $documentIds): void + { + if ($documentIds === []) { + throw new ValidationException('At least one document ID is required'); + } + } +} diff --git a/src/Resources/SignerSessionResource.php b/src/Resources/SignerSessionResource.php index 683def5..a5fadbb 100644 --- a/src/Resources/SignerSessionResource.php +++ b/src/Resources/SignerSessionResource.php @@ -17,6 +17,9 @@ * - PUT /documents/{documentId}/signers/confirm-data * - POST /signature * - GET /signature/{type} + * - GET /sign (current document/assignment view) + * - POST /documents/{documentId}/assignments/{id} (sign — collect method) + * - PUT /documents/{documentId}/assignments/{id}/reject (decline) * * The access code is required for every call. */ @@ -122,6 +125,69 @@ public function downloadSignature(string $accessCode, string $type): string return $response->getBody(); } + /** + * Retrieve the document/assignment the signer currently has access to. + * `GET /sign?signer-access-code={code}` + * + * Requires the access code (and a verified code on the underlying request). The + * response mirrors the document shape with the signer's `current_signer` and the + * assignment items they must complete. + */ + public function currentDocument(string $accessCode): array + { + $response = $this->httpClient->get('sign', ['signer-access-code' => $accessCode]); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Sign a document with input fields (collect method). + * `POST /documents/{documentId}/assignments/{assignmentId}?signer-access-code={code}` + * + * For virtual assignments the signer must first call {@see confirmData()}. + * + * @param array $fields + * + * @throws ValidationException when no fields are provided + */ + public function sign(string $documentId, string $assignmentId, string $accessCode, array $fields): array + { + if ($fields === []) { + throw new ValidationException('At least one field value is required to sign'); + } + + $response = $this->httpClient->post( + "documents/{$documentId}/assignments/{$assignmentId}", + array_values($fields), + [], + ['signer-access-code' => $accessCode] + ); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Decline (reject) an assignment as a signer. + * `PUT /documents/{documentId}/assignments/{assignmentId}/reject?signer-access-code={code}` + * + * @throws ValidationException when no reason is provided + */ + public function decline(string $documentId, string $assignmentId, string $accessCode, string $reason): array + { + if ($reason === '') { + throw new ValidationException('A decline reason is required'); + } + + $response = $this->httpClient->put( + "documents/{$documentId}/assignments/{$assignmentId}/reject", + ['decline_reason' => $reason], + [], + ['signer-access-code' => $accessCode] + ); + + return $this->extractData($response->getData() ?? []); + } + private function assertType(string $type): void { if (!in_array($type, [self::TYPE_SIGNATURE, self::TYPE_INITIAL], true)) { diff --git a/src/Resources/TagResource.php b/src/Resources/TagResource.php new file mode 100644 index 0000000..51312e1 --- /dev/null +++ b/src/Resources/TagResource.php @@ -0,0 +1,114 @@ +> + */ + public function list(?string $search = null): array + { + $params = []; + if ($search !== null && $search !== '') { + $params['search'] = $search; + } + + $response = $this->httpClient->get($this->accountPath('tags'), $params); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Create a new tag in the workspace. + * `POST /accounts/{account_id}/tags` + * + * @param string $name display name; trimmed, whitespace-collapsed, max 64 chars + * @param string|null $color 6-character hex color (with or without leading `#`); null for none + * + * @throws ValidationException when the name is empty + */ + public function create(string $name, ?string $color = null): array + { + $this->assertName($name); + + $payload = ['name' => $name]; + if ($color !== null) { + $payload['color'] = $color; + } + + $response = $this->httpClient->post($this->accountPath('tags'), $payload); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Update a tag's name and/or color. + * `PUT /accounts/{account_id}/tags/{tag_id}` + * + * Either field may be omitted to leave it untouched. Pass `color: null` to clear + * the color. Returns 409 Conflict (an {@see \Assinafy\SDK\Exceptions\ApiException}) + * if another tag already uses the new name. + * + * @param array $data subset of `{ name, color }` + * + * @throws ValidationException when no updatable field is provided + */ + public function update(string $tagId, array $data): array + { + if (!array_key_exists('name', $data) && !array_key_exists('color', $data)) { + throw new ValidationException('Provide at least one of name or color to update'); + } + + if (array_key_exists('name', $data)) { + $this->assertName((string) $data['name']); + } + + $response = $this->httpClient->put($this->accountPath("tags/{$tagId}"), $data); + + return $this->extractData($response->getData() ?? []); + } + + /** + * Delete a tag. + * `DELETE /accounts/{account_id}/tags/{tag_id}` + * + * By default the API refuses with 409 Conflict if the tag is still attached to any + * document or template. Pass `$force = true` to detach it from everything first; the + * documents and templates themselves are not deleted. + */ + public function delete(string $tagId, bool $force = false): array + { + $query = $force ? ['force' => 'true'] : []; + + $response = $this->httpClient->delete($this->accountPath("tags/{$tagId}"), [], $query); + + return $this->extractData($response->getData() ?? []); + } + + private function assertName(string $name): void + { + if (trim($name) === '') { + throw new ValidationException('Tag name cannot be empty', ['name' => $name]); + } + } +} diff --git a/src/Resources/WebhookResource.php b/src/Resources/WebhookResource.php index 1769766..9559b84 100644 --- a/src/Resources/WebhookResource.php +++ b/src/Resources/WebhookResource.php @@ -5,21 +5,35 @@ namespace Assinafy\SDK\Resources; /** - * Webhooks resource — manages the workspace's single webhook subscription. + * Webhooks resource — the workspace's single webhook subscription plus the + * delivery-history (dispatch) endpoints. * - * The Assinafy webhook subscription is an upsert: `PUT` creates or replaces it, - * `GET` returns the current configuration, `DELETE` removes it. These endpoints - * are not currently rendered in the public docs UI (`https://api.assinafy.com.br/v1/docs`) - * but they are part of the v1 API surface and respond at the documented paths; - * the live integration test exercises them on every release. + * The subscription is an upsert: {@see register()} (`PUT`) creates or replaces it, + * {@see get()} returns the current configuration, and {@see deactivate()} pauses + * delivery via the dedicated `inactivate` route. There is no `DELETE` route — the + * way to stop receiving events is to inactivate the subscription. + * + * @see https://api.assinafy.com.br/v1/docs */ class WebhookResource extends AbstractResource { + public const EVENT_DOCUMENT_UPLOADED = 'document_uploaded'; + public const EVENT_DOCUMENT_METADATA_READY = 'document_metadata_ready'; + public const EVENT_DOCUMENT_PREPARED = 'document_prepared'; + public const EVENT_ASSIGNMENT_CREATED = 'assignment_created'; + public const EVENT_SIGNATURE_REQUESTED = 'signature_requested'; public const EVENT_DOCUMENT_READY = 'document_ready'; + public const EVENT_SIGNER_CREATED = 'signer_created'; + public const EVENT_SIGNER_EMAIL_VERIFIED = 'signer_email_verified'; + public const EVENT_SIGNER_WHATSAPP_VERIFIED = 'signer_whatsapp_verified'; + public const EVENT_SIGNER_DATA_CONFIRMED = 'signer_data_confirmed'; public const EVENT_SIGNER_SIGNED = 'signer_signed_document'; + public const EVENT_SIGNER_VIEWED = 'signer_viewed_document'; public const EVENT_SIGNER_REJECTED = 'signer_rejected_document'; + public const EVENT_USER_REJECTED = 'user_rejected_document'; public const EVENT_DOCUMENT_PROCESSING_FAILED = 'document_processing_failed'; + /** Sensible default subscription covering the common document lifecycle events. */ public const DEFAULT_EVENTS = [ self::EVENT_DOCUMENT_READY, self::EVENT_SIGNER_SIGNED, @@ -31,9 +45,7 @@ class WebhookResource extends AbstractResource * Register or replace the workspace webhook subscription. * `PUT /accounts/{account_id}/webhooks/subscriptions` * - * The API requires `is_active` in the request body (the live sandbox returns - * `O atributo "is_active" é obrigatório.` if it's missing) even though the - * field is not currently rendered in the public docs UI. + * The API requires `is_active`, `url`, `email`, and `events` in the body. * * @param array $events when empty, {@see DEFAULT_EVENTS} is sent */ @@ -42,7 +54,7 @@ public function register(string $url, string $email, array $events = [], bool $i $payload = [ 'url' => $url, 'email' => $email, - 'events' => $events !== [] ? $events : self::DEFAULT_EVENTS, + 'events' => $events !== [] ? array_values($events) : self::DEFAULT_EVENTS, 'is_active' => $isActive, ]; @@ -69,46 +81,36 @@ public function get(): ?array /** * Disable delivery without losing the subscription configuration. + * `PUT /accounts/{account_id}/webhooks/inactivate` * - * The v1 API has no `DELETE /accounts/{id}/webhooks/subscriptions` route (it - * returns 404). The way to stop receiving events is to flip `is_active` to - * `false` via `PUT`. The URL / email / events stay on file so the subscription - * can be re-enabled later with {@see activate()} without re-supplying them. + * The URL / email / events stay on file so the subscription can be re-enabled + * later with {@see activate()} without re-supplying them. */ public function deactivate(): array { - $current = $this->get(); + $response = $this->httpClient->put($this->accountPath('webhooks/inactivate')); - if ($current === null) { - throw new \RuntimeException('No webhook subscription is configured — nothing to deactivate.'); - } - - return $this->register( - (string) ($current['url'] ?? ''), - (string) ($current['email'] ?? ''), - is_array($current['events'] ?? null) && $current['events'] !== [] - ? $current['events'] - : self::DEFAULT_EVENTS, - false - ); + return $this->extractData($response->getData() ?? []); } /** * Re-enable delivery on the existing subscription. * - * Counterpart of {@see deactivate()}; flips `is_active` back to `true` - * via `PUT` while preserving the configured URL / email / events. + * There is no dedicated "activate" route, so this re-sends the stored URL / email / + * events via {@see register()} with `is_active = true`. + * + * @throws \RuntimeException when no subscription has been configured yet */ public function activate(): array { $current = $this->get(); - if ($current === null) { + if ($current === null || ($current['url'] ?? null) === null) { throw new \RuntimeException('No webhook subscription is configured — call register() first.'); } return $this->register( - (string) ($current['url'] ?? ''), + (string) $current['url'], (string) ($current['email'] ?? ''), is_array($current['events'] ?? null) && $current['events'] !== [] ? $current['events'] @@ -116,4 +118,46 @@ public function activate(): array true ); } + + /** + * List the available webhook event types and their descriptions. + * `GET /webhooks/event-types` (not account-scoped). + * + * @return array + */ + public function eventTypes(): array + { + $response = $this->httpClient->get('webhooks/event-types'); + + return $this->extractData($response->getData() ?? []); + } + + /** + * List the webhook delivery history (dispatches) for the workspace. + * `GET /accounts/{account_id}/webhooks` + * + * @param array $filters optional `event`, `delivered`, `from`, `to`, + * `page`, `per-page` + * @return array{data?: array>, meta?: array} full + * envelope — items under `['data']`, pagination under `['meta']`. + */ + public function dispatches(array $filters = []): array + { + $response = $this->httpClient->get($this->accountPath('webhooks'), $filters); + + return $response->getData() ?? []; + } + + /** + * Manually retry a single webhook dispatch. + * `POST /accounts/{account_id}/webhooks/{dispatch_id}/retry` + * + * Returns the newly created dispatch entry. + */ + public function retryDispatch(string $dispatchId): array + { + $response = $this->httpClient->post($this->accountPath("webhooks/{$dispatchId}/retry")); + + return $this->extractData($response->getData() ?? []); + } } diff --git a/tests/Integration/LiveApiTest.php b/tests/Integration/LiveApiTest.php index 0179227..2032165 100644 --- a/tests/Integration/LiveApiTest.php +++ b/tests/Integration/LiveApiTest.php @@ -382,12 +382,109 @@ public function testWebhookFullRoundTrip(): void $reactivated = $webhooks->activate(); $this->assertTrue($reactivated['is_active'] ?? null); } finally { - // Restore the prior subscription if there was one. Best-effort. - if ($hadConfig) { - try { + // Restore the prior subscription if there was one; otherwise leave delivery + // disabled so we don't strand the account with an active bogus endpoint. + // Best-effort either way. + try { + if ($hadConfig) { $webhooks->register($existingUrl, $existingEmail, $existingEvents, $existingActive); - } catch (\Throwable $e) { - // best-effort — the test still reports the underlying failure + } else { + $webhooks->deactivate(); + } + } catch (\Throwable $e) { + // best-effort — the test still reports the underlying failure + } + } + } + + /** Tier 1 — workspace tag CRUD (no credit cost). */ + public function testTagLifecycle(): void + { + $tags = $this->client->tags(); + $name = 'SDK Tag ' . uniqid(); + + $created = $tags->create($name, 'ff8800'); + $this->assertNotEmpty($created['id']); + $this->assertSame($name, $created['name']); + + $listed = $tags->list($name); + $this->assertContains($created['id'], array_column($listed, 'id')); + + $renamed = $tags->update($created['id'], ['name' => $name . ' renamed']); + $this->assertSame($name . ' renamed', $renamed['name']); + + $deleted = $tags->delete($created['id']); + $this->assertTrue($deleted['deleted'] ?? false); + } + + /** Tier 1 — field-definition CRUD plus the global type catalog (no credit cost). */ + public function testFieldLifecycleAndTypes(): void + { + $fields = $this->client->fields(); + + $types = $fields->types(); + $this->assertNotEmpty($types); + $this->assertContains('text', array_column($types, 'type')); + + $created = $fields->create('text', 'SDK Field ' . uniqid()); + $this->assertNotEmpty($created['id']); + + $fetched = $fields->get($created['id']); + $this->assertSame($created['id'], $fetched['id']); + + $updated = $fields->update($created['id'], ['name' => 'SDK Field renamed']); + $this->assertSame('SDK Field renamed', $updated['name']); + + $this->assertContains($created['id'], array_column($fields->list(), 'id')); + + $fields->delete($created['id']); + } + + /** Tier 1 — webhook discovery endpoints (read-only). */ + public function testWebhookEventTypesAndDispatches(): void + { + $eventTypes = $this->client->webhooks()->eventTypes(); + $this->assertContains('document_ready', array_column($eventTypes, 'id')); + + $dispatches = $this->client->webhooks()->dispatches(['per-page' => 1]); + $this->assertArrayHasKey('data', $dispatches); + } + + /** Tier 1 — document tag attach/list/replace/detach round-trip (no credit cost). */ + public function testDocumentTagRoundTrip(): void + { + $pdf = $this->makePdfFixture(); + $doc = $this->client->documents()->upload($pdf); + $this->createdDocuments[] = $doc['id']; + $this->client->documents()->waitUntilReady($doc['id'], 60, 2); + + $documents = $this->client->documents(); + $tagName = 'SDK DocTag ' . uniqid(); + + $afterAppend = $documents->appendTags($doc['id'], [$tagName]); + $this->assertContains($tagName, array_column($afterAppend, 'name')); + + $listed = $documents->listTags($doc['id']); + $this->assertContains($tagName, array_column($listed, 'name')); + + $replaceName = 'SDK DocTag2 ' . uniqid(); + $documents->replaceTags($doc['id'], [$replaceName]); + $afterReplace = $documents->listTags($doc['id']); + $this->assertSame([$replaceName], array_column($afterReplace, 'name')); + + $tagId = $afterReplace[0]['id']; + $detached = $documents->detachTag($doc['id'], $tagId); + $this->assertTrue($detached['detached'] ?? false); + + // Clean up the workspace tags the document operations auto-created. + foreach ([$tagName, $replaceName] as $name) { + foreach ($this->client->tags()->list($name) as $tag) { + if ($tag['name'] === $name) { + try { + $this->client->tags()->delete($tag['id'], true); + } catch (\Throwable $e) { + // best-effort + } } } } diff --git a/tests/Unit/AssinafyClientTest.php b/tests/Unit/AssinafyClientTest.php index 3762a7c..df3dbaf 100644 --- a/tests/Unit/AssinafyClientTest.php +++ b/tests/Unit/AssinafyClientTest.php @@ -9,8 +9,11 @@ use Assinafy\SDK\Resources\AssignmentResource; use Assinafy\SDK\Resources\AuthResource; use Assinafy\SDK\Resources\DocumentResource; +use Assinafy\SDK\Resources\FieldResource; +use Assinafy\SDK\Resources\SignerDocumentResource; use Assinafy\SDK\Resources\SignerResource; use Assinafy\SDK\Resources\SignerSessionResource; +use Assinafy\SDK\Resources\TagResource; use Assinafy\SDK\Resources\TemplateResource; use Assinafy\SDK\Resources\WebhookResource; use Assinafy\SDK\Support\WebhookVerifier; @@ -28,9 +31,13 @@ public function testFactoryAndAccessorsReturnSingletons(): void $this->assertInstanceOf(SignerResource::class, $client->signers()); $this->assertInstanceOf(AssignmentResource::class, $client->assignments()); $this->assertInstanceOf(TemplateResource::class, $client->templates()); + $this->assertInstanceOf(TagResource::class, $client->tags()); + $this->assertInstanceOf(FieldResource::class, $client->fields()); $this->assertInstanceOf(WebhookResource::class, $client->webhooks()); $this->assertInstanceOf(AuthResource::class, $client->auth()); $this->assertInstanceOf(SignerSessionResource::class, $client->signerSession()); + $this->assertSame($client->signerDocuments(), $client->signerDocuments()); + $this->assertInstanceOf(SignerDocumentResource::class, $client->signerDocuments()); $this->assertInstanceOf(WebhookVerifier::class, $client->webhookVerifier()); } diff --git a/tests/Unit/Resources/AssignmentResourceTest.php b/tests/Unit/Resources/AssignmentResourceTest.php index c6b5e5f..a74b167 100644 --- a/tests/Unit/Resources/AssignmentResourceTest.php +++ b/tests/Unit/Resources/AssignmentResourceTest.php @@ -123,4 +123,29 @@ public function testResetExpiration(): void $this->assertSame('documents/doc1/assignments/a1/reset-expiration', $call['uri']); $this->assertSame(['expires_at' => '2027-01-01T00:00:00Z'], $call['body']); } + + public function testCreatePassesStepForSequentialSigning(): void + { + $this->http->queueJson(201, ['id' => 'a1']); + + $this->assignments->create('doc1', [ + ['id' => 's1', 'step' => 1], + ['id' => 's2', 'step' => 2], + ]); + + $signers = $this->http->lastCall()['body']['signers']; + $this->assertSame(['id' => 's1', 'step' => 1], $signers[0]); + $this->assertSame(['id' => 's2', 'step' => 2], $signers[1]); + } + + public function testWhatsappNotifications(): void + { + $this->http->queueJson(200, [['signer_id' => 's1', 'phone_number' => '+5511999990001']]); + + $this->assignments->whatsappNotifications('doc1', 'a1'); + + $call = $this->http->lastCall(); + $this->assertSame('GET', $call['method']); + $this->assertSame('documents/doc1/assignments/a1/whatsapp-notifications', $call['uri']); + } } diff --git a/tests/Unit/Resources/DocumentResourceTest.php b/tests/Unit/Resources/DocumentResourceTest.php index 474f6e7..4939cbb 100644 --- a/tests/Unit/Resources/DocumentResourceTest.php +++ b/tests/Unit/Resources/DocumentResourceTest.php @@ -220,6 +220,47 @@ public function testWaitUntilReadyThrowsOnFailure(): void $this->documents->waitUntilReady('doc1', 5, 1); } + public function testDocumentTagsLifecyclePaths(): void + { + $this->http->queueJson(200, [['id' => 't1', 'name' => 'Contracts']]); + $this->documents->listTags('doc1'); + $list = $this->http->lastCall(); + $this->assertSame('GET', $list['method']); + $this->assertSame('accounts/acc/documents/doc1/tags', $list['uri']); + + $this->http->queueJson(200, []); + $this->documents->appendTags('doc1', ['Urgent']); + $append = $this->http->lastCall(); + $this->assertSame('POST', $append['method']); + $this->assertSame('accounts/acc/documents/doc1/tags', $append['uri']); + $this->assertSame(['tags' => ['Urgent']], $append['body']); + + $this->http->queueJson(200, []); + $this->documents->replaceTags('doc1', ['A', 'B']); + $replace = $this->http->lastCall(); + $this->assertSame('PUT', $replace['method']); + $this->assertSame(['tags' => ['A', 'B']], $replace['body']); + + $this->http->queueJson(200, ['detached' => true]); + $this->documents->detachTag('doc1', 't1'); + $detach = $this->http->lastCall(); + $this->assertSame('DELETE', $detach['method']); + $this->assertSame('accounts/acc/documents/doc1/tags/t1', $detach['uri']); + } + + public function testAppendTagsRejectsEmpty(): void + { + $this->expectException(ValidationException::class); + $this->documents->appendTags('doc1', []); + } + + public function testReplaceTagsAllowsEmptyToDetachAll(): void + { + $this->http->queueJson(200, []); + $this->documents->replaceTags('doc1', []); + $this->assertSame(['tags' => []], $this->http->lastCall()['body']); + } + private function writeFixturePdf(): string { $path = tempnam(sys_get_temp_dir(), 'asn') . '.pdf'; diff --git a/tests/Unit/Resources/FieldResourceTest.php b/tests/Unit/Resources/FieldResourceTest.php new file mode 100644 index 0000000..d550974 --- /dev/null +++ b/tests/Unit/Resources/FieldResourceTest.php @@ -0,0 +1,124 @@ +http = new FakeHttpClient(); + $this->fields = new FieldResource($this->http, new Configuration('key', 'acc')); + } + + public function testCreateMergesOptions(): void + { + $this->http->queueJson(200, ['id' => 'f1', 'type' => 'text', 'name' => 'CPF']); + + $this->fields->create('text', 'CPF', ['regex' => '/x/', 'is_required' => false]); + + $call = $this->http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame('accounts/acc/fields', $call['uri']); + $this->assertSame( + ['type' => 'text', 'name' => 'CPF', 'regex' => '/x/', 'is_required' => false], + $call['body'] + ); + } + + public function testCreateRejectsEmptyType(): void + { + $this->expectException(ValidationException::class); + $this->fields->create('', 'Name'); + } + + public function testCreateRejectsEmptyName(): void + { + $this->expectException(ValidationException::class); + $this->fields->create('text', ''); + } + + public function testListSendsBooleanFlagsOnlyWhenTrue(): void + { + $this->http->queueJson(200, []); + $this->fields->list(true, true); + $this->assertSame( + ['include_inactive' => 'true', 'include_standard' => 'true'], + $this->http->lastCall()['query'] + ); + + $this->http->queueJson(200, []); + $this->fields->list(); + $this->assertSame([], $this->http->lastCall()['query']); + } + + public function testGetUpdateDeletePaths(): void + { + $this->http->queueJson(200, ['id' => 'f1']); + $this->fields->get('f1'); + $this->assertSame('accounts/acc/fields/f1', $this->http->lastCall()['uri']); + + $this->http->queueJson(200, ['id' => 'f1', 'name' => 'New']); + $this->fields->update('f1', ['name' => 'New']); + $put = $this->http->lastCall(); + $this->assertSame('PUT', $put['method']); + $this->assertSame('accounts/acc/fields/f1', $put['uri']); + + $this->http->queueJson(200, []); + $this->fields->delete('f1'); + $this->assertSame('DELETE', $this->http->lastCall()['method']); + } + + public function testValidateAsUserSendsNoAccessCode(): void + { + $this->http->queueJson(200, ['success' => true]); + + $this->fields->validate('f1', '400.676.228-36'); + + $call = $this->http->lastCall(); + $this->assertSame('accounts/acc/fields/f1/validate', $call['uri']); + $this->assertSame(['value' => '400.676.228-36'], $call['body']); + $this->assertSame([], $call['query']); + } + + public function testValidateAsSignerSendsAccessCode(): void + { + $this->http->queueJson(200, ['success' => true]); + $this->fields->validate('f1', 'x', 'CODE'); + $this->assertSame(['signer-access-code' => 'CODE'], $this->http->lastCall()['query']); + } + + public function testValidateMultipleSendsArrayBody(): void + { + $this->http->queueJson(200, []); + + $values = [ + ['field_id' => 'f1', 'value' => '1'], + ['field_id' => 'f2', 'value' => 'a@b.com'], + ]; + $this->fields->validateMultiple($values, 'CODE'); + + $call = $this->http->lastCall(); + $this->assertSame('accounts/acc/fields/validate-multiple', $call['uri']); + $this->assertSame($values, $call['body']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testTypesHitsGlobalEndpoint(): void + { + $this->http->queueJson(200, [['type' => 'text', 'name' => 'Text']]); + $result = $this->fields->types(); + $this->assertSame('field-types', $this->http->lastCall()['uri']); + $this->assertSame('text', $result[0]['type']); + } +} diff --git a/tests/Unit/Resources/SignerDocumentResourceTest.php b/tests/Unit/Resources/SignerDocumentResourceTest.php new file mode 100644 index 0000000..18bd701 --- /dev/null +++ b/tests/Unit/Resources/SignerDocumentResourceTest.php @@ -0,0 +1,102 @@ +http = new FakeHttpClient(); + $this->docs = new SignerDocumentResource($this->http, new Configuration('key', 'acc')); + } + + public function testCurrentSendsAccessCode(): void + { + $this->http->queueJson(200, ['id' => 'd1']); + + $this->docs->current('s1', 'CODE'); + + $call = $this->http->lastCall(); + $this->assertSame('GET', $call['method']); + $this->assertSame('signers/s1/document', $call['uri']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testListMergesFilters(): void + { + $this->http->queueJson(200, []); + + $this->docs->list('s1', 'CODE', ['status' => 'pending_signature']); + + $this->assertSame( + ['signer-access-code' => 'CODE', 'status' => 'pending_signature'], + $this->http->lastCall()['query'] + ); + } + + public function testSignMultipleBodyAndQuery(): void + { + $this->http->queueJson(200, []); + + $this->docs->signMultiple('CODE', ['d1', 'd2']); + + $call = $this->http->lastCall(); + $this->assertSame('PUT', $call['method']); + $this->assertSame('signers/documents/sign-multiple', $call['uri']); + $this->assertSame(['document_ids' => ['d1', 'd2']], $call['body']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testSignMultipleRejectsEmpty(): void + { + $this->expectException(ValidationException::class); + $this->docs->signMultiple('CODE', []); + } + + public function testDeclineMultipleSendsReason(): void + { + $this->http->queueJson(200, []); + + $this->docs->declineMultiple('CODE', ['d1'], 'Bad terms'); + + $call = $this->http->lastCall(); + $this->assertSame('signers/documents/decline-multiple', $call['uri']); + $this->assertSame(['document_ids' => ['d1'], 'decline_reason' => 'Bad terms'], $call['body']); + } + + public function testDeclineMultipleRejectsEmptyReason(): void + { + $this->expectException(ValidationException::class); + $this->docs->declineMultiple('CODE', ['d1'], ''); + } + + public function testDownloadReturnsBodyAndValidatesArtifact(): void + { + $this->http->queueRaw(200, '%PDF-1.4 binary'); + + $bytes = $this->docs->download('s1', 'd1', 'CODE', DocumentResource::ARTIFACT_CERTIFICATED); + + $call = $this->http->lastCall(); + $this->assertSame('signers/s1/documents/d1/download/certificated', $call['uri']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + $this->assertStringStartsWith('%PDF', $bytes); + } + + public function testDownloadRejectsUnknownArtifact(): void + { + $this->expectException(ValidationException::class); + $this->docs->download('s1', 'd1', 'CODE', 'nope'); + } +} diff --git a/tests/Unit/Resources/SignerSessionResourceTest.php b/tests/Unit/Resources/SignerSessionResourceTest.php index 5ecfcee..07160da 100644 --- a/tests/Unit/Resources/SignerSessionResourceTest.php +++ b/tests/Unit/Resources/SignerSessionResourceTest.php @@ -106,4 +106,53 @@ public function testDownloadSignature(): void $this->assertSame('signature/initial', $call['uri']); $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); } + + public function testCurrentDocumentHitsSignEndpoint(): void + { + $this->http->queueJson(200, ['id' => 'doc1']); + $this->session->currentDocument('CODE'); + + $call = $this->http->lastCall(); + $this->assertSame('GET', $call['method']); + $this->assertSame('sign', $call['uri']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testSignPostsFieldArrayWithAccessCodeOnQuery(): void + { + $this->http->queueJson(200, []); + + $fields = [['itemId' => 'i1', 'fieldId' => 'f1', 'pageId' => 'p1', 'value' => 'Signed']]; + $this->session->sign('doc1', 'a1', 'CODE', $fields); + + $call = $this->http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame('documents/doc1/assignments/a1', $call['uri']); + $this->assertSame($fields, $call['body']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testSignRejectsEmptyFields(): void + { + $this->expectException(ValidationException::class); + $this->session->sign('doc1', 'a1', 'CODE', []); + } + + public function testDeclineSendsReasonAndAccessCode(): void + { + $this->http->queueJson(200, []); + $this->session->decline('doc1', 'a1', 'CODE', 'No thanks'); + + $call = $this->http->lastCall(); + $this->assertSame('PUT', $call['method']); + $this->assertSame('documents/doc1/assignments/a1/reject', $call['uri']); + $this->assertSame(['decline_reason' => 'No thanks'], $call['body']); + $this->assertSame(['signer-access-code' => 'CODE'], $call['query']); + } + + public function testDeclineRejectsEmptyReason(): void + { + $this->expectException(ValidationException::class); + $this->session->decline('doc1', 'a1', 'CODE', ''); + } } diff --git a/tests/Unit/Resources/TagResourceTest.php b/tests/Unit/Resources/TagResourceTest.php new file mode 100644 index 0000000..fb47224 --- /dev/null +++ b/tests/Unit/Resources/TagResourceTest.php @@ -0,0 +1,104 @@ +http = new FakeHttpClient(); + $this->tags = new TagResource($this->http, new Configuration('key', 'acc')); + } + + public function testListUnwrapsDataAndOmitsEmptySearch(): void + { + $this->http->queueJson(200, [['id' => 't1', 'name' => 'Contracts']]); + + $result = $this->tags->list(); + + $call = $this->http->lastCall(); + $this->assertSame('GET', $call['method']); + $this->assertSame('accounts/acc/tags', $call['uri']); + $this->assertSame([], $call['query']); + $this->assertSame('t1', $result[0]['id']); + } + + public function testListPassesSearch(): void + { + $this->http->queueJson(200, []); + $this->tags->list('contract'); + $this->assertSame(['search' => 'contract'], $this->http->lastCall()['query']); + } + + public function testCreateSendsNameAndColor(): void + { + $this->http->queueJson(200, ['id' => 't1', 'name' => 'Contracts', 'color' => 'ff8800']); + + $this->tags->create('Contracts', 'ff8800'); + + $call = $this->http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame('accounts/acc/tags', $call['uri']); + $this->assertSame(['name' => 'Contracts', 'color' => 'ff8800'], $call['body']); + } + + public function testCreateOmitsColorWhenNull(): void + { + $this->http->queueJson(200, ['id' => 't1']); + $this->tags->create('Contracts'); + $this->assertSame(['name' => 'Contracts'], $this->http->lastCall()['body']); + } + + public function testCreateRejectsBlankName(): void + { + $this->expectException(ValidationException::class); + $this->tags->create(' '); + } + + public function testUpdatePathAndBody(): void + { + $this->http->queueJson(200, ['id' => 't1', 'name' => 'New']); + + $this->tags->update('t1', ['name' => 'New', 'color' => null]); + + $call = $this->http->lastCall(); + $this->assertSame('PUT', $call['method']); + $this->assertSame('accounts/acc/tags/t1', $call['uri']); + $this->assertSame(['name' => 'New', 'color' => null], $call['body']); + } + + public function testUpdateRejectsEmptyPayload(): void + { + $this->expectException(ValidationException::class); + $this->tags->update('t1', []); + } + + public function testDeleteWithoutForce(): void + { + $this->http->queueJson(200, ['deleted' => true]); + $this->tags->delete('t1'); + + $call = $this->http->lastCall(); + $this->assertSame('DELETE', $call['method']); + $this->assertSame('accounts/acc/tags/t1', $call['uri']); + $this->assertSame([], $call['query']); + } + + public function testDeleteWithForceSendsQuery(): void + { + $this->http->queueJson(200, ['deleted' => true]); + $this->tags->delete('t1', true); + $this->assertSame(['force' => 'true'], $this->http->lastCall()['query']); + } +} diff --git a/tests/Unit/Resources/WebhookResourceTest.php b/tests/Unit/Resources/WebhookResourceTest.php index cf9b939..79fe508 100644 --- a/tests/Unit/Resources/WebhookResourceTest.php +++ b/tests/Unit/Resources/WebhookResourceTest.php @@ -68,28 +68,22 @@ public function testGet(): void $this->assertSame('accounts/a/webhooks/subscriptions', $http->lastCall()['uri']); } - public function testDeactivatePreservesConfigAndFlipsIsActive(): void + public function testDeactivateUsesDedicatedInactivateEndpoint(): void { [$http, $webhooks] = $this->build(); - // GET current $http->queueJson(200, [ 'url' => 'https://x', 'email' => 'a@b.com', 'events' => [WebhookResource::EVENT_DOCUMENT_READY], - 'is_active' => true, + 'is_active' => false, ]); - // PUT replacement - $http->queueJson(200, []); - $webhooks->deactivate(); + $result = $webhooks->deactivate(); $put = $http->lastCall(); $this->assertSame('PUT', $put['method']); - $this->assertSame('accounts/a/webhooks/subscriptions', $put['uri']); - $this->assertSame('https://x', $put['body']['url']); - $this->assertSame('a@b.com', $put['body']['email']); - $this->assertSame([WebhookResource::EVENT_DOCUMENT_READY], $put['body']['events']); - $this->assertFalse($put['body']['is_active']); + $this->assertSame('accounts/a/webhooks/inactivate', $put['uri']); + $this->assertFalse($result['is_active']); } public function testActivateReusesExistingConfig(): void @@ -108,12 +102,49 @@ public function testActivateReusesExistingConfig(): void $this->assertTrue($http->lastCall()['body']['is_active']); } - public function testDeactivateThrowsWhenNoSubscription(): void + public function testActivateThrowsWhenNoSubscription(): void { [$http, $webhooks] = $this->build(); $http->queueJson(200, []); $this->expectException(\RuntimeException::class); - $webhooks->deactivate(); + $webhooks->activate(); + } + + public function testEventTypesHitsGlobalEndpoint(): void + { + [$http, $webhooks] = $this->build(); + $http->queueJson(200, [['id' => 'document_ready', 'description' => 'x']]); + + $result = $webhooks->eventTypes(); + + $this->assertSame('webhooks/event-types', $http->lastCall()['uri']); + $this->assertSame('document_ready', $result[0]['id']); + } + + public function testDispatchesReturnsEnvelopeWithFilters(): void + { + [$http, $webhooks] = $this->build(); + $http->queueJson(200, [['id' => 'd1', 'event' => 'document_ready']]); + + $result = $webhooks->dispatches(['event' => 'document_ready', 'delivered' => 'false']); + + $call = $http->lastCall(); + $this->assertSame('GET', $call['method']); + $this->assertSame('accounts/a/webhooks', $call['uri']); + $this->assertSame(['event' => 'document_ready', 'delivered' => 'false'], $call['query']); + $this->assertArrayHasKey('data', $result); + } + + public function testRetryDispatch(): void + { + [$http, $webhooks] = $this->build(); + $http->queueJson(200, ['id' => 'd1', 'delivered' => true]); + + $webhooks->retryDispatch('d1'); + + $call = $http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame('accounts/a/webhooks/d1/retry', $call['uri']); } } diff --git a/tests/Unit/Support/FakeHttpClient.php b/tests/Unit/Support/FakeHttpClient.php index aa47b8c..f4fde8e 100644 --- a/tests/Unit/Support/FakeHttpClient.php +++ b/tests/Unit/Support/FakeHttpClient.php @@ -59,9 +59,9 @@ public function put(string $uri, array $data = [], array $headers = [], array $q return $this->record('PUT', $uri, ['body' => $data, 'headers' => $headers, 'query' => $query]); } - public function delete(string $uri, array $headers = []): Response + public function delete(string $uri, array $headers = [], array $query = []): Response { - return $this->record('DELETE', $uri, ['headers' => $headers]); + return $this->record('DELETE', $uri, ['headers' => $headers, 'query' => $query]); } public function uploadFile(string $uri, string $filePath, array $data = [], array $headers = []): Response