From 25a4a5aaeeb96758c8db2c54e23785d63db7e68b Mon Sep 17 00:00:00 2001 From: stromek Date: Fri, 17 Jul 2026 12:05:44 +0200 Subject: [PATCH] Add message reactions, and thread/message create+update endpoints Closes the gap between the SDK and the Customer API: reactions were already live server-side but unwrapped, and thread/message creation had no SDK surface at all (read-only until now). - MessageResource::reactions() -> GET .../messages/{hash}/reactions/ (MessageReaction, MessageReactionUser models) - ThreadResource::create() -> POST /threads/ (upsert by code, optional initial message + attachments) - ThreadResource::update() -> PUT /threads/{code}/ - MessageResource::create() -> POST /threads/{code}/messages/ Prepares CHANGELOG.md for the first tagged release, 0.1.0. --- CHANGELOG.md | 9 +++ README.md | 27 ++++++++ examples/README.md | 2 + examples/create_thread_and_message.php | 55 +++++++++++++++++ examples/message_reactions.php | 46 ++++++++++++++ src/Model/MessageReaction.php | 43 +++++++++++++ src/Model/MessageReactionUser.php | 33 ++++++++++ src/Resource/MessageResource.php | 47 +++++++++++++- src/Resource/ThreadResource.php | 46 +++++++++++++- tests/Model/MessageReactionTest.php | 58 ++++++++++++++++++ tests/Resource/MessageResourceTest.php | 85 ++++++++++++++++++++++++++ tests/Resource/ThreadResourceTest.php | 62 +++++++++++++++++++ 12 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 examples/create_thread_and_message.php create mode 100644 examples/message_reactions.php create mode 100644 src/Model/MessageReaction.php create mode 100644 src/Model/MessageReactionUser.php create mode 100644 tests/Model/MessageReactionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 45608ef..4fb9fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-07-17 + ### Added - Initial release of the STROMCOM Customer SDK for PHP. - `Client` covering Project, Users, Threads, Messages and Attachment endpoints. @@ -27,3 +29,10 @@ 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. diff --git a/README.md b/README.md index b5ff49c..31acb8c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,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 +107,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. | +| [`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) | Cursor pagination via `lastMessageHash`. | | [`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 +136,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,14 +154,18 @@ 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); ``` ## Configuration diff --git a/examples/README.md b/examples/README.md index 06ebc33..80adc82 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. | +| [`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) | Walk every message in a thread via the `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/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/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, [