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, [