diff --git a/CHANGELOG.md b/CHANGELOG.md index 45608ef..743a737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.0] - 2026-10-16 + ### Added - Initial release of the STROMCOM Customer SDK for PHP. @@ -27,3 +28,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PHPUnit 11 test suite (78 tests, 210 assertions), PHPStan level 9, php-cs-fixer config. - GitHub Actions CI for PHP 8.3 and 8.4. +- `MessageResource::reactions()` — list emoji reactions on a message, + aggregated by code (`MessageReaction`, `MessageReactionUser` models). +- `ThreadResource::create()` — upsert a thread by code, optionally with an + initial message and attachments. +- `ThreadResource::update()` — update thread `name` / `url` / `attributes`. +- `MessageResource::create()` — send a message (with attachments) in an + existing thread. + +### Fixed + +- `CurlHttpClient` rejects an empty HTTP method or URL with `TransportException` instead of passing it to cURL. +- README: OpenAPI spec link now points to `/docs/customer/openapi.json` (was `swagger.json`). +- `examples/paginate_messages.php` rewritten as incremental sync — the `lastMessageHash` cursor is inclusive and the API has no page size. + +### Documentation + +- README: how the `lastMessageHash` cursor behaves (oldest-first, inclusive, no page size). +- README: 402 plan-limit and 403 `ACTION_FORBIDDEN` errors surface as `ApiException`; branch on `getApiCode()`. +- README: required token type (`customer` / `both`, server-side only) and where to create it. +- README: known limitation — response `meta` (warnings, `meta.updated`) is not exposed. diff --git a/README.md b/README.md index b5ff49c..2ba7301 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ composer require stromcom/customer-sdk ## API access & documentation -- **Reference docs:** -- **OpenAPI spec:** +- **Reference docs (Swagger UI):** +- **OpenAPI spec:** API access is gated by your STROMCOM subscription — only plans that explicitly include API access can issue Customer API tokens and call @@ -23,6 +23,11 @@ the endpoints below. If your token returns `401 Unauthorized` or your project's `allowAPI` flag is `false`, check your current plan in the STROMCOM console or contact support to upgrade. +The token must be an API key of type **`customer`** (or `both`), created +in the customer panel under *Project → Credentials* +(`/customer/project/{id}/credentials/`). It grants full access to the +project — use it **server-side only**, never in browser or mobile code. + ## Quick start ```php @@ -54,6 +59,26 @@ $stromcom->users()->update($hash, ['name' => 'Jane Smith']); $stromcom->users()->delete($hash); // soft delete, idempotent $stromcom->users()->restore($hash); +// Create a thread (upsert by code) with an optional initial message. +// The author must be identified via user_hash/user_code when a message/ +// attachments are present — the request isn't bound to a logged-in user. +$created = $stromcom->threads()->create([ + 'code' => 'order-1234', + 'name' => 'Order #1234', + 'message' => '

Your order has shipped.

', + 'user_code' => 'employee-42', +]); +$thread = $created['thread']; // Thread +$message = $created['message']; // ?Message + +$stromcom->threads()->update('order-1234', ['name' => 'Order #1234 (shipped)']); + +// Send a follow-up message in the same thread +$stromcom->messages()->create('order-1234', [ + 'message' => '

Tracking number: 1Z999AA10123456784

', + 'user_code' => 'employee-42', +]); + // Thread + messages + attachments in one round-trip $result = $stromcom->threads()->getWithMessages('order-1234'); $thread = $result['thread']; // Thread @@ -87,8 +112,10 @@ covering each area: | [`project_settings.php`](examples/project_settings.php) | Read & PATCH project policy flags. | | [`users_lifecycle.php`](examples/users_lifecycle.php) | Create → fetch → update → delete → restore. | | [`thread_with_messages.php`](examples/thread_with_messages.php) | Thread + messages + attachments in one call. | -| [`paginate_messages.php`](examples/paginate_messages.php) | Cursor pagination via `lastMessageHash`. | +| [`create_thread_and_message.php`](examples/create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. | +| [`paginate_messages.php`](examples/paginate_messages.php) | Incremental sync via the `lastMessageHash` cursor. | | [`download_attachment.php`](examples/download_attachment.php) | Resolve and save attachments to disk. | +| [`message_reactions.php`](examples/message_reactions.php) | List emoji reactions on a message, aggregated by code. | | [`error_handling.php`](examples/error_handling.php) | Catch the typed exception hierarchy. | | [`custom_http_client.php`](examples/custom_http_client.php) | Plug a custom `HttpClientInterface`. | @@ -114,6 +141,7 @@ keys land as `null`. | `Message`, `MessageList` | `messages()->list()`, `threads()->getWithMessages()` | | `MessageAttachment` | `messages()->attachments()`, `MessageList::attachmentsFor()` | | `AttachmentDownload` | `messages()->attachmentDownload()` | +| `MessageReaction`, `MessageReactionUser` | `messages()->reactions()` | ## Method reference @@ -131,16 +159,34 @@ void $stromcom->users()->delete(string $hash); User $stromcom->users()->restore(string $hash); // Threads +array{thread: Thread, message: ?Message, attachmentsUploadURL: list} $stromcom->threads()->create(array $payload); +Thread $stromcom->threads()->update(string $code, array $changes); Thread $stromcom->threads()->get(string $code, array $expand = []); array{thread: Thread, messages: MessageList} $stromcom->threads()->getWithMessages(string $code); // Messages +array{message: Message, attachmentsUploadURL: list} $stromcom->messages()->create(string $threadCode, array $payload); MessageList $stromcom->messages()->list(string $threadCode, ?string $lastMessageHash = null); void $stromcom->messages()->delete(string $threadCode, string $messageHash); list $stromcom->messages()->attachments(string $threadCode, string $messageHash); AttachmentDownload $stromcom->messages()->attachmentDownload(string $threadCode, string $messageHash, string $attachmentHash); +list $stromcom->messages()->reactions(string $threadCode, string $messageHash); ``` +### Message cursor (`lastMessageHash`) + +`messages()->list($threadCode, $lastMessageHash)` is not page-based: + +- messages are returned **oldest-first**; +- the cursor is **inclusive** — the first returned message is the cursor + message itself; +- there is **no page size** — the server returns every message from the + cursor onward (limited only by your plan's history window). + +Use it for incremental sync: store the hash of the newest message you +have, fetch from it next time and drop the first item when it equals the +cursor. See [`examples/paginate_messages.php`](examples/paginate_messages.php). + ## Configuration ```php @@ -170,11 +216,11 @@ All SDK exceptions extend `Stromcom\Sdk\Exception\StromcomException`. | `AuthenticationException` | 401 — invalid / missing token | | `NotFoundException` | 404 — resource not found | | `ValidationException` | 400 / 422 — invalid input | -| `ConflictException` | 409 — e.g. user code already exists, limit reached | +| `ConflictException` | 409 — e.g. user code already exists | | `RateLimitException` | 429 — slow down | | `ServerException` | 5xx | | `TransportException` | network failure / non-JSON response | -| `ApiException` | catch-all base for any other API error | +| `ApiException` | base class; thrown as-is for any other status, notably 402 and 403 (see below) | Every API exception exposes: @@ -189,9 +235,42 @@ try { } ``` +Plan limits (**402**) and forbidden actions (**403**) have no dedicated +subclass — they surface as a plain `ApiException`. Catch the specific +exceptions first, then `ApiException`, and branch on `getApiCode()`: + +| Status | `getApiCode()` | +|---|---| +| 402 | `USER_LIMIT_REACHED`, `THREAD_LIMIT_REACHED`, `MESSAGE_LIMIT_REACHED`, `STORAGE_LIMIT_REACHED`, `MESSAGE_STORAGE_LIMIT_REACHED`, `WEBHOOK_LIMIT_REACHED` | +| 403 | `ACTION_FORBIDDEN` | + +```php +use Stromcom\Sdk\Exception\ApiException; +use Stromcom\Sdk\Exception\ValidationException; + +try { + $stromcom->messages()->create('order-1234', $payload); +} catch (ValidationException $e) { + // fix the payload +} catch (ApiException $e) { + match ($e->getApiCode()) { + 'MESSAGE_LIMIT_REACHED', 'STORAGE_LIMIT_REACHED' => notifyAdminToUpgrade($e), + 'ACTION_FORBIDDEN' => logForbidden($e), + default => throw $e, + }; +} +``` + A worked example of catching every variant lives in [`examples/error_handling.php`](examples/error_handling.php). +## Known limitations + +- The SDK returns only the `data` part of the API response envelope. The + `meta` part (e.g. `meta.warnings`, `meta.updated`) is dropped and is not + reachable through the SDK. If you need it, call the API directly or wrap + your own `HttpClientInterface` to inspect the raw response. + ## Requirements - PHP **8.3** or **8.4** diff --git a/composer.json b/composer.json index 50904f0..d96fdc0 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "ca": [ "@phpstan", - "@cs-check" + "@cs" ] }, "autoload": { diff --git a/examples/README.md b/examples/README.md index 06ebc33..386bec7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,8 +18,10 @@ php examples/quickstart.php | [`project_settings.php`](project_settings.php) | Read project settings, then PATCH a couple of policy flags. | | [`users_lifecycle.php`](users_lifecycle.php) | Full user lifecycle: create → fetch → update → delete → restore. | | [`thread_with_messages.php`](thread_with_messages.php) | Single-call fetch of a thread, its messages, and grouped attachments. | -| [`paginate_messages.php`](paginate_messages.php) | Walk every message in a thread via the `lastMessageHash` cursor. | +| [`create_thread_and_message.php`](create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. | +| [`paginate_messages.php`](paginate_messages.php) | Incremental sync of new messages via the inclusive `lastMessageHash` cursor. | | [`download_attachment.php`](download_attachment.php) | Resolve attachment metadata, fetch a signed URL, save to disk. | +| [`message_reactions.php`](message_reactions.php) | List emoji reactions on a message, aggregated by code. | | [`error_handling.php`](error_handling.php) | Catch the typed exception hierarchy (`NotFound`, `Validation`, …). | | [`custom_http_client.php`](custom_http_client.php) | Plug a custom `HttpClientInterface` (logging wrapper here). | diff --git a/examples/create_thread_and_message.php b/examples/create_thread_and_message.php new file mode 100644 index 0000000..d66e87d --- /dev/null +++ b/examples/create_thread_and_message.php @@ -0,0 +1,55 @@ +create($payload) + * → ['thread' => Thread, 'message' => ?Message, 'attachmentsUploadURL' => list] + * threads()->update($code, $changes) + * → Thread + * messages()->create($threadCode, $payload) + * → ['message' => Message, 'attachmentsUploadURL' => list] + * + * Requests are not bound to a logged-in user — whenever a message or + * attachments are present, the author must be identified via `user_hash` + * or `user_code`. + * + * Run with: + * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_USER_CODE=employee-42 \ + * php examples/create_thread_and_message.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use Stromcom\Sdk\Client; + +$token = getenv('STROMCOM_TOKEN') ?: ''; +$threadCode = getenv('STROMCOM_THREAD') ?: ''; +$userCode = getenv('STROMCOM_USER_CODE') ?: ''; +if ($token === '' || $threadCode === '' || $userCode === '') { + fwrite(STDERR, "Set STROMCOM_TOKEN, STROMCOM_THREAD and STROMCOM_USER_CODE.\n"); exit(1); +} + +$stromcom = new Client($token); + +$created = $stromcom->threads()->create([ + 'code' => $threadCode, + 'name' => "Order {$threadCode}", + 'message' => '

Your order has shipped.

', + 'user_code' => $userCode, +]); +printf("Thread created/updated: %s (%s)\n", $created['thread']->name, $created['thread']->hash); +if ($created['message'] !== null) { + printf("Initial message: %s\n", $created['message']->hash); +} + +$updated = $stromcom->threads()->update($threadCode, ['name' => "Order {$threadCode} (shipped)"]); +printf("Thread renamed to: %s\n", $updated->name); + +$sent = $stromcom->messages()->create($threadCode, [ + 'message' => '

Tracking number: 1Z999AA10123456784

', + 'user_code' => $userCode, +]); +printf("Follow-up message: %s\n", $sent['message']->hash); diff --git a/examples/message_reactions.php b/examples/message_reactions.php new file mode 100644 index 0000000..7a11bb9 --- /dev/null +++ b/examples/message_reactions.php @@ -0,0 +1,46 @@ +reactions($threadCode, $messageHash) + * → list (code, count, currentUserReacted, users) + * + * Read-only: reacting to a message is only available to end users via the + * App API, not to the customer credential. + * + * Run with: + * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_MESSAGE=eVO6SY5kf5 \ + * php examples/message_reactions.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use Stromcom\Sdk\Client; + +$token = getenv('STROMCOM_TOKEN') ?: ''; +$threadCode = getenv('STROMCOM_THREAD') ?: ''; +$messageHash = getenv('STROMCOM_MESSAGE') ?: ''; +if ($token === '' || $threadCode === '' || $messageHash === '') { + fwrite(STDERR, "Set STROMCOM_TOKEN, STROMCOM_THREAD and STROMCOM_MESSAGE.\n"); exit(1); +} + +$stromcom = new Client($token); +$reactions = $stromcom->messages()->reactions($threadCode, $messageHash); + +if ($reactions === []) { + echo "Message has no reactions.\n"; + exit(0); +} + +foreach ($reactions as $reaction) { + $userNames = implode(', ', array_map(static fn($user) => $user->name, $reaction->users)); + printf( + "%s x%d%s — %s\n", + $reaction->code, + $reaction->count, + $reaction->currentUserReacted ? ' (you)' : '', + $userNames, + ); +} diff --git a/examples/paginate_messages.php b/examples/paginate_messages.php index 96d9a1c..f226a48 100644 --- a/examples/paginate_messages.php +++ b/examples/paginate_messages.php @@ -2,52 +2,65 @@ declare(strict_types=1); /** - * Paginate through every message in a thread. + * Incremental sync of a thread's messages via the `lastMessageHash` cursor. * - * The `lastMessageHash` query parameter is a cursor: pass the hash of - * the last message you've already seen to get the next batch. The - * Customer API returns messages in chronological order; the loop - * below stops when an empty page comes back. + * How the cursor works on the Customer API: + * - messages come back oldest-first; + * - `lastMessageHash` is INCLUSIVE — the first returned item is the + * cursor message itself; + * - there is no page size — the server returns every message from the + * cursor onward (bounded only by your plan's history window). + * + * So there is nothing to "page through": fetch once without a cursor, + * remember the hash of the newest message, and on the next sync fetch + * from that hash and drop the first item (you already have it). * * Run with: * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 php examples/paginate_messages.php + * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_CURSOR= php examples/paginate_messages.php */ require __DIR__ . '/../vendor/autoload.php'; use Stromcom\Sdk\Client; +use Stromcom\Sdk\Configuration; +use Stromcom\Sdk\Model\Message; $token = getenv('STROMCOM_TOKEN') ?: ''; $threadCode = getenv('STROMCOM_THREAD') ?: ''; +$baseUrl = getenv('STROMCOM_BASE_URL') ?: 'https://www.stromcom.cz/api/customer/v1/'; if ($token === '' || $threadCode === '') { fwrite(STDERR, "Set STROMCOM_TOKEN and STROMCOM_THREAD.\n"); exit(1); } -$stromcom = new Client($token); - -$cursor = null; -$total = 0; +$stromcom = new Client(new Configuration(token: $token, baseUrl: $baseUrl)); -do { - $page = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor); - $count = count($page->messages); - $total += $count; +/** + * Return messages newer than $cursor (all messages when $cursor is null). + * + * @return list + */ +function fetchNewMessages(Client $stromcom, string $threadCode, ?string $cursor): array { + $messages = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor)->messages; - foreach ($page->messages as $msg) { - printf("%-12s %s\n", $msg->hash, strip_tags($msg->message)); + if ($cursor !== null && $messages !== [] && $messages[0]->hash === $cursor) { + array_shift($messages); } - if ($count === 0) { - break; - } + return $messages; +} - $cursor = $page->messages[$count - 1]->hash; +// In a real integration the cursor lives in your database, per thread. +$cursor = getenv('STROMCOM_CURSOR') ?: null; - // First-page-only single fetch when the API serves the whole thread - // in one shot — guard against an infinite loop on small threads. - if ($count < 50) { - break; - } -} while (true); +$newMessages = fetchNewMessages($stromcom, $threadCode, $cursor); + +foreach ($newMessages as $message) { + printf("%-12s %s\n", $message->hash, strip_tags($message->message)); +} + +if ($newMessages !== []) { + $cursor = $newMessages[array_key_last($newMessages)]->hash; +} -printf("\nTotal messages walked: %d\n", $total); +printf("\nNew messages: %d\nNext cursor: %s\n", count($newMessages), $cursor ?? '(none)'); diff --git a/src/Http/CurlHttpClient.php b/src/Http/CurlHttpClient.php index a6265c2..0d44b63 100644 --- a/src/Http/CurlHttpClient.php +++ b/src/Http/CurlHttpClient.php @@ -18,6 +18,10 @@ public function request( array $headers = [], ?string $body = null, ): RawResponse { + if ($method === '' || $url === '') { + throw new TransportException('HTTP method and URL must not be empty.'); + } + $ch = curl_init(); if ($ch === false) { throw new TransportException('Could not initialize cURL.'); diff --git a/src/Model/MessageReaction.php b/src/Model/MessageReaction.php new file mode 100644 index 0000000..243fcea --- /dev/null +++ b/src/Model/MessageReaction.php @@ -0,0 +1,43 @@ + $users */ + public function __construct( + public readonly string $code, + public readonly int $count, + public readonly bool $currentUserReacted, + public readonly array $users, + ) { + } + + public static function fromArray(array $data): static { + /** @var list> $rawUsers */ + $rawUsers = isset($data['users']) && is_array($data['users']) ? array_values($data['users']) : []; + + return new self( + code: self::requireString($data, 'code'), + count: self::readInt($data, 'count') ?? 0, + currentUserReacted: self::readBool($data, 'currentUserReacted') ?? false, + users: MessageReactionUser::listFromArray($rawUsers), + ); + } + + /** + * @param list> $items + * + * @return list + */ + public static function listFromArray(array $items): array { + return array_map(static fn(array $row): self => self::fromArray($row), $items); + } + +} diff --git a/src/Model/MessageReactionUser.php b/src/Model/MessageReactionUser.php new file mode 100644 index 0000000..97062cc --- /dev/null +++ b/src/Model/MessageReactionUser.php @@ -0,0 +1,33 @@ +> $items + * + * @return list + */ + public static function listFromArray(array $items): array { + return array_map(static fn(array $row): self => self::fromArray($row), $items); + } + +} diff --git a/src/Resource/MessageResource.php b/src/Resource/MessageResource.php index 1088e08..390d3dd 100644 --- a/src/Resource/MessageResource.php +++ b/src/Resource/MessageResource.php @@ -4,14 +4,44 @@ namespace Stromcom\Sdk\Resource; use Stromcom\Sdk\Model\AttachmentDownload; +use Stromcom\Sdk\Model\Message; use Stromcom\Sdk\Model\MessageAttachment; use Stromcom\Sdk\Model\MessageList; +use Stromcom\Sdk\Model\MessageReaction; /** - * `/threads/{code}/messages/` — read messages and attachments, admin-delete. + * `/threads/{code}/messages/` — create, read messages and attachments, admin-delete. */ final class MessageResource extends AbstractResource { + /** + * POST /threads/{code}/messages/ — create a message in an existing thread. + * The author is identified via `user_hash` or `user_code` in $payload + * (requests are not bound to a logged-in user). Attachments are uploaded + * in two steps: pass file names in `attachments`, then PUT each file's + * bytes to the matching `attachmentsUploadURL` entry. + * + * @param array $payload + * + * @return array{message: Message, attachmentsUploadURL: list} + */ + public function create(string $threadCode, array $payload): array { + /** @var array $data */ + $data = $this->client->request('POST', "threads/{$threadCode}/messages/", body: $payload) ?? []; + + /** @var array $messageData */ + $messageData = isset($data['message']) && is_array($data['message']) ? $data['message'] : []; + /** @var list $uploadURLs */ + $uploadURLs = isset($data['attachmentsUploadURL']) && is_array($data['attachmentsUploadURL']) + ? array_values($data['attachmentsUploadURL']) + : []; + + return [ + 'message' => Message::fromArray($messageData), + 'attachmentsUploadURL' => $uploadURLs, + ]; + } + /** * GET /threads/{code}/messages/ * @@ -57,4 +87,19 @@ public function attachmentDownload(string $threadCode, string $messageHash, stri return AttachmentDownload::fromArray($data); } + /** + * GET /threads/{code}/messages/{messageHash}/reactions/ — emoji reactions + * aggregated by code. Read-only: reacting is only available to end users + * via the App API, not to the customer credential. + * + * @return list + */ + public function reactions(string $threadCode, string $messageHash): array { + /** @var array $data */ + $data = $this->client->request('GET', "threads/{$threadCode}/messages/{$messageHash}/reactions/") ?? []; + /** @var list> $rawReactions */ + $rawReactions = isset($data['reactions']) && is_array($data['reactions']) ? array_values($data['reactions']) : []; + return MessageReaction::listFromArray($rawReactions); + } + } diff --git a/src/Resource/ThreadResource.php b/src/Resource/ThreadResource.php index 171180e..08ee660 100644 --- a/src/Resource/ThreadResource.php +++ b/src/Resource/ThreadResource.php @@ -3,14 +3,58 @@ namespace Stromcom\Sdk\Resource; +use Stromcom\Sdk\Model\Message; use Stromcom\Sdk\Model\MessageList; use Stromcom\Sdk\Model\Thread; /** - * `/threads/` — read threads bound to your business entities by `code`. + * `/threads/` — create, read and update threads bound to your business + * entities by `code`. */ final class ThreadResource extends AbstractResource { + /** + * POST /threads/ — create a thread (upsert by `code` — an existing thread + * with the same code is updated). Optionally include an initial `message`; + * when `message` or `attachments` are present in $payload, the author must + * be identified via `user_hash` or `user_code`. + * + * @param array $payload + * + * @return array{thread: Thread, message: ?Message, attachmentsUploadURL: list} + */ + public function create(array $payload): array { + /** @var array $data */ + $data = $this->client->request('POST', 'threads/', body: $payload) ?? []; + + /** @var array $threadData */ + $threadData = isset($data['thread']) && is_array($data['thread']) ? $data['thread'] : []; + /** @var array|null $messageData */ + $messageData = isset($data['message']) && is_array($data['message']) ? $data['message'] : null; + /** @var list $uploadURLs */ + $uploadURLs = isset($data['attachmentsUploadURL']) && is_array($data['attachmentsUploadURL']) + ? array_values($data['attachmentsUploadURL']) + : []; + + return [ + 'thread' => Thread::fromArray($threadData), + 'message' => $messageData !== null ? Message::fromArray($messageData) : null, + 'attachmentsUploadURL' => $uploadURLs, + ]; + } + + /** + * PUT /threads/{code}/ — update thread metadata (`name`, `url`, + * `attributes`). Only the keys present in $changes are written. + * + * @param array $changes + */ + public function update(string $code, array $changes): Thread { + /** @var array $data */ + $data = $this->client->request('PUT', "threads/{$code}/", body: $changes) ?? []; + return Thread::fromArray($this->extractThread($data)); + } + /** * GET /threads/{code}/ * diff --git a/tests/Http/CurlHttpClientTest.php b/tests/Http/CurlHttpClientTest.php new file mode 100644 index 0000000..2c276db --- /dev/null +++ b/tests/Http/CurlHttpClientTest.php @@ -0,0 +1,23 @@ +expectException(TransportException::class); + + (new CurlHttpClient())->request($method, $url); + } + +} diff --git a/tests/Model/MessageReactionTest.php b/tests/Model/MessageReactionTest.php new file mode 100644 index 0000000..00f8419 --- /dev/null +++ b/tests/Model/MessageReactionTest.php @@ -0,0 +1,58 @@ + '👍', + 'count' => 2, + 'currentUserReacted' => true, + 'users' => [ + ['hash' => 'u1', 'name' => 'Jane'], + ['hash' => 'u2', 'name' => 'John'], + ], + ]); + + $this->assertSame('👍', $reaction->code); + $this->assertSame(2, $reaction->count); + $this->assertTrue($reaction->currentUserReacted); + $this->assertCount(2, $reaction->users); + $this->assertSame('u1', $reaction->users[0]->hash); + $this->assertSame('Jane', $reaction->users[0]->name); + } + + #[Test] + public function count_and_current_user_reacted_default_when_missing(): void { + $reaction = MessageReaction::fromArray(['code' => '👍']); + + $this->assertSame(0, $reaction->count); + $this->assertFalse($reaction->currentUserReacted); + $this->assertSame([], $reaction->users); + } + + #[Test] + public function throws_when_code_is_missing(): void { + $this->expectException(\UnexpectedValueException::class); + MessageReaction::fromArray(['count' => 1]); + } + + #[Test] + public function list_from_array_hydrates_each_row(): void { + $list = MessageReaction::listFromArray([ + ['code' => '👍', 'count' => 1, 'currentUserReacted' => false, 'users' => []], + ['code' => '🎉', 'count' => 3, 'currentUserReacted' => true, 'users' => []], + ]); + + $this->assertCount(2, $list); + $this->assertContainsOnlyInstancesOf(MessageReaction::class, $list); + } + +} diff --git a/tests/Resource/MessageResourceTest.php b/tests/Resource/MessageResourceTest.php index 94f6f23..585c76e 100644 --- a/tests/Resource/MessageResourceTest.php +++ b/tests/Resource/MessageResourceTest.php @@ -24,6 +24,47 @@ protected function setUp(): void { ); } + #[Test] + public function create_sends_payload_and_hydrates_message(): void { + $this->http->enqueueJson(201, [ + 'status' => 'success', + 'data' => [ + 'message' => ['hash' => 'm1', 'message' => '

Hi

', 'userName' => 'Jane'], + 'attachmentsUploadURL' => ['https://upload.example/invoice.pdf'], + ], + ]); + + $result = $this->client->messages()->create('order-1', [ + 'message' => '

Hi

', + 'user_code' => 'u1', + 'attachments' => ['invoice.pdf'], + ]); + + $this->assertSame('m1', $result['message']->hash); + $this->assertSame('Jane', $result['message']->userName); + $this->assertSame(['https://upload.example/invoice.pdf'], $result['attachmentsUploadURL']); + + $call = $this->http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame(self::BASE_URL . 'threads/order-1/messages/', $call['url']); + $this->assertSame( + ['message' => '

Hi

', 'user_code' => 'u1', 'attachments' => ['invoice.pdf']], + json_decode((string) $call['body'], true), + ); + } + + #[Test] + public function create_defaults_upload_urls_to_empty_list(): void { + $this->http->enqueueJson(201, [ + 'status' => 'success', + 'data' => ['message' => ['hash' => 'm1', 'message' => '

Hi

']], + ]); + + $result = $this->client->messages()->create('order-1', ['message' => '

Hi

']); + + $this->assertSame([], $result['attachmentsUploadURL']); + } + #[Test] public function list_returns_messages_and_attachments(): void { $this->http->enqueueJson(200, [ @@ -112,4 +153,48 @@ public function attachment_download_returns_signed_url_payload(): void { ); } + #[Test] + public function reactions_returns_aggregated_reactions(): void { + $this->http->enqueueJson(200, [ + 'status' => 'success', + 'data' => [ + 'reactions' => [ + [ + 'code' => '👍', + 'count' => 2, + 'currentUserReacted' => true, + 'users' => [ + ['hash' => 'u1', 'name' => 'Jane'], + ['hash' => 'u2', 'name' => 'John'], + ], + ], + ], + ], + ]); + + $reactions = $this->client->messages()->reactions('order-1', 'm1'); + + $this->assertCount(1, $reactions); + $this->assertSame('👍', $reactions[0]->code); + $this->assertSame(2, $reactions[0]->count); + $this->assertTrue($reactions[0]->currentUserReacted); + $this->assertCount(2, $reactions[0]->users); + $this->assertSame('u1', $reactions[0]->users[0]->hash); + $this->assertSame('Jane', $reactions[0]->users[0]->name); + $this->assertSame( + self::BASE_URL . 'threads/order-1/messages/m1/reactions/', + $this->http->lastCall()['url'], + ); + } + + #[Test] + public function reactions_returns_empty_list_when_message_has_none(): void { + $this->http->enqueueJson(200, [ + 'status' => 'success', + 'data' => ['reactions' => []], + ]); + + $this->assertSame([], $this->client->messages()->reactions('order-1', 'm1')); + } + } diff --git a/tests/Resource/ThreadResourceTest.php b/tests/Resource/ThreadResourceTest.php index 7bffc1d..055bb85 100644 --- a/tests/Resource/ThreadResourceTest.php +++ b/tests/Resource/ThreadResourceTest.php @@ -24,6 +24,68 @@ protected function setUp(): void { ); } + #[Test] + public function create_upserts_thread_without_message(): void { + $this->http->enqueueJson(201, [ + 'status' => 'success', + 'data' => [ + 'thread' => ['code' => 'order-1', 'hash' => 'eVO6SY5kf5', 'name' => 'Order 1'], + 'message' => null, + 'attachmentsUploadURL' => [], + ], + ]); + + $result = $this->client->threads()->create(['code' => 'order-1', 'name' => 'Order 1']); + + $this->assertSame('order-1', $result['thread']->code); + $this->assertNull($result['message']); + $this->assertSame([], $result['attachmentsUploadURL']); + + $call = $this->http->lastCall(); + $this->assertSame('POST', $call['method']); + $this->assertSame(self::BASE_URL . 'threads/', $call['url']); + $this->assertSame(['code' => 'order-1', 'name' => 'Order 1'], json_decode((string) $call['body'], true)); + } + + #[Test] + public function create_with_initial_message_hydrates_message_and_upload_urls(): void { + $this->http->enqueueJson(201, [ + 'status' => 'success', + 'data' => [ + 'thread' => ['code' => 'order-1', 'hash' => 'eVO6SY5kf5', 'name' => 'Order 1'], + 'message' => ['hash' => 'm1', 'message' => '

Hi

', 'userName' => 'Jane'], + 'attachmentsUploadURL' => ['https://upload.example/invoice.pdf'], + ], + ]); + + $result = $this->client->threads()->create([ + 'code' => 'order-1', + 'message' => '

Hi

', + 'user_code' => 'u1', + 'attachments' => ['invoice.pdf'], + ]); + + $this->assertSame('m1', $result['message']?->hash); + $this->assertSame(['https://upload.example/invoice.pdf'], $result['attachmentsUploadURL']); + } + + #[Test] + public function update_sends_changes_and_returns_thread(): void { + $this->http->enqueueJson(200, [ + 'status' => 'success', + 'data' => ['code' => 'order-1', 'hash' => 'eVO6SY5kf5', 'name' => 'Renamed'], + ]); + + $thread = $this->client->threads()->update('order-1', ['name' => 'Renamed']); + + $this->assertSame('Renamed', $thread->name); + + $call = $this->http->lastCall(); + $this->assertSame('PUT', $call['method']); + $this->assertSame(self::BASE_URL . 'threads/order-1/', $call['url']); + $this->assertSame(['name' => 'Renamed'], json_decode((string) $call['body'], true)); + } + #[Test] public function get_without_expand_omits_query_string(): void { $this->http->enqueueJson(200, [