From c5bce8026d7d97801fbe2c8dc5c15d0475c52bff Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Wed, 18 Sep 2024 20:31:42 +0400 Subject: [PATCH 01/27] Implement GetFollowers request and resource --- .../Requests/App/Bsky/Graph/GetFollowers.php | 41 +++++++++++++++++++ .../App/Bsky/Graph/GetFollowersResource.php | 28 +++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php create mode 100644 src/Resources/App/Bsky/Graph/GetFollowersResource.php diff --git a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php new file mode 100644 index 0000000..aea6468 --- /dev/null +++ b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php @@ -0,0 +1,41 @@ +prefix()); + + if (! $client->authenticated()) { + return; + } + + try { + $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); + $this->queryParameter('actor', $client->authenticated()->did()); + } catch (AuthRequired $e) {} + } + + public function resource(array $data): ResourceContract + { + return new GetFollowersResource($data); + } + + public function build(): RequestContract + { + return $this; + } +} \ No newline at end of file diff --git a/src/Resources/App/Bsky/Graph/GetFollowersResource.php b/src/Resources/App/Bsky/Graph/GetFollowersResource.php new file mode 100644 index 0000000..e97c562 --- /dev/null +++ b/src/Resources/App/Bsky/Graph/GetFollowersResource.php @@ -0,0 +1,28 @@ +content = $content; + } + + protected function casts(): array + { + return [ + 'followers' => FollowersAsset::class, + ]; + } +} From 254ec0f2d300979c2634ebe513f59f8d4b62ef55 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 19 Sep 2024 18:10:29 +0400 Subject: [PATCH 02/27] Refactor API request handling and authentication - Remove static prefix from Client class - Refactor APIRequest to use Client instance for initialization - Update endpoint generation in APIRequest - Introduce Authentication trait for API requests - Simplify CreateSession request construction - Adjust Smith trait to handle request forging with arguments --- src/Client.php | 6 --- src/HTTP/API/APIRequest.php | 43 +++++++++---------- .../Requests/App/Bsky/Actor/GetProfile.php | 22 ++-------- .../Com/Atproto/Server/CreateSession.php | 7 +-- src/HTTP/Traits/Authentication.php | 22 ++++++++++ src/Traits/Authentication.php | 3 +- src/Traits/Smith.php | 39 ++++++++++------- tests/Feature/ClientTest.php | 2 +- tests/Unit/ClientTest.php | 3 +- tests/Unit/HTTP/API/APIRequestTest.php | 19 +++++--- .../App/Bsky/Actor/GetProfileTest.php | 3 +- .../Com/Atproto/Repo/CreateRecordTest.php | 3 +- .../Com/Atproto/Repo/UploadBlobTest.php | 3 +- 13 files changed, 94 insertions(+), 81 deletions(-) create mode 100644 src/HTTP/Traits/Authentication.php diff --git a/src/Client.php b/src/Client.php index 4e5e438..385af35 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,10 +9,4 @@ class Client { use Smith; use Authentication; - protected static string $prefix = "Atproto\\HTTP\\API\\Requests\\"; - - public function prefix(): string - { - return self::$prefix; - } } diff --git a/src/HTTP/API/APIRequest.php b/src/HTTP/API/APIRequest.php index 8f5eda2..216b66c 100644 --- a/src/HTTP/API/APIRequest.php +++ b/src/HTTP/API/APIRequest.php @@ -2,45 +2,42 @@ namespace Atproto\HTTP\API; +use Atproto\Client; use Atproto\Contracts\HTTP\APIRequestContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\HTTP\Request; abstract class APIRequest extends Request implements APIRequestContract { - public function __construct(string $prefix = '') - { - $this->origin(self::API_BASE_URL) - ->headers(self::API_BASE_HEADERS); + private Client $client; - if ($prefix) { - $this->path($this->routePath($prefix)); - } + public function __construct(Client $client) + { + $this->client = $client; + $this->initialize(); } public function send(): ResourceContract { - $response = parent::send(); - return $this->resource($response); + return $this->resource(parent::send()); + } + + private function initialize(): void + { + $this->origin(self::API_BASE_URL) + ->path($this->endpoint()) + ->headers(self::API_BASE_HEADERS); } - private function routePath(string $prefix): string + private function endpoint(): string { - $classNamespace = static::class; - - if (strpos($classNamespace, $prefix) === 0) { - $routePath = substr($classNamespace, strlen($prefix)); - } else { - $routePath = $classNamespace; - } - - $routeParts = explode("\\", $routePath); - $routePath = array_reduce( - $routeParts, - fn ($carry, $part) => $carry .= ".".lcfirst($part) + $endpointParts = explode("\\", $this->client->path()); + + $endpoint = array_reduce($endpointParts, + fn ($carry, $part) => $carry .= "." . lcfirst($part) ); - return "/xrpc/" . trim($routePath, '.'); + return sprintf("/xrpc/%s", trim($endpoint, ".")); } abstract public function resource(array $data): ResourceContract; diff --git a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php index 7f037d1..4bda070 100644 --- a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php +++ b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php @@ -5,31 +5,15 @@ use Atproto\Client; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; -use Atproto\Exceptions\Auth\AuthRequired; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\HTTP\API\APIRequest; +use Atproto\HTTP\Traits\Authentication; use Atproto\Resources\App\Bsky\Actor\GetProfileResource; use Exception; class GetProfile extends APIRequest { - public function __construct(Client $client = null) - { - if (! $client) { - return; - } - - parent::__construct($client->prefix()); - - if (! $client->authenticated()) { - return; - } - - try { - $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); - $this->queryParameter('actor', $client->authenticated()->did()); - } catch (AuthRequired $e) {} - } + use Authentication; /** * @return RequestContract|string @@ -85,4 +69,4 @@ public function resource(array $data): ResourceContract { return new GetProfileResource($data); } -} \ No newline at end of file +} diff --git a/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php b/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php index b56ad03..555e35e 100644 --- a/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php +++ b/src/HTTP/API/Requests/Com/Atproto/Server/CreateSession.php @@ -2,6 +2,7 @@ namespace Atproto\HTTP\API\Requests\Com\Atproto\Server; +use Atproto\Client; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; use Atproto\HTTP\API\APIRequest; @@ -9,12 +10,12 @@ class CreateSession extends APIRequest { - public function __construct(string $prefix, string $username, string $password) + public function __construct(Client $client, string $identifier, string $password) { - parent::__construct($prefix); + parent::__construct($client); $this->method('POST')->origin('https://bsky.social/')->parameters([ - 'identifier' => $username, + 'identifier' => $identifier, 'password' => $password, ]); } diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php new file mode 100644 index 0000000..f1a93d6 --- /dev/null +++ b/src/HTTP/Traits/Authentication.php @@ -0,0 +1,22 @@ +authenticated()) { + $this->header("Authorization", "Bearer " . $authenticated->accessJwt()); + } + } +} \ No newline at end of file diff --git a/src/Traits/Authentication.php b/src/Traits/Authentication.php index d9f9e43..edda2dc 100644 --- a/src/Traits/Authentication.php +++ b/src/Traits/Authentication.php @@ -3,7 +3,6 @@ namespace Atproto\Traits; use Atproto\Exceptions\BlueskyException; -use Atproto\HTTP\API\Requests\Com\Atproto\Server\CreateSession; use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; trait Authentication @@ -15,7 +14,7 @@ trait Authentication */ public function authenticate(string $identifier, string $password): void { - $request = new CreateSession(self::$prefix, $identifier, $password); + $request = $this->com()->atproto()->server()->createSession()->forge($identifier, $password); /** @var CreateSessionResource $response */ $response = $request->send(); diff --git a/src/Traits/Smith.php b/src/Traits/Smith.php index b14000b..ce5b3b0 100644 --- a/src/Traits/Smith.php +++ b/src/Traits/Smith.php @@ -8,7 +8,8 @@ trait Smith { - protected array $path = []; + private string $prefix = "Atproto\\HTTP\\API\\Requests\\"; + private array $path = []; public function __call(string $name, array $arguments): Client { @@ -17,35 +18,41 @@ public function __call(string $name, array $arguments): Client return $this; } - protected function refresh(): void - { - $this->path = []; - } - /** * @throws RequestNotFoundException */ - public function forge(): RequestContract + public function forge(...$arguments): RequestContract { - $namespace = $this->namespace(); + $arguments = array_merge([$this], array_values($arguments)); - if (! class_exists($namespace)) { - throw new RequestNotFoundException("$namespace class does not exist."); + $request = $this->request(); + + if (! class_exists($request)) { + throw new RequestNotFoundException("$request class does not exist."); } - return new $namespace($this); + $request = new $request(...$arguments); + + $this->refresh(); + + return $request; } - protected function namespace(): string + public function path(): string { - $namespace = $this->prefix() . implode('\\', array_map( + return implode('\\', array_map( 'ucfirst', $this->path )); + } - $this->refresh(); + private function refresh(): void + { + $this->path = []; + } - return $namespace; + private function request(): string + { + return $this->prefix . $this->path(); } - abstract public function prefix(): string; } diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 89d8ba6..6a27ba9 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -50,7 +50,6 @@ public function testGetProfile(): void $this->assertInstanceOf(ResourceContract::class, $authenticated); $this->assertIsString($authenticated->handle()); - ; $this->assertSame($username, $authenticated->handle()); $profile = $this->client @@ -59,6 +58,7 @@ public function testGetProfile(): void ->actor() ->getProfile() ->forge() + ->actor($this->client->authenticated()->did()) ->send(); $this->assertInstanceOf(ResourceContract::class, $profile); diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 48dfb77..9699bd1 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -40,7 +40,7 @@ public function testNamespaceGeneration(): void { $this->client->app()->bsky()->actor(); - $method = $this->method('namespace', $this->client); + $method = $this->method('request', $this->client); $namespace = $method->invoke($this->client); @@ -58,7 +58,6 @@ public function testBuildThrowsRequestNotFoundException(): void $this->client->forge(); } - /** * @throws RequestNotFoundException */ diff --git a/tests/Unit/HTTP/API/APIRequestTest.php b/tests/Unit/HTTP/API/APIRequestTest.php index 1091bef..f92ef04 100644 --- a/tests/Unit/HTTP/API/APIRequestTest.php +++ b/tests/Unit/HTTP/API/APIRequestTest.php @@ -2,10 +2,10 @@ namespace Tests\Unit\HTTP\API; +use Atproto\Client; use Atproto\HTTP\API\APIRequest; use Atproto\HTTP\API\Requests\Com\Atproto\Server\CreateSession; use Faker\Factory; -use Faker\Generator; use PHPUnit\Framework\TestCase; use Tests\Supports\Reflection; @@ -14,20 +14,27 @@ class APIRequestTest extends TestCase use Reflection; private APIRequest $request; - private Generator $faker; private array $parameters = []; public function setUp(): void { - $this->faker = Factory::create(); + $faker = Factory::create(); + + $clientMock = $this->createMock(Client::class); + + $clientMock->method('path')->willReturn(str_replace( + 'Atproto\\HTTP\\API\\Requests\\', + '', + CreateSession::class + )); $this->parameters = [ - 'identifier' => $this->faker->userName, - 'password' => $this->faker->password, + 'identifier' => $faker->userName, + 'password' => $faker->password, ]; $this->request = new CreateSession( - 'Atproto\\HTTP\\API\\Requests\\', + $clientMock, $this->parameters['identifier'], $this->parameters['password'] ); diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index d7878a4..6302cfa 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\HTTP\API\Requests\App\Bsky\Actor; +use Atproto\Client; use Atproto\Contracts\HTTP\APIRequestContract; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\MissingFieldProvidedException; @@ -25,7 +26,7 @@ protected function setUp(): void { parent::setUp(); $this->faker = Factory::create(); - $this->request = new GetProfile(); + $this->request = new GetProfile($this->createMock(Client::class)); } /** diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php index c7256c8..42e78f1 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\HTTP\API\Requests\Com\Atproto\Repo; +use Atproto\Client; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\HTTP\API\Requests\Com\Atproto\Repo\CreateRecord; use Faker\Factory; @@ -15,7 +16,7 @@ class CreateRecordTest extends TestCase protected function setUp(): void { - $this->createRecord = new CreateRecord(); + $this->createRecord = new CreateRecord($this->createMock(Client::class)); $this->faker = Factory::create(); } diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php index 0f1216b..c1be36f 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\HTTP\API\Requests\Com\Atproto\Repo; +use Atproto\Client; use Atproto\Exceptions\Http\MissingFieldProvidedException; use Atproto\HTTP\API\Requests\Com\Atproto\Repo\UploadBlob; use Faker\Factory; @@ -19,7 +20,7 @@ class UploadBlobTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->uploadBlob = new UploadBlob(); + $this->uploadBlob = new UploadBlob($this->createMock(Client::class)); $this->faker = Factory::create(); } From bd3325d5d27c43417d4afd2ad93557cce37654d0 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 19 Sep 2024 18:11:59 +0400 Subject: [PATCH 03/27] Apply PSR-12 via php-cs-fixer --- src/API/App/Bsky/Actor/GetProfile.php | 6 ++-- src/API/Com/Atrproto/Repo/CreateRecord.php | 23 +++++++----- src/API/Com/Atrproto/Repo/UploadBlob.php | 12 ++++--- .../Strategies/PasswordAuthentication.php | 5 +-- src/Builders/Bluesky/RecordBuilder.php | 28 ++++++++------- src/Contracts/HTTP/APIRequestContract.php | 4 +-- .../HTTP/Resources/AssetContract.php | 2 +- src/Exceptions/BlueskyException.php | 3 +- .../Http/MissingFieldProvidedException.php | 2 +- .../Http/Response/AuthMissingException.php | 2 +- .../AuthenticationRequiredException.php | 2 +- .../Http/Response/ExpiredTokenException.php | 2 +- .../Http/Response/InvalidRequestException.php | 2 +- .../Http/Response/InvalidTokenException.php | 2 +- src/Exceptions/Http/UnsupportedHTTPMethod.php | 1 - .../Resource/BadAssetCallException.php | 4 +-- src/Exceptions/cURLException.php | 1 - .../Types/NonPrimitive/FollowerAssetType.php | 3 +- .../Types/NonPrimitive/LabelAssetType.php | 3 +- src/HTTP/API/APIRequest.php | 3 +- src/HTTP/Traits/Authentication.php | 2 +- src/Helpers/Arr.php | 23 ++++++------ src/Helpers/File.php | 36 ++++++++++++------- .../App/Bsky/Actor/GetProfileResource.php | 2 +- src/Resources/Assets/BaseAsset.php | 2 +- src/Resources/Assets/CreatorAsset.php | 2 +- src/Resources/Assets/FollowersAsset.php | 2 +- src/Resources/BaseResource.php | 2 +- .../Com/Atproto/Repo/CreateRecordResource.php | 1 - src/helpers.php | 2 +- tests/Feature/BlueskyClientTest.php | 32 ++++++++--------- tests/Supports/AssetTest.php | 2 +- tests/Supports/ByListAssetTest.php | 3 +- tests/Supports/NonPrimitiveAssetTest.php | 2 +- tests/Supports/PrimitiveAssetTest.php | 2 +- tests/Supports/Reflection.php | 2 +- tests/Supports/UserAssetTest.php | 15 ++++---- .../Com/Atproto/Repo/CreateRecordTest.php | 2 +- .../Com/Atproto/Repo/UploadBlobTest.php | 2 +- .../App/Bsky/Actor/GetProfileResourceTest.php | 3 +- .../Resources/Assets/AssociatedAssetTest.php | 9 ++--- tests/Unit/Resources/Assets/BaseAssetTest.php | 4 +-- .../Resources/Assets/FollowersAssetTest.php | 4 +-- .../Assets/JoinedViaStarterPackAssetTest.php | 9 ++--- .../Assets/KnownFollowersAssetTest.php | 7 ++-- .../Unit/Resources/Assets/LabelAssetTest.php | 3 +- .../Unit/Resources/Assets/LabelsAssetTest.php | 2 +- .../Unit/Resources/Assets/ViewerAssetTest.php | 3 +- 48 files changed, 162 insertions(+), 128 deletions(-) diff --git a/src/API/App/Bsky/Actor/GetProfile.php b/src/API/App/Bsky/Actor/GetProfile.php index 1337e1e..ae969dc 100644 --- a/src/API/App/Bsky/Actor/GetProfile.php +++ b/src/API/App/Bsky/Actor/GetProfile.php @@ -47,8 +47,9 @@ public function __construct() */ public function setActor($actor) { - if (! is_string($actor)) + if (! is_string($actor)) { throw new InvalidArgumentException("'actor' must be a string"); + } $this->body->actor = $actor; } @@ -146,8 +147,9 @@ public function boot($authResponse) */ public function getBody() { - if (! isset($this->body->actor)) + if (! isset($this->body->actor)) { throw new RequestBodyHasMissingRequiredFields('actor'); + } return ['actor' => $this->body->actor]; } diff --git a/src/API/Com/Atrproto/Repo/CreateRecord.php b/src/API/Com/Atrproto/Repo/CreateRecord.php index 3183e01..d2d4a90 100644 --- a/src/API/Com/Atrproto/Repo/CreateRecord.php +++ b/src/API/Com/Atrproto/Repo/CreateRecord.php @@ -48,8 +48,9 @@ public function __construct() */ public function setRepo($repo) { - if (!is_string($repo)) + if (!is_string($repo)) { throw new InvalidArgumentException("'repo' must be a string"); + } $this->body->repo = $repo; @@ -75,11 +76,13 @@ public function getRepo() */ public function setRkey($rkey) { - if (! is_string($rkey)) + if (! is_string($rkey)) { throw new InvalidArgumentException("'key' must be a string"); + } - if (strlen($rkey) > 15 || 1 > strlen($rkey)) + if (strlen($rkey) > 15 || 1 > strlen($rkey)) { throw new InvalidArgumentException("'key' length must be between 1 and 15 characters"); + } $this->body->rkey = $rkey; @@ -105,8 +108,9 @@ public function getRkey() */ public function setValidate($validate) { - if (! is_bool($validate)) + if (! is_bool($validate)) { throw new InvalidArgumentException("'validate' must be a boolean"); + } $this->body->validate = $validate; @@ -163,11 +167,13 @@ public function setCollection($collection) 'app.bsky.graph.follow' ]; - if (! is_string($collection)) + if (! is_string($collection)) { throw new InvalidArgumentException("'collection' must be a string"); + } - if (! in_array($collection, $acceptableCollections)) + if (! in_array($collection, $acceptableCollections)) { throw new InvalidArgumentException("'collection' must be one of '" . implode("', '", $acceptableCollections) . "'"); + } $this->body->collection = $collection; @@ -286,8 +292,9 @@ public function getBody() array_keys($fields) ); - if (! empty($missingFields)) + if (! empty($missingFields)) { throw new RequestBodyHasMissingRequiredFields(implode(', ', $missingFields)); + } return json_encode($fields); } @@ -315,4 +322,4 @@ public function boot($authResponse) $this->body->repo = $authResponse->did; } -} \ No newline at end of file +} diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index 6a5c498..ce205f2 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -55,15 +55,18 @@ public function setBlob($filePath) { $file = new File($filePath); - if (! $file->exists()) + if (! $file->exists()) { throw new InvalidArgumentException("File '$filePath' does not exist"); + } - if (! $file->isFile()) + if (! $file->isFile()) { throw new InvalidArgumentException("File '$filePath' is not a file"); + } $maxSize = 1000000; - if ($file->getFileSize() > $maxSize) + if ($file->getFileSize() > $maxSize) { throw new InvalidArgumentException("File '$filePath' is too big. Max file size is $maxSize bytes."); + } $this->body->blob = $file; @@ -88,8 +91,9 @@ public function getBlob() */ public function getBody() { - if (! isset($this->body->blob)) + if (! isset($this->body->blob)) { throw new RequestBodyHasMissingRequiredFields(implode(', ', ['blob'])); + } return $this->body ->blob diff --git a/src/Auth/Strategies/PasswordAuthentication.php b/src/Auth/Strategies/PasswordAuthentication.php index e838a25..d403db0 100644 --- a/src/Auth/Strategies/PasswordAuthentication.php +++ b/src/Auth/Strategies/PasswordAuthentication.php @@ -4,7 +4,7 @@ use Atproto\Contracts\AuthStrategyContract; use Atproto\Exceptions\Auth\AuthFailed; -use \InvalidArgumentException; +use InvalidArgumentException; /** * Class PasswordAuthentication @@ -85,8 +85,9 @@ public function authenticate(array $credentials) $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($statusCode !== 200) + if ($statusCode !== 200) { throw new AuthFailed("Authentication failed: " . ($response ? json_decode($response)->message : "Unknown error")); + } return json_decode($response); } diff --git a/src/Builders/Bluesky/RecordBuilder.php b/src/Builders/Bluesky/RecordBuilder.php index 4a0eceb..a4bffd6 100644 --- a/src/Builders/Bluesky/RecordBuilder.php +++ b/src/Builders/Bluesky/RecordBuilder.php @@ -49,8 +49,9 @@ public function addText($text) E_USER_DEPRECATED ); - if (! is_string($text)) + if (! is_string($text)) { throw new InvalidArgumentException("'text' must be string"); + } $this->record->text = (string) $this->record->text . "$text\n"; @@ -61,11 +62,11 @@ public function addText($text) PREG_OFFSET_CAPTURE ); - if (! empty($urlMatches)) + if (! empty($urlMatches)) { $this->record->facets = []; + } - foreach($urlMatches[0] as $match) - { + foreach($urlMatches[0] as $match) { $url = $match[0]; $startPos = $match[1]; $endPos = $startPos + strlen($url); @@ -109,15 +110,17 @@ public function addType($type = 'app.bsky.feed.post') E_USER_DEPRECATED ); - if (! is_string($type)) + if (! is_string($type)) { throw new InvalidArgumentException("'type' must be string"); + } $acceptedTypes = ['app.bsky.feed.post']; - if (! in_array($type, $acceptedTypes)) + if (! in_array($type, $acceptedTypes)) { throw new InvalidArgumentException( "'$type' is not a valid for 'type' value. It can only be one of the following: " . implode(', ', $acceptedTypes) ); + } $this->record->type = $type; @@ -146,12 +149,9 @@ public function addCreatedAt($createdAt = null) E_USER_DEPRECATED ); - if (! is_null($createdAt)) - { + if (! is_null($createdAt)) { $createdAt = $createdAt->format('c'); - } - else - { + } else { $createdAt = date('c'); } @@ -183,14 +183,16 @@ public function addImage($blob, $alt = "") E_USER_DEPRECATED ); - if (! is_string($alt)) + if (! is_string($alt)) { throw new InvalidArgumentException("'alt' must be a string"); + } - if (! isset($this->record->embed)) + if (! isset($this->record->embed)) { $this->record->embed = (object) [ "\$type" => "app.bsky.embed.images", "images" => [] ]; + } $this->record->embed->images[] = [ "image" => $blob, diff --git a/src/Contracts/HTTP/APIRequestContract.php b/src/Contracts/HTTP/APIRequestContract.php index 5fbbf32..eb483f1 100644 --- a/src/Contracts/HTTP/APIRequestContract.php +++ b/src/Contracts/HTTP/APIRequestContract.php @@ -6,8 +6,8 @@ interface APIRequestContract extends RequestContract { - const API_BASE_URL = 'https://bsky.social'; - const API_BASE_HEADERS = [ + public const API_BASE_URL = 'https://bsky.social'; + public const API_BASE_HEADERS = [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; diff --git a/src/Contracts/HTTP/Resources/AssetContract.php b/src/Contracts/HTTP/Resources/AssetContract.php index 00b9c7f..743895f 100644 --- a/src/Contracts/HTTP/Resources/AssetContract.php +++ b/src/Contracts/HTTP/Resources/AssetContract.php @@ -6,4 +6,4 @@ interface AssetContract { public function cast(); public function revert(); -} \ No newline at end of file +} diff --git a/src/Exceptions/BlueskyException.php b/src/Exceptions/BlueskyException.php index 12dce6b..9acc57b 100644 --- a/src/Exceptions/BlueskyException.php +++ b/src/Exceptions/BlueskyException.php @@ -4,5 +4,4 @@ class BlueskyException extends \Exception { - -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/MissingFieldProvidedException.php b/src/Exceptions/Http/MissingFieldProvidedException.php index 725d902..de3f9fe 100644 --- a/src/Exceptions/Http/MissingFieldProvidedException.php +++ b/src/Exceptions/Http/MissingFieldProvidedException.php @@ -10,4 +10,4 @@ public function __construct($message = "", $code = 0, $previous = null) { parent::__construct("Missing provided fields: $message", $code, $previous); } -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/Response/AuthMissingException.php b/src/Exceptions/Http/Response/AuthMissingException.php index 1538a94..6c6ab93 100644 --- a/src/Exceptions/Http/Response/AuthMissingException.php +++ b/src/Exceptions/Http/Response/AuthMissingException.php @@ -10,4 +10,4 @@ public function __construct($message = "Authentication Required", $code = 401, $ { parent::__construct($message, $code, $previous); } -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/Response/AuthenticationRequiredException.php b/src/Exceptions/Http/Response/AuthenticationRequiredException.php index 125c072..bc3eaa3 100644 --- a/src/Exceptions/Http/Response/AuthenticationRequiredException.php +++ b/src/Exceptions/Http/Response/AuthenticationRequiredException.php @@ -10,4 +10,4 @@ public function __construct($message = "Invalid identifier or password", $code = { parent::__construct($message, $code, $previous); } -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/Response/ExpiredTokenException.php b/src/Exceptions/Http/Response/ExpiredTokenException.php index 8286b04..293baa7 100644 --- a/src/Exceptions/Http/Response/ExpiredTokenException.php +++ b/src/Exceptions/Http/Response/ExpiredTokenException.php @@ -6,4 +6,4 @@ class ExpiredTokenException extends BlueskyException { -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/Response/InvalidRequestException.php b/src/Exceptions/Http/Response/InvalidRequestException.php index 4afdb48..216e6e0 100644 --- a/src/Exceptions/Http/Response/InvalidRequestException.php +++ b/src/Exceptions/Http/Response/InvalidRequestException.php @@ -6,4 +6,4 @@ class InvalidRequestException extends BlueskyException { -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/Response/InvalidTokenException.php b/src/Exceptions/Http/Response/InvalidTokenException.php index 954246f..bb07867 100644 --- a/src/Exceptions/Http/Response/InvalidTokenException.php +++ b/src/Exceptions/Http/Response/InvalidTokenException.php @@ -6,4 +6,4 @@ class InvalidTokenException extends BlueskyException { -} \ No newline at end of file +} diff --git a/src/Exceptions/Http/UnsupportedHTTPMethod.php b/src/Exceptions/Http/UnsupportedHTTPMethod.php index 2e17e48..370df76 100644 --- a/src/Exceptions/Http/UnsupportedHTTPMethod.php +++ b/src/Exceptions/Http/UnsupportedHTTPMethod.php @@ -7,5 +7,4 @@ */ class UnsupportedHTTPMethod extends \Exception { - } diff --git a/src/Exceptions/Resource/BadAssetCallException.php b/src/Exceptions/Resource/BadAssetCallException.php index f897e55..3c2586d 100644 --- a/src/Exceptions/Resource/BadAssetCallException.php +++ b/src/Exceptions/Resource/BadAssetCallException.php @@ -14,7 +14,7 @@ public function __construct($message = "", $code = 0, Throwable $previous = null parent::__construct($this->message($message), $code, $previous); } - private function message(string $asset) : string + private function message(string $asset): string { if ($asset) { return "'$asset' $this->message"; @@ -22,4 +22,4 @@ private function message(string $asset) : string return ucfirst($this->message); } -} \ No newline at end of file +} diff --git a/src/Exceptions/cURLException.php b/src/Exceptions/cURLException.php index 78bb4f6..a2eaacd 100644 --- a/src/Exceptions/cURLException.php +++ b/src/Exceptions/cURLException.php @@ -2,7 +2,6 @@ namespace Atproto\Exceptions; - use Exception; /** diff --git a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php b/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php index 7a9f779..9d0c79c 100644 --- a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php +++ b/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php @@ -7,9 +7,8 @@ class FollowerAssetType implements TypeInterface { - public function validate($value): bool { return $value instanceof FollowerAsset; } -} \ No newline at end of file +} diff --git a/src/GenericCollection/Types/NonPrimitive/LabelAssetType.php b/src/GenericCollection/Types/NonPrimitive/LabelAssetType.php index 60067ae..e7d9859 100644 --- a/src/GenericCollection/Types/NonPrimitive/LabelAssetType.php +++ b/src/GenericCollection/Types/NonPrimitive/LabelAssetType.php @@ -7,9 +7,8 @@ class LabelAssetType implements TypeInterface { - public function validate($value): bool { return $value instanceof LabelAsset; } -} \ No newline at end of file +} diff --git a/src/HTTP/API/APIRequest.php b/src/HTTP/API/APIRequest.php index 216b66c..c99ffc5 100644 --- a/src/HTTP/API/APIRequest.php +++ b/src/HTTP/API/APIRequest.php @@ -33,7 +33,8 @@ private function endpoint(): string { $endpointParts = explode("\\", $this->client->path()); - $endpoint = array_reduce($endpointParts, + $endpoint = array_reduce( + $endpointParts, fn ($carry, $part) => $carry .= "." . lcfirst($part) ); diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index f1a93d6..cef08cc 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -19,4 +19,4 @@ public function __construct(Client $client) $this->header("Authorization", "Bearer " . $authenticated->accessJwt()); } } -} \ No newline at end of file +} diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index ccace73..5bed62b 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -16,15 +16,16 @@ class Arr */ public static function get(array $array, string $key, $default = null) { - if (isset($array[$key])) + if (isset($array[$key])) { return $array[$key]; + } $segments = explode('.', $key); - foreach($segments as $segment) - { - if (! self::exists($array, $segment)) + foreach($segments as $segment) { + if (! self::exists($array, $segment)) { return $default; + } $array = $array[$segment]; } @@ -41,11 +42,13 @@ public static function get(array $array, string $key, $default = null) */ public static function exists($array, string $key): bool { - if (! is_array($array)) + if (! is_array($array)) { return false; + } - if (! array_key_exists($key, $array)) + if (! array_key_exists($key, $array)) { return false; + } return true; } @@ -66,12 +69,12 @@ public static function forget(array &$array, string $key): void { $parts = explode(".", $key); - while(count($parts) > 1) - { + while(count($parts) > 1) { $part = array_shift($parts); - if (isset($array[$part])) + if (isset($array[$part])) { $array = &$array[$part]; + } } unset($array[array_shift($parts)]); @@ -133,4 +136,4 @@ public static function pull(array &$array, string $key, $default = null) return $value; } -} \ No newline at end of file +} diff --git a/src/Helpers/File.php b/src/Helpers/File.php index 72a5e02..21951aa 100644 --- a/src/Helpers/File.php +++ b/src/Helpers/File.php @@ -7,7 +7,8 @@ * * Helper class for file-related operations. */ -class File { +class File +{ /** @var string $file_path The path to the file. */ private $file_path; @@ -16,7 +17,8 @@ class File { * * @param string $file_path The path to the file. */ - public function __construct($file_path) { + public function __construct($file_path) + { $this->file_path = $file_path; } @@ -25,7 +27,8 @@ public function __construct($file_path) { * * @return string The MIME type. */ - public function getMimeType() { + public function getMimeType() + { $mime_type = mime_content_type($this->file_path); return $mime_type !== false ? $mime_type : 'unknown'; } @@ -35,7 +38,8 @@ public function getMimeType() { * * @return string The file extension. */ - public function getExtension() { + public function getExtension() + { $extension = pathinfo($this->file_path, PATHINFO_EXTENSION); return $extension !== '' ? $extension : 'unknown'; } @@ -45,7 +49,8 @@ public function getExtension() { * * @return string The filename. */ - public function getFileName() { + public function getFileName() + { return pathinfo($this->file_path, PATHINFO_FILENAME); } @@ -54,7 +59,8 @@ public function getFileName() { * * @return string The base path. */ - public function getBasePath() { + public function getBasePath() + { return pathinfo($this->file_path, PATHINFO_DIRNAME); } @@ -63,7 +69,8 @@ public function getBasePath() { * * @return int The file size in bytes. */ - public function getFileSize() { + public function getFileSize() + { return filesize($this->file_path); } @@ -72,7 +79,8 @@ public function getFileSize() { * * @return bool True if the file exists, false otherwise. */ - public function exists() { + public function exists() + { return file_exists($this->file_path); } @@ -81,7 +89,8 @@ public function exists() { * * @return bool True if the path is a file, false otherwise. */ - public function isFile() { + public function isFile() + { return is_file($this->file_path); } @@ -90,7 +99,8 @@ public function isFile() { * * @return bool True if the path is a directory, false otherwise. */ - public function isDirectory() { + public function isDirectory() + { return is_dir($this->file_path); } @@ -99,7 +109,8 @@ public function isDirectory() { * * @return false|int The creation time as a Unix timestamp, or false on failure. */ - public function getCreationTime() { + public function getCreationTime() + { return filectime($this->file_path); } @@ -108,7 +119,8 @@ public function getCreationTime() { * * @return false|int The last modification time as a Unix timestamp, or false on failure. */ - public function getModificationTime() { + public function getModificationTime() + { return filemtime($this->file_path); } diff --git a/src/Resources/App/Bsky/Actor/GetProfileResource.php b/src/Resources/App/Bsky/Actor/GetProfileResource.php index 5f30a60..341d40c 100644 --- a/src/Resources/App/Bsky/Actor/GetProfileResource.php +++ b/src/Resources/App/Bsky/Actor/GetProfileResource.php @@ -30,4 +30,4 @@ class GetProfileResource implements ResourceContract { use UserAsset; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/BaseAsset.php b/src/Resources/Assets/BaseAsset.php index 9ffc96e..9880538 100644 --- a/src/Resources/Assets/BaseAsset.php +++ b/src/Resources/Assets/BaseAsset.php @@ -14,7 +14,7 @@ public function __construct($value) $this->value = $value; $this->cast(); } - + public function cast(): AssetContract { return $this; diff --git a/src/Resources/Assets/CreatorAsset.php b/src/Resources/Assets/CreatorAsset.php index dcb5b91..dd4cca1 100644 --- a/src/Resources/Assets/CreatorAsset.php +++ b/src/Resources/Assets/CreatorAsset.php @@ -25,4 +25,4 @@ class CreatorAsset implements ResourceContract, AssetContract { use UserAsset; -} \ No newline at end of file +} diff --git a/src/Resources/Assets/FollowersAsset.php b/src/Resources/Assets/FollowersAsset.php index 144e7e5..b502797 100644 --- a/src/Resources/Assets/FollowersAsset.php +++ b/src/Resources/Assets/FollowersAsset.php @@ -22,6 +22,6 @@ protected function item($data): AssetContract protected function type(): TypeInterface { - return new FollowerAssetType; + return new FollowerAssetType(); } } diff --git a/src/Resources/BaseResource.php b/src/Resources/BaseResource.php index 0701335..0d0f346 100644 --- a/src/Resources/BaseResource.php +++ b/src/Resources/BaseResource.php @@ -65,4 +65,4 @@ private function parse(string $name) return $value; } -} \ No newline at end of file +} diff --git a/src/Resources/Com/Atproto/Repo/CreateRecordResource.php b/src/Resources/Com/Atproto/Repo/CreateRecordResource.php index 72ec7fa..138b034 100644 --- a/src/Resources/Com/Atproto/Repo/CreateRecordResource.php +++ b/src/Resources/Com/Atproto/Repo/CreateRecordResource.php @@ -5,7 +5,6 @@ use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Resources\BaseResource; - class CreateRecordResource implements ResourceContract { use BaseResource; diff --git a/src/helpers.php b/src/helpers.php index f2f025f..8ae5523 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -40,4 +40,4 @@ function trait_uses_recursive($trait): array return $traits; } -} \ No newline at end of file +} diff --git a/tests/Feature/BlueskyClientTest.php b/tests/Feature/BlueskyClientTest.php index 0631bef..4f086cc 100644 --- a/tests/Feature/BlueskyClientTest.php +++ b/tests/Feature/BlueskyClientTest.php @@ -58,7 +58,7 @@ public function testConstructorWithCustomURL() // Test getRequest method public function testGetRequestMethod() { - $request = new CreateRecord; + $request = new CreateRecord(); $client = new BlueskyClient($request); $this->assertInstanceOf(RequestContract::class, $client->getRequest()); @@ -69,8 +69,8 @@ public function testGetRequestMethod() // Test authenticate method with valid credentials public function testAuthenticateWithValidCredentials() { - $client = new BlueskyClient(new CreateRecord); - $client->setStrategy(new PasswordAuthentication); + $client = new BlueskyClient(new CreateRecord()); + $client->setStrategy(new PasswordAuthentication()); $authenticated = $client->authenticate([ 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], @@ -89,8 +89,8 @@ public function testAuthenticateWithInvalidCredentials() $this->expectException(AuthFailed::class); $this->expectExceptionMessage("Authentication failed: "); - $client = new BlueskyClient(new CreateRecord); - $client->setStrategy(new PasswordAuthentication); + $client = new BlueskyClient(new CreateRecord()); + $client->setStrategy(new PasswordAuthentication()); $client->authenticate([ 'identifier' => 'invalid identifier', @@ -101,7 +101,7 @@ public function testAuthenticateWithInvalidCredentials() // Test execute method with CreateRecord public function testExecuteWithCreateRecord() { - $request = new CreateRecord; + $request = new CreateRecord(); $recordBuilder = (new RecordBuilder()) ->addText("Hello World! I am posted from PHP Unit tests for testing this URL adding to this post: \n1. https://www.fs-poster.com \n2. https://github.com/shahmal1yev/blueskysdk \n3. https://github.com/easypay/php-yigim") @@ -112,7 +112,7 @@ public function testExecuteWithCreateRecord() $client = new BlueskyClient($request); - $client->setStrategy(new PasswordAuthentication) + $client->setStrategy(new PasswordAuthentication()) ->authenticate([ 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], 'password' => $_ENV["BLUESKY_PASSWORD"] @@ -129,9 +129,9 @@ public function testExecuteWithCreateRecord() // Test execute method with UploadBlob public function testExecuteWithUploadBlob() { - $client = new BlueskyClient(new UploadBlob); + $client = new BlueskyClient(new UploadBlob()); - $client->setStrategy(new PasswordAuthentication) + $client->setStrategy(new PasswordAuthentication()) ->authenticate([ 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], 'password' => $_ENV["BLUESKY_PASSWORD"] @@ -148,7 +148,7 @@ public function testExecuteWithUploadBlob() // Test execute method with GetProfile public function testExecuteWithGetProfile() { - $client = new BlueskyClient(new GetProfile); + $client = new BlueskyClient(new GetProfile()); $client->authenticate([ 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], @@ -168,7 +168,7 @@ public function testExecuteWithGetProfile() // Test send method with GetProfile public function testSendWithGetProfile() { - $request = new GetProfile; + $request = new GetProfile(); $request->setActor('shahmal1yevv.bsky.social'); @@ -224,9 +224,9 @@ public function testSendWithRequestWhichHasNotResourceSupport() // Test execute method with both UploadBlob and CreateRecord public function testExecuteWithUploadBlobAndCreateRecord() { - $client = new BlueskyClient(new UploadBlob); + $client = new BlueskyClient(new UploadBlob()); - $client->setStrategy(new PasswordAuthentication) + $client->setStrategy(new PasswordAuthentication()) ->authenticate([ 'identifier' => $_ENV["BLUESKY_IDENTIFIER"], 'password' => $_ENV["BLUESKY_PASSWORD"] @@ -255,7 +255,7 @@ public function testExecuteWithUploadBlobAndCreateRecord() ->addImage($image->blob) ->addCreatedAt(); - $client->setRequest(new CreateRecord); + $client->setRequest(new CreateRecord()); $client->getRequest()->setRecord($recordBuilder); @@ -270,8 +270,8 @@ public function testExecuteWithUploadBlobAndCreateRecord() // Test setStrategy method public function testSetStrategyMethod() { - $authStrategy = new PasswordAuthentication; - $client = new BlueskyClient(new CreateRecord); + $authStrategy = new PasswordAuthentication(); + $client = new BlueskyClient(new CreateRecord()); $client->setStrategy($authStrategy); diff --git a/tests/Supports/AssetTest.php b/tests/Supports/AssetTest.php index 424da14..c38e132 100644 --- a/tests/Supports/AssetTest.php +++ b/tests/Supports/AssetTest.php @@ -36,4 +36,4 @@ protected static function getData(): array } abstract protected function resource(array $data); -} \ No newline at end of file +} diff --git a/tests/Supports/ByListAssetTest.php b/tests/Supports/ByListAssetTest.php index 476a29e..afd74db 100644 --- a/tests/Supports/ByListAssetTest.php +++ b/tests/Supports/ByListAssetTest.php @@ -8,7 +8,8 @@ trait ByListAssetTest { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; public function primitiveAssetsProvider(): array { diff --git a/tests/Supports/NonPrimitiveAssetTest.php b/tests/Supports/NonPrimitiveAssetTest.php index af8184b..08fd401 100644 --- a/tests/Supports/NonPrimitiveAssetTest.php +++ b/tests/Supports/NonPrimitiveAssetTest.php @@ -46,4 +46,4 @@ public function testNonPrimitiveAssets(string $name, string $expectedAsset, $val } abstract public function nonPrimitiveAssetsProvider(): array; -} \ No newline at end of file +} diff --git a/tests/Supports/PrimitiveAssetTest.php b/tests/Supports/PrimitiveAssetTest.php index 202f6bd..4ebecb7 100644 --- a/tests/Supports/PrimitiveAssetTest.php +++ b/tests/Supports/PrimitiveAssetTest.php @@ -38,7 +38,7 @@ public function falsyValuesProvider(): array { list(, static::$falsyValues) = self::getData(); - return array_map(fn($value) => [$value], static::$falsyValues); + return array_map(fn ($value) => [$value], static::$falsyValues); } abstract public function primitiveAssetsProvider(): array; diff --git a/tests/Supports/Reflection.php b/tests/Supports/Reflection.php index 036d5ea..2478907 100644 --- a/tests/Supports/Reflection.php +++ b/tests/Supports/Reflection.php @@ -48,4 +48,4 @@ protected function setPropertyValue(string $propertyName, $value, $object): void { $this->property($propertyName, $object)->setValue($object, $value); } -} \ No newline at end of file +} diff --git a/tests/Supports/UserAssetTest.php b/tests/Supports/UserAssetTest.php index eb2e803..637154c 100644 --- a/tests/Supports/UserAssetTest.php +++ b/tests/Supports/UserAssetTest.php @@ -9,7 +9,8 @@ trait UserAssetTest { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; public function primitiveAssetsProvider(): array { @@ -20,11 +21,11 @@ public function primitiveAssetsProvider(): array ['handle', $this->faker->userName, 'assertIsString'], ['displayName', $this->faker->name, 'assertIsString'], ['description', $this->faker->text, 'assertIsString'], - ['avatar', $this->faker->imageUrl(10,10), 'assertIsString'], - ['banner', $this->faker->imageUrl(10,10), 'assertIsString'], - ['followersCount', $this->faker->numberBetween(1,100), 'assertIsInt'], - ['followsCount', $this->faker->numberBetween(1,100), 'assertIsInt'], - ['postsCount', $this->faker->numberBetween(1,100), 'assertIsInt'], + ['avatar', $this->faker->imageUrl(10, 10), 'assertIsString'], + ['banner', $this->faker->imageUrl(10, 10), 'assertIsString'], + ['followersCount', $this->faker->numberBetween(1, 100), 'assertIsInt'], + ['followsCount', $this->faker->numberBetween(1, 100), 'assertIsInt'], + ['postsCount', $this->faker->numberBetween(1, 100), 'assertIsInt'], ]; } @@ -37,4 +38,4 @@ public function nonPrimitiveAssetsProvider(): array ['labels', GenericCollectionInterface::class, []], ]; } -} \ No newline at end of file +} diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php index 42e78f1..64cd712 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/CreateRecordTest.php @@ -109,4 +109,4 @@ public function testChaining() $this->assertInstanceOf(CreateRecord::class, $result); } -} \ No newline at end of file +} diff --git a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php index c1be36f..99adb56 100644 --- a/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php +++ b/tests/Unit/HTTP/API/Requests/Com/Atproto/Repo/UploadBlobTest.php @@ -84,4 +84,4 @@ public function testBuildReturnsInstanceWhenAllFieldsAreSet(): void $this->assertInstanceOf(UploadBlob::class, $result); } -} \ No newline at end of file +} diff --git a/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php b/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php index 4a526f8..c56a1f7 100644 --- a/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php +++ b/tests/Unit/Resources/App/Bsky/Actor/GetProfileResourceTest.php @@ -22,7 +22,8 @@ class GetProfileResourceTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; public function primitiveAssetsProvider(): array { diff --git a/tests/Unit/Resources/Assets/AssociatedAssetTest.php b/tests/Unit/Resources/Assets/AssociatedAssetTest.php index 59ad02b..a7adcf3 100644 --- a/tests/Unit/Resources/Assets/AssociatedAssetTest.php +++ b/tests/Unit/Resources/Assets/AssociatedAssetTest.php @@ -11,16 +11,17 @@ class AssociatedAssetTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; public function primitiveAssetsProvider(): array { list($this->faker) = self::getData(); return [ - ['lists', $this->faker->numberBetween(1,10), 'assertIsInt'], - ['feedgens', $this->faker->numberBetween(1,10), 'assertIsInt'], - ['starterPacks', $this->faker->numberBetween(1,10), 'assertIsInt'], + ['lists', $this->faker->numberBetween(1, 10), 'assertIsInt'], + ['feedgens', $this->faker->numberBetween(1, 10), 'assertIsInt'], + ['starterPacks', $this->faker->numberBetween(1, 10), 'assertIsInt'], ['labeler', $this->faker->boolean, 'assertIsBool'], ]; } diff --git a/tests/Unit/Resources/Assets/BaseAssetTest.php b/tests/Unit/Resources/Assets/BaseAssetTest.php index 6e29b47..d0c4d22 100644 --- a/tests/Unit/Resources/Assets/BaseAssetTest.php +++ b/tests/Unit/Resources/Assets/BaseAssetTest.php @@ -22,7 +22,7 @@ public function setUp(): void { $this->assetTestSetUp(); - list(,,$this->asset) = self::getData(); + list(, , $this->asset) = self::getData(); } protected static function getData(): array @@ -56,4 +56,4 @@ protected function resource(array $data): TestableAsset class TestableAsset implements AssetContract { use BaseAsset; -} \ No newline at end of file +} diff --git a/tests/Unit/Resources/Assets/FollowersAssetTest.php b/tests/Unit/Resources/Assets/FollowersAssetTest.php index 4c8ec3b..c6443b5 100644 --- a/tests/Unit/Resources/Assets/FollowersAssetTest.php +++ b/tests/Unit/Resources/Assets/FollowersAssetTest.php @@ -97,7 +97,7 @@ protected function generateComplexFollowerData(): array $range = range(1, $this->faker->numberBetween(1, 20)); return array_map(function () { - list(,,$schema) = static::getData(); + list(, , $schema) = static::getData(); return array_combine( array_keys($schema), @@ -126,7 +126,7 @@ protected function assertFollowerMatchesData(FollowerAsset $follower, array $dat protected function assertFollowerMatchesComplexData(FollowerAsset $follower): void { - list(,,$schema) = self::getData(); + list(, , $schema) = self::getData(); foreach ($schema as $key => $datum) { $expected = $datum['casted']; diff --git a/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php b/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php index 0f227aa..10d9066 100644 --- a/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php +++ b/tests/Unit/Resources/Assets/JoinedViaStarterPackAssetTest.php @@ -13,7 +13,8 @@ class JoinedViaStarterPackAssetTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; /** * @throws InvalidArgumentException @@ -41,9 +42,9 @@ public function primitiveAssetsProvider(): array return [ ['uri', $this->faker->word, 'assertIsString'], ['cid', $this->faker->word, 'assertIsString'], - ['listItemCount', $this->faker->numberBetween(1,10), 'assertIsInt'], - ['joinedWeekCount', $this->faker->numberBetween(1,10), 'assertIsInt'], - ['joinedAllCount', $this->faker->numberBetween(1,10), 'assertIsInt'], + ['listItemCount', $this->faker->numberBetween(1, 10), 'assertIsInt'], + ['joinedWeekCount', $this->faker->numberBetween(1, 10), 'assertIsInt'], + ['joinedAllCount', $this->faker->numberBetween(1, 10), 'assertIsInt'], ]; } } diff --git a/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php b/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php index f1dc94b..384ab00 100644 --- a/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php +++ b/tests/Unit/Resources/Assets/KnownFollowersAssetTest.php @@ -11,7 +11,8 @@ class KnownFollowersAssetTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; /** * @throws InvalidArgumentException @@ -25,12 +26,12 @@ public function nonPrimitiveAssetsProvider(): array { list($faker) = self::getData(); - $count = $faker->numberBetween(1,20); + $count = $faker->numberBetween(1, 20); return [ ['followers', FollowersAsset::class, [array_map(fn () => [ 'displayName' => $faker->name, - ], range(1,$count))]], + ], range(1, $count))]], ]; } diff --git a/tests/Unit/Resources/Assets/LabelAssetTest.php b/tests/Unit/Resources/Assets/LabelAssetTest.php index 42c6a49..f581f1e 100644 --- a/tests/Unit/Resources/Assets/LabelAssetTest.php +++ b/tests/Unit/Resources/Assets/LabelAssetTest.php @@ -12,7 +12,8 @@ class LabelAssetTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; /** * @param array $data diff --git a/tests/Unit/Resources/Assets/LabelsAssetTest.php b/tests/Unit/Resources/Assets/LabelsAssetTest.php index 660aa34..f2b0e39 100644 --- a/tests/Unit/Resources/Assets/LabelsAssetTest.php +++ b/tests/Unit/Resources/Assets/LabelsAssetTest.php @@ -40,7 +40,7 @@ public function testAssetWithNonPrimitiveTypes(): void protected function assertLabelMatchesComplexData(LabelAsset $label): void { - list(,,$schema) = self::getData(); + list(, , $schema) = self::getData(); foreach ($schema as $key => $datum) { $expected = $datum['casted']; diff --git a/tests/Unit/Resources/Assets/ViewerAssetTest.php b/tests/Unit/Resources/Assets/ViewerAssetTest.php index 99cb50b..9d5ea9a 100644 --- a/tests/Unit/Resources/Assets/ViewerAssetTest.php +++ b/tests/Unit/Resources/Assets/ViewerAssetTest.php @@ -15,7 +15,8 @@ class ViewerAssetTest extends TestCase { - use PrimitiveAssetTest, NonPrimitiveAssetTest; + use PrimitiveAssetTest; + use NonPrimitiveAssetTest; /** * @throws InvalidArgumentException From 94324e70cc38ea69aa496d7b9dd944a6b2e4b8b0 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 20 Sep 2024 17:25:18 +0400 Subject: [PATCH 04/27] Update GetFollowers Request and add Unit Tests - Added parameter setters for `actor`, `limit`, and `cursor` with validation for `limit`. - Implemented error handling with `AuthMissingException` and `MissingFieldProvidedException` during request building. - Enhanced `Authentication` trait with token management. - Added comprehensive unit tests for `GetFollowers` to ensure proper parameter handling, validation, and response. --- src/Exceptions/InvalidArgumentException.php | 10 ++ .../Requests/App/Bsky/Actor/GetProfile.php | 7 +- .../Requests/App/Bsky/Graph/GetFollowers.php | 65 +++++++-- src/HTTP/Traits/Authentication.php | 12 ++ src/HTTP/Traits/RequestBuilder.php | 8 +- .../App/Bsky/Graph/GetFollowersTest.php | 124 ++++++++++++++++++ 6 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/InvalidArgumentException.php create mode 100644 tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php new file mode 100644 index 0000000..7c06522 --- /dev/null +++ b/src/Exceptions/InvalidArgumentException.php @@ -0,0 +1,10 @@ +headers(), 'Authorization')) { + throw new AuthMissingException(); + } + $missing = []; if (! $this->queryParameter('actor')) { diff --git a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php index aea6468..d53bef8 100644 --- a/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php +++ b/src/HTTP/API/Requests/App/Bsky/Graph/GetFollowers.php @@ -2,31 +2,58 @@ namespace Atproto\HTTP\API\Requests\App\Bsky\Graph; -use Atproto\Client; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\Contracts\RequestContract; -use Atproto\Exceptions\Auth\AuthRequired; +use Atproto\Exceptions\Http\MissingFieldProvidedException; +use Atproto\Exceptions\Http\Response\AuthMissingException; +use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Helpers\Arr; use Atproto\HTTP\API\APIRequest; +use Atproto\HTTP\Traits\Authentication; use Atproto\Resources\App\Bsky\Graph\GetFollowersResource; class GetFollowers extends APIRequest { - public function __construct(Client $client = null) + use Authentication; + + public function actor(string $actor = null) + { + if (is_null($actor)) { + return $this->queryParameter('actor'); + } + + $this->queryParameter('actor', $actor); + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function limit(int $limit = null) { - if (! $client) { - return; + if (is_null($limit)) { + return (int) $this->queryParameter('limit') ?: null; + } + + if (! ($limit >= 1 && $limit <= 100)) { + throw new InvalidArgumentException("Limit must be between 1 and 100."); } - parent::__construct($client->prefix()); + $this->queryParameter('limit', $limit); - if (! $client->authenticated()) { - return; + return $this; + } + + public function cursor(string $cursor = null) + { + if (is_null($cursor)) { + return $this->queryParameter('cursor'); } - try { - $this->header('Authorization', 'Bearer ' . $client->authenticated()->accessJwt()); - $this->queryParameter('actor', $client->authenticated()->did()); - } catch (AuthRequired $e) {} + $this->queryParameter('cursor', $cursor); + + return $this; } public function resource(array $data): ResourceContract @@ -34,8 +61,20 @@ public function resource(array $data): ResourceContract return new GetFollowersResource($data); } + /** + * @throws MissingFieldProvidedException + * @throws AuthMissingException + */ public function build(): RequestContract { + if (! Arr::exists($this->headers(false), 'Authorization')) { + throw new AuthMissingException(); + } + + if (! $this->queryParameter('actor')) { + throw new MissingFieldProvidedException('actor'); + } + return $this; } -} \ No newline at end of file +} diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index cef08cc..f1a08b1 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -3,6 +3,7 @@ namespace Atproto\HTTP\Traits; use Atproto\Client; +use Atproto\Contracts\RequestContract; use Atproto\HTTP\API\APIRequest; trait Authentication @@ -19,4 +20,15 @@ public function __construct(Client $client) $this->header("Authorization", "Bearer " . $authenticated->accessJwt()); } } + + public function token(string $token = null) + { + if (is_null($token)) { + return $this->header('Authorization'); + } + + $this->header('Authorization', "Bearer " . $token); + + return $this; + } } diff --git a/src/HTTP/Traits/RequestBuilder.php b/src/HTTP/Traits/RequestBuilder.php index 0f262b7..af3d557 100644 --- a/src/HTTP/Traits/RequestBuilder.php +++ b/src/HTTP/Traits/RequestBuilder.php @@ -134,8 +134,12 @@ public function parameters($parameters = null) public function queryParameters($queryParameters = null) { - if (is_bool($queryParameters) && $queryParameters) { - return http_build_query($this->queryParameters); + if (is_bool($queryParameters)) { + if ($queryParameters) { + return http_build_query($this->queryParameters); + } + + return $this->queryParameters; } if (is_null($queryParameters)) { diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php new file mode 100644 index 0000000..4883f9b --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -0,0 +1,124 @@ +clientMock = $this->createMock(Client::class); + $this->request = new GetFollowers($this->clientMock); + } + + public function testActorSetterAndGetter() + { + $actor = 'testActor'; + $this->request->actor($actor); + $this->assertSame($actor, $this->request->actor(), 'Actor getter should return the value set by the setter.'); + } + + public function testLimitSetterAndGetter() + { + $limit = 50; + $this->request->limit($limit); + $this->assertSame($limit, $this->request->limit(), 'Limit getter should return the value set by the setter.'); + } + + public function testLimitSetterThrowsExceptionForZero() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(0); + } + + public function testLimitSetterThrowsExceptionForNegativeValue() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(-10); + } + + public function testLimitSetterThrowsExceptionForValueAboveMaximum() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Limit must be between 1 and 100.'); + $this->request->limit(101); + } + + public function testCursorSetterAndGetter() + { + $cursor = 'testCursor'; + $this->request->cursor($cursor); + $this->assertSame($cursor, $this->request->cursor(), 'Cursor getter should return the value set by the setter.'); + } + + public function testBuildThrowsExceptionWhenAuthorizationHeaderMissing() + { + $this->expectException(AuthMissingException::class); + $this->expectExceptionMessage('Authentication Required'); + + // Set required 'actor' parameter + $this->request->actor('testActor'); + + // Do not set 'Authorization' header + $this->request->build(); + } + + public function testBuildThrowsExceptionWhenActorParameterMissing() + { + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("Missing provided fields: actor"); + + // Set 'Authorization' header + $this->request->token('Bearer token'); + + // Do not set 'actor' parameter + $this->request->build(); + } + + public function testBuildSucceedsWithRequiredParameters() + { + // Set required 'Authorization' header and 'actor' parameter + $this->request->token('Bearer token'); + $this->request->actor('testActor'); + + // Should not throw any exceptions + $builtRequest = $this->request->build(); + $this->assertInstanceOf(GetFollowers::class, $builtRequest, 'Build should return an instance of GetFollowers.'); + } + + public function testResourceMethodReturnsCorrectInstance() + { + $data = ['followers' => []]; + $resource = $this->request->resource($data); + $this->assertInstanceOf(ResourceContract::class, $resource, 'Resource method should return an instance of ResourceContract.'); + } + + public function testLimitGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->limit(), 'Limit getter should return null when not set.'); + } + + public function testActorGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->actor(), 'Actor getter should return null when not set.'); + } + + public function testCursorGetterReturnsNullWhenNotSet() + { + $this->assertNull($this->request->cursor(), 'Cursor getter should return null when not set.'); + } +} From 40242bc2875ca2930d79234281f2b01c2ec4b7f4 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 00:55:25 +0400 Subject: [PATCH 05/27] Implement Observer pattern - Add SplSubject interface to Client class - Implement SplObserver in APIRequestContract - Update Authentication trait with observer functionality - Modify Smith trait to attach APIRequests as observers - Add unit tests for observer pattern implementation - Update feature tests to include observer notification scenario --- src/Client.php | 3 +- src/Contracts/HTTP/APIRequestContract.php | 3 +- src/HTTP/API/APIRequest.php | 11 ++- src/HTTP/Traits/Authentication.php | 8 +++ src/Traits/Authentication.php | 27 +++++++ src/Traits/Smith.php | 6 ++ tests/Feature/ClientTest.php | 24 +++++++ tests/Unit/ClientTest.php | 86 ++++++++++++++++++++++- 8 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/Client.php b/src/Client.php index 385af35..4875a28 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,8 +4,9 @@ use Atproto\Traits\Authentication; use Atproto\Traits\Smith; +use SplSubject; -class Client +class Client implements SplSubject { use Smith; use Authentication; diff --git a/src/Contracts/HTTP/APIRequestContract.php b/src/Contracts/HTTP/APIRequestContract.php index eb483f1..3228df3 100644 --- a/src/Contracts/HTTP/APIRequestContract.php +++ b/src/Contracts/HTTP/APIRequestContract.php @@ -3,8 +3,9 @@ namespace Atproto\Contracts\HTTP; use Atproto\Contracts\RequestContract; +use SplObserver; -interface APIRequestContract extends RequestContract +interface APIRequestContract extends RequestContract, SplObserver { public const API_BASE_URL = 'https://bsky.social'; public const API_BASE_HEADERS = [ diff --git a/src/HTTP/API/APIRequest.php b/src/HTTP/API/APIRequest.php index c99ffc5..aba1b35 100644 --- a/src/HTTP/API/APIRequest.php +++ b/src/HTTP/API/APIRequest.php @@ -6,10 +6,11 @@ use Atproto\Contracts\HTTP\APIRequestContract; use Atproto\Contracts\HTTP\Resources\ResourceContract; use Atproto\HTTP\Request; +use SplSubject; abstract class APIRequest extends Request implements APIRequestContract { - private Client $client; + protected Client $client; public function __construct(Client $client) { @@ -22,7 +23,7 @@ public function send(): ResourceContract return $this->resource(parent::send()); } - private function initialize(): void + protected function initialize(): void { $this->origin(self::API_BASE_URL) ->path($this->endpoint()) @@ -42,4 +43,10 @@ private function endpoint(): string } abstract public function resource(array $data): ResourceContract; + + public function update(SplSubject $subject): void + { + /** @var Client $subject */ + $this->client = $subject; + } } diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index cef08cc..28cce47 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -4,6 +4,7 @@ use Atproto\Client; use Atproto\HTTP\API\APIRequest; +use SplSubject; trait Authentication { @@ -14,6 +15,13 @@ public function __construct(Client $client) } parent::__construct($client); + $this->update($client); + } + + public function update(SplSubject $client): void + { + /** @var Client $client */ + parent::update($client); if ($authenticated = $client->authenticated()) { $this->header("Authorization", "Bearer " . $authenticated->accessJwt()); diff --git a/src/Traits/Authentication.php b/src/Traits/Authentication.php index edda2dc..c950766 100644 --- a/src/Traits/Authentication.php +++ b/src/Traits/Authentication.php @@ -4,10 +4,18 @@ use Atproto\Exceptions\BlueskyException; use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; +use SplObjectStorage; +use SplObserver; trait Authentication { private ?CreateSessionResource $authenticated = null; + private SplObjectStorage $observers; + + public function __construct() + { + $this->observers = new SplObjectStorage(); + } /** * @throws BlueskyException @@ -20,10 +28,29 @@ public function authenticate(string $identifier, string $password): void $response = $request->send(); $this->authenticated = $response; + + $this->notify(); } public function authenticated(): ?CreateSessionResource { return $this->authenticated; } + + public function attach(SplObserver $observer) + { + $this->observers->attach($observer); + } + + public function detach(SplObserver $observer) + { + $this->observers->detach($observer); + } + + public function notify() + { + foreach ($this->observers as $observer) { + $observer->update($this); + } + } } diff --git a/src/Traits/Smith.php b/src/Traits/Smith.php index ce5b3b0..f9f121a 100644 --- a/src/Traits/Smith.php +++ b/src/Traits/Smith.php @@ -3,6 +3,8 @@ namespace Atproto\Traits; use Atproto\Client; +use Atproto\Contracts\HTTP\APIRequestContract; +use Atproto\Contracts\Observer; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\Request\RequestNotFoundException; @@ -31,8 +33,10 @@ public function forge(...$arguments): RequestContract throw new RequestNotFoundException("$request class does not exist."); } + /** @var APIRequestContract $request */ $request = new $request(...$arguments); + $this->attach($request); $this->refresh(); return $request; @@ -55,4 +59,6 @@ private function request(): string { return $this->prefix . $this->path(); } + + } diff --git a/tests/Feature/ClientTest.php b/tests/Feature/ClientTest.php index 6a27ba9..4db36f6 100644 --- a/tests/Feature/ClientTest.php +++ b/tests/Feature/ClientTest.php @@ -96,4 +96,28 @@ public function testClientThrowsExceptionWhenAuthenticationRequired(): void ->forge() ->send(); } + + /** + * @throws BlueskyException + */ + public function testObserverNotificationOnAuthentication(): void + { + $request = $this->client->app() + ->bsky() + ->actor() + ->getProfile() + ->forge(); + + $this->client->authenticate( + $_ENV['BLUESKY_IDENTIFIER'], + $_ENV['BLUESKY_PASSWORD'] + ); + + $response = $request->actor($this->client->authenticated()->did()) + ->build() + ->send(); + + $this->assertInstanceOf(ResourceContract::class, $response); + $this->assertInstanceOf(GetProfileResource::class, $response); + } } diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 9699bd1..1660d24 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -5,8 +5,12 @@ use Atproto\Client; use Atproto\Contracts\RequestContract; use Atproto\Exceptions\Http\Request\RequestNotFoundException; +use Atproto\HTTP\API\APIRequest; +use Atproto\HTTP\API\Requests\Com\Atproto\Server\CreateSession; +use Atproto\Resources\Com\Atproto\Server\CreateSessionResource; use PHPUnit\Framework\TestCase; use ReflectionException; +use SplObserver; use Tests\Supports\Reflection; class ClientTest extends TestCase @@ -48,7 +52,7 @@ public function testNamespaceGeneration(): void $this->assertSame($expectedNamespace, $namespace); } - public function testBuildThrowsRequestNotFoundException(): void + public function testForgeThrowsRequestNotFoundException(): void { $this->client->nonExistentMethod(); @@ -61,7 +65,7 @@ public function testBuildThrowsRequestNotFoundException(): void /** * @throws RequestNotFoundException */ - public function testBuildReturnsRequestContract(): void + public function testForgeReturnsRequestContract(): void { $this->client->app()->bsky()->actor()->getProfile(); @@ -90,4 +94,82 @@ public function testAuthenticatedReturnsNullWhenNotAuthenticated(): void { $this->assertNull($this->client->authenticated()); } + + public function testAttachObserver(): void + { + $mockObserver = $this->createMock(SplObserver::class); + $this->client->attach($mockObserver); + + $observers = $this->getPropertyValue('observers', $this->client); + $this->assertCount(1, $observers); + $this->assertTrue($observers->contains($mockObserver)); + } + + public function testDetachObserver(): void + { + $mockObserver = $this->createMock(SplObserver::class); + $this->client->attach($mockObserver); + $this->client->detach($mockObserver); + + $observers = $this->getPropertyValue('observers', $this->client); + $this->assertCount(0, $observers); + } + + public function testNotifyObservers(): void + { + $mockObserver1 = $this->createMock(SplObserver::class); + $mockObserver2 = $this->createMock(SplObserver::class); + + $mockObserver1->expects($this->once())->method('update')->with($this->client); + $mockObserver2->expects($this->once())->method('update')->with($this->client); + + $this->client->attach($mockObserver1); + $this->client->attach($mockObserver2); + + $this->client->notify(); + } + + public function testForgeAttachesObserver(): void + { + $this->client->app()->bsky()->actor()->getProfile(); + $request = $this->client->forge(); + + $observers = $this->getPropertyValue('observers', $this->client); + $this->assertCount(1, $observers); + $this->assertTrue($observers->contains($request)); + } + + public function testAPIRequestUpdatesOnNotify(): void + { + $this->mockAuthenticate(); + + $mockRequest = $this->createMock(APIRequest::class); + + $this->client->attach($mockRequest); + + $mockRequest->expects($this->once()) + ->method('update') + ->with($this->client); + + $this->client->authenticate('username', 'password'); + } + + private function mockAuthenticate() + { + $mockCreateSession = $this->getMockBuilder(CreateSession::class) + ->disableOriginalConstructor() + ->onlyMethods(['send']) + ->getMock(); + + $mockCreateSession->expects($this->once()) + ->method('send') + ->willReturn($this->createMock(CreateSessionResource::class)); + + $this->client = $this->getMockBuilder(Client::class) + ->onlyMethods(['forge']) + ->getMock(); + + $this->client->method('forge') + ->willReturn($mockCreateSession); + } } From dc6deb156f271e8dd0ec9d5b6898aad74dda272c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 02:31:23 +0400 Subject: [PATCH 06/27] Add GetProfiles request for fetching multiple user profiles - Implement ProfileAssetType for type validation - Add token method to Authentication trait for easier token handling - Create InvalidArgumentException - Modify MissingFieldProvidedException message - Update .gitignore to exclude PHP-CS-Fixer files - Update RequestContract to allow array query parameters --- .gitignore | 13 +- src/Contracts/RequestContract.php | 4 +- .../Http/MissingFieldProvidedException.php | 2 +- src/Exceptions/InvalidArgumentException.php | 3 +- .../Types/NonPrimitive/ProfileAssetType.php | 14 +++ .../Requests/App/Bsky/Actor/GetProfiles.php | 71 +++++++++++ src/HTTP/Traits/Authentication.php | 3 +- src/HTTP/Traits/RequestBuilder.php | 2 +- .../App/Bsky/Actor/GetProfilesResource.php | 21 ++++ src/Resources/Assets/ProfileAsset.php | 11 ++ src/Resources/Assets/ProfilesAsset.php | 28 +++++ tests/Feature/ClientTest.php | 3 - .../App/Bsky/Actor/GetProfilesTest.php | 116 ++++++++++++++++++ .../App/Bsky/Actor/GetProfileTest.php | 2 +- .../App/Bsky/Actor/GetProfilesTest.php | 94 ++++++++++++++ 15 files changed, 374 insertions(+), 13 deletions(-) create mode 100644 src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php create mode 100644 src/HTTP/API/Requests/App/Bsky/Actor/GetProfiles.php create mode 100644 src/Resources/App/Bsky/Actor/GetProfilesResource.php create mode 100644 src/Resources/Assets/ProfileAsset.php create mode 100644 src/Resources/Assets/ProfilesAsset.php create mode 100644 tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php create mode 100644 tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php diff --git a/.gitignore b/.gitignore index f183cec..206dca0 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,15 @@ fabric.properties # Build data /build/ -# End of https://www.toptal.com/developers/gitignore/api/phpstorm+all,composer,phpunit +### PHP-CS-Fixer ### +# Covers PHP CS Fixer +# Reference: https://cs.symfony.com/ + +# Generated files +.php-cs-fixer.cache + +# Local config See: https://cs.symfony.com/doc/config.html +.php-cs-fixer.php + +# End of https://www.toptal.com/developers/gitignore/api/php-cs-fixer + diff --git a/src/Contracts/RequestContract.php b/src/Contracts/RequestContract.php index abcdcd1..7d1fea2 100644 --- a/src/Contracts/RequestContract.php +++ b/src/Contracts/RequestContract.php @@ -62,10 +62,10 @@ public function parameter(string $name, $value = null); * Get or set a query parameter in the request. * * @param string $name The query parameter name - * @param string|null $value The query parameter value to set, or null to get the current value. + * @param array|string|null $value The query parameter value to set, or null to get the current value. * @return mixed|string|null The query parameter value or instance for chaining */ - public function queryParameter(string $name, string $value = null); + public function queryParameter(string $name, $value = null); /** * Get or set multiple headers at once. diff --git a/src/Exceptions/Http/MissingFieldProvidedException.php b/src/Exceptions/Http/MissingFieldProvidedException.php index de3f9fe..7e0c342 100644 --- a/src/Exceptions/Http/MissingFieldProvidedException.php +++ b/src/Exceptions/Http/MissingFieldProvidedException.php @@ -8,6 +8,6 @@ class MissingFieldProvidedException extends BlueskyException { public function __construct($message = "", $code = 0, $previous = null) { - parent::__construct("Missing provided fields: $message", $code, $previous); + parent::__construct("Missing fields provided: $message", $code, $previous); } } diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php index 7c06522..4c80bfc 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Exceptions/InvalidArgumentException.php @@ -6,5 +6,4 @@ class InvalidArgumentException extends BlueskyException { - -} \ No newline at end of file +} diff --git a/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php b/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php new file mode 100644 index 0000000..6b43ffe --- /dev/null +++ b/src/GenericCollection/Types/NonPrimitive/ProfileAssetType.php @@ -0,0 +1,14 @@ +actors; + } + + if ($actors->gettype() !== StringType::class) { + throw new InvalidArgumentException(sprintf( + "'\$actors' collection must be of type '%s' but is of type '%s'", + StringType::class, + $actors->gettype() + )); + } + + if (! ($actors->count() >= 1 && $actors->count() <= 25)) { + throw new InvalidArgumentException("'\$actors' collection count must be between 1 and 25"); + } + + $this->actors = $actors; + + $this->queryParameter('actors', array_values($this->actors->toArray())); + + return $this; + } + + /** + * @throws AuthRequired + * @throws MissingFieldProvidedException + */ + public function build(): RequestContract + { + if (is_null($this->header('Authorization'))) { + throw new AuthRequired(); + } + + if (is_null($this->actors)) { + throw new MissingFieldProvidedException("actors"); + } + + return $this; + } +} diff --git a/src/HTTP/Traits/Authentication.php b/src/HTTP/Traits/Authentication.php index 94d15d6..69e54ab 100644 --- a/src/HTTP/Traits/Authentication.php +++ b/src/HTTP/Traits/Authentication.php @@ -3,7 +3,6 @@ namespace Atproto\HTTP\Traits; use Atproto\Client; -use Atproto\Contracts\RequestContract; use Atproto\HTTP\API\APIRequest; use SplSubject; @@ -35,7 +34,7 @@ public function token(string $token = null) return $this->header('Authorization'); } - $this->header('Authorization', "Bearer " . $token); + $this->header('Authorization', "Bearer $token"); return $this; } diff --git a/src/HTTP/Traits/RequestBuilder.php b/src/HTTP/Traits/RequestBuilder.php index af3d557..e0ab102 100644 --- a/src/HTTP/Traits/RequestBuilder.php +++ b/src/HTTP/Traits/RequestBuilder.php @@ -79,7 +79,7 @@ public function parameter(string $name, $value = null) return $this; } - public function queryParameter(string $name, string $value = null) + public function queryParameter(string $name, $value = null) { if (is_null($value)) { return $this->queryParameters[$name] ?? null; diff --git a/src/Resources/App/Bsky/Actor/GetProfilesResource.php b/src/Resources/App/Bsky/Actor/GetProfilesResource.php new file mode 100644 index 0000000..3c35c4f --- /dev/null +++ b/src/Resources/App/Bsky/Actor/GetProfilesResource.php @@ -0,0 +1,21 @@ + ProfilesAsset::class + ]; + } +} diff --git a/src/Resources/Assets/ProfileAsset.php b/src/Resources/Assets/ProfileAsset.php new file mode 100644 index 0000000..9f45a39 --- /dev/null +++ b/src/Resources/Assets/ProfileAsset.php @@ -0,0 +1,11 @@ +send(); } - /** - * @throws BlueskyException - */ public function testObserverNotificationOnAuthentication(): void { $request = $this->client->app() diff --git a/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php new file mode 100644 index 0000000..4d18679 --- /dev/null +++ b/tests/Feature/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php @@ -0,0 +1,116 @@ +client = new Client(); + + $this->request = $this->client + ->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge(); + } + + /** + * @throws BlueskyException + */ + private function authenticate(): void + { + $username = $_ENV['BLUESKY_IDENTIFIER']; + $password = $_ENV['BLUESKY_PASSWORD']; + + $this->assertIsString($username); + $this->assertIsString($password); + + $this->client->authenticate($username, $password); + } + + /** + * @throws InvalidArgumentException + * @throws \Atproto\Exceptions\InvalidArgumentException + * @throws BlueskyException + */ + public function testGettingProfiles(): void + { + $this->authenticate(); + + $profiles = new GenericCollection(new StringType(), [ + $this->client->authenticated()->did() + ]); + + $response = $this->client + ->app() + ->bsky() + ->actor() + ->getProfiles() + ->forge() + ->actors($profiles) + ->build() + ->send(); + + $this->assertInstanceOf(GetProfilesResource::class, $response); + $this->assertSame($profiles->count(), $response->profiles()->count()); + + $actualDidArr = array_map(fn (ProfileAsset $profile) => $profile->did(), $response->profiles()->toArray()); + + $this->assertSame($profiles->toArray(), $actualDidArr); + } + + public function testGettingProfilesThrowsExceptionWhenAuthTokenIsMissing(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(AuthMissingException::class); + $this->expectExceptionMessage("Authentication Required"); + + $this->request->send(); + } + + public function testGettingProfilesThrowsExceptionWhenAuthTokenIsInvalid(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage("Malformed authorization header"); + $this->expectExceptionCode(400); + + $this->request->token('Bearer token')->send(); + } + + public function testGettingProfilesThrowsExceptionWhenActorsMissing(): void + { + $this->expectException(BlueskyException::class); + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage("Missing fields provided: actors"); + + $this->authenticate(); + + $request = (new GetProfiles($this->client))->build(); + + $request->send(); + } +} diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index 6302cfa..6b842ac 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -91,7 +91,7 @@ public function testBuildReturnsSameInterface(): void public function testBuildThrowsAnExceptionWhenActorDoesNotExist(): void { $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing provided fields: actor, token"); + $this->expectExceptionMessage("Missing fields provided: actor, token"); $this->request->build(); } diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php new file mode 100644 index 0000000..6bc3a4e --- /dev/null +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfilesTest.php @@ -0,0 +1,94 @@ +client = $this->createMock(Client::class); + $this->request = new GetProfiles($this->client); + } + + /** + * @throws InvalidArgumentException + * @throws \GenericCollection\Exceptions\InvalidArgumentException + */ + public function testActorsSetterAndGetter(): void + { + $actors = new GenericCollection(new StringType(), ['actor1', 'actor2']); + $this->request->actors($actors); + $this->assertSame($actors, $this->request->actors(), 'Actors getter should return the value set by the setter.'); + } + + public function testActorsSetterThrowsExceptionForInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection must be of type 'GenericCollection\Types\Primitive\StringType'"); + $this->request->actors(new GenericCollection(\stdClass::class)); + } + + public function testActorsSetterThrowsExceptionForTooFewActors(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection count must be between 1 and 25"); + $this->request->actors(new GenericCollection(StringType::class)); + } + + /** + * @throws \GenericCollection\Exceptions\InvalidArgumentException + */ + public function testActorsSetterThrowsExceptionForTooManyActors(): void + { + $actors = new GenericCollection(new StringType()); + for ($i = 0; $i <= 25; $i++) { + $actors->add($i, "actor$i"); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'\$actors' collection count must be between 1 and 25"); + $this->request->actors($actors); + } + + public function testBuildThrowsAuthRequiredException(): void + { + $this->expectException(AuthRequired::class); + $this->request->build(); + } + + public function testBuildThrowsMissingFieldProvidedException(): void + { + $this->request->token('Bearer token'); + $this->expectException(MissingFieldProvidedException::class); + $this->expectExceptionMessage('actors'); + $this->request->build(); + } + + public function testBuildSucceeds(): void + { + $actors = new GenericCollection(new StringType(), ['actor1']); + $this->request->token('Bearer token')->actors($actors); + + $this->assertInstanceOf(GetProfiles::class, $this->request->build(), 'Build should return an instance of GetProfiles.'); + } + + public function testResourceMethodReturnsCorrectInstance(): void + { + $data = ['actors' => []]; + $resource = $this->request->resource($data); + $this->assertInstanceOf(GetProfilesResource::class, $resource, 'Resource method should return an instance of GetProfilesResource.'); + } +} From 52d51f35d4fa5acd600744fdc5f2b55a269155f8 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 02:39:26 +0400 Subject: [PATCH 07/27] FIX: Refactor GetProfile request and update related tests --- .../API/Requests/App/Bsky/Actor/GetProfile.php | 14 ++------------ .../API/Requests/App/Bsky/Actor/GetProfileTest.php | 4 +++- .../Requests/App/Bsky/Graph/GetFollowersTest.php | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php index c5b3fe1..954ac2f 100644 --- a/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php +++ b/src/HTTP/API/Requests/App/Bsky/Actor/GetProfile.php @@ -49,22 +49,12 @@ public function token(string $token = null) */ public function build(): RequestContract { - if (! Arr::exists($this->headers(), 'Authorization')) { + if (! $this->header('Authorization')) { throw new AuthMissingException(); } - $missing = []; - if (! $this->queryParameter('actor')) { - $missing[] = 'actor'; - } - - if (! $this->header('Authorization')) { - $missing[] = 'token'; - } - - if (! empty($missing)) { - throw new MissingFieldProvidedException(implode(", ", $missing)); + throw new MissingFieldProvidedException('actor'); } return $this; diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php index 6b842ac..9bbce9a 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Actor/GetProfileTest.php @@ -90,8 +90,10 @@ public function testBuildReturnsSameInterface(): void public function testBuildThrowsAnExceptionWhenActorDoesNotExist(): void { + $this->request->token($this->faker->word); + $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing fields provided: actor, token"); + $this->expectExceptionMessage("Missing fields provided: actor"); $this->request->build(); } diff --git a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php index 4883f9b..508bf92 100644 --- a/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php +++ b/tests/Unit/HTTP/API/Requests/App/Bsky/Graph/GetFollowersTest.php @@ -80,7 +80,7 @@ public function testBuildThrowsExceptionWhenAuthorizationHeaderMissing() public function testBuildThrowsExceptionWhenActorParameterMissing() { $this->expectException(MissingFieldProvidedException::class); - $this->expectExceptionMessage("Missing provided fields: actor"); + $this->expectExceptionMessage("Missing fields provided: actor"); // Set 'Authorization' header $this->request->token('Bearer token'); From 93b85f7d3fa60ee5d3875ce6f86793d4a9d44bac Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 23 Sep 2024 22:12:03 +0400 Subject: [PATCH 08/27] WIP: Implement Facet lexicon - Add new RichText classes: FeatureAbstract, Link, Mention, and Tag - Implement Facet lexicon structure as per app.bsky.richtext.facet schema - Add unit tests for FeatureAbstract serialization - Improve type safety and add input validation for union facet classes --- .../App/Bsky/RichText/FeatureAbstract.php | 25 +++++++++ src/Lexicons/App/Bsky/RichText/Link.php | 35 +++++++++++++ src/Lexicons/App/Bsky/RichText/Mention.php | 25 +++++++++ src/Lexicons/App/Bsky/RichText/Tag.php | 34 +++++++++++++ .../App/Bsky/RichText/FeatureAbstractTest.php | 49 ++++++++++++++++++ .../Lexicons/App/Bsky/RichText/LinkTest.php | 51 +++++++++++++++++++ .../App/Bsky/RichText/MentionTest.php | 20 ++++++++ .../Lexicons/App/Bsky/RichText/TagTest.php | 22 ++++++++ 8 files changed, 261 insertions(+) create mode 100644 src/Lexicons/App/Bsky/RichText/FeatureAbstract.php create mode 100644 src/Lexicons/App/Bsky/RichText/Link.php create mode 100644 src/Lexicons/App/Bsky/RichText/Mention.php create mode 100644 src/Lexicons/App/Bsky/RichText/Tag.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php new file mode 100644 index 0000000..9ea75a0 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -0,0 +1,25 @@ + $this->type(),], + $this->schema() + ); + } + + public function __toString(): string + { + return json_encode($this); + } + + abstract public function schema(): array; + abstract public function type(): string; +} diff --git a/src/Lexicons/App/Bsky/RichText/Link.php b/src/Lexicons/App/Bsky/RichText/Link.php new file mode 100644 index 0000000..7bc4e4f --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Link.php @@ -0,0 +1,35 @@ +url = $url; + } + + public function schema(): array + { + return [ + "uri" => $this->url, + ]; + } + + public function type(): string + { + return "link"; + } +} diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php new file mode 100644 index 0000000..dbbfc16 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -0,0 +1,25 @@ +did = $did; + } + + public function schema(): array + { + return [ + "did" => $this->did, + ]; + } + + public function type(): string + { + return "mention"; + } +} diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php new file mode 100644 index 0000000..bcb2108 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -0,0 +1,34 @@ + 640) { + throw new InvalidArgumentException("Tag cannot be longer than 640 characters."); + } + + $this->tag = $tag; + } + + public function schema(): array + { + return [ + "tag" => $this->tag + ]; + } + + public function type(): string + { + return "tag"; + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php new file mode 100644 index 0000000..5144cce --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php @@ -0,0 +1,49 @@ +faker = Factory::create(); + } + + /** @dataProvider featureProvider */ + public function testFeatureSerializationIsCorrect(string $class, string $input, array $expectedSchema): void + { + $instance = new $class($input); + + $this->assertSame($this->buildExpectedJson($expectedSchema), json_encode($instance)); + $this->assertSame($this->buildExpectedJson($expectedSchema), (string)$instance); + } + + public function featureProvider(): array + { + $faker = Factory::create(); + + $url = $faker->url; + $did = $faker->uuid; + $tag = $faker->word; + + return [ + [Link::class, $url, ['type' => 'link', 'uri' => $url]], + [Mention::class, $did, ['type' => 'mention', 'did' => $did]], + [Tag::class, $tag, ['type' => 'tag', 'tag' => $tag]], + ]; + } + + private function buildExpectedJson(array $schema): string + { + return json_encode($schema); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php new file mode 100644 index 0000000..9ae978f --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php @@ -0,0 +1,51 @@ +faker = Factory::create(); + } + + public function testConstructorThrowsExceptionWhenPassedInvalidDataType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid URI: 123"); + + new Link(123); + } + + public function testLinkThrowsExceptionWhenPassedInvalidURL() + { + $invalidURL = $this->faker->word; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid URI: $invalidURL"); + + new Link($invalidURL); + } + + /** + * @throws InvalidArgumentException|ReflectionException + */ + public function testLinkConstructorWorksCorrectly() + { + $url = $this->faker->url; + $link = new Link($url); + + $this->assertSame($url, $this->getPropertyValue('url', $link)); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php new file mode 100644 index 0000000..b07447d --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php @@ -0,0 +1,20 @@ +faker->word; + $mention = new Mention($expected); + + $this->assertSame($expected, $this->getPropertyValue('did', $mention)); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php new file mode 100644 index 0000000..e0507de --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php @@ -0,0 +1,22 @@ +faker->word; + $tag = new Tag($expected); + + $this->assertSame($expected, $this->getPropertyValue('tag', $tag)); + } +} From 0ac209e71f8a08f7730d5fc6f2fcb37ee3e8c5ea Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Mon, 30 Sep 2024 21:41:01 +0400 Subject: [PATCH 09/27] Update dependencies --- composer.lock | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/composer.lock b/composer.lock index 93b0564..44e0bc8 100644 --- a/composer.lock +++ b/composer.lock @@ -232,16 +232,16 @@ }, { "name": "shahmal1yev/gcollection", - "version": "1.0.6", + "version": "1.0.7", "source": { "type": "git", "url": "https://github.com/shahmal1yev/gcollection.git", - "reference": "17c04887dd852eeedb4451e1d276d4faa22eb15a" + "reference": "1ec135b2c65e15b1ab97b886c21bf49adc1358cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/shahmal1yev/gcollection/zipball/17c04887dd852eeedb4451e1d276d4faa22eb15a", - "reference": "17c04887dd852eeedb4451e1d276d4faa22eb15a", + "url": "https://api.github.com/repos/shahmal1yev/gcollection/zipball/1ec135b2c65e15b1ab97b886c21bf49adc1358cc", + "reference": "1ec135b2c65e15b1ab97b886c21bf49adc1358cc", "shasum": "" }, "require": { @@ -261,9 +261,9 @@ "description": "GenericCollection is a versatile PHP library that provides a type-safe collection class for managing various data types. Simplify your data management with intuitive methods and strong type constraints.", "support": { "issues": "https://github.com/shahmal1yev/gcollection/issues", - "source": "https://github.com/shahmal1yev/gcollection/tree/1.0.6" + "source": "https://github.com/shahmal1yev/gcollection/tree/1.0.7" }, - "time": "2024-07-27T22:29:24+00:00" + "time": "2024-09-30T17:35:29+00:00" }, { "name": "symfony/deprecation-contracts", @@ -334,20 +334,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -394,7 +394,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -410,24 +410,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -474,7 +474,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -490,20 +490,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/translation", - "version": "v5.4.42", + "version": "v5.4.44", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "1d702caccb9f091b738696185f778b1bfef7b5b2" + "reference": "6fed3a20b5b87ee9cdd9dacf545922b8fd475921" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/1d702caccb9f091b738696185f778b1bfef7b5b2", - "reference": "1d702caccb9f091b738696185f778b1bfef7b5b2", + "url": "https://api.github.com/repos/symfony/translation/zipball/6fed3a20b5b87ee9cdd9dacf545922b8fd475921", + "reference": "6fed3a20b5b87ee9cdd9dacf545922b8fd475921", "shasum": "" }, "require": { @@ -571,7 +571,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v5.4.42" + "source": "https://github.com/symfony/translation/tree/v5.4.44" }, "funding": [ { @@ -587,7 +587,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:14:19+00:00" + "time": "2024-09-15T08:12:35+00:00" }, { "name": "symfony/translation-contracts", @@ -864,16 +864,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.3.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3abf7425cd284141dc5d8d14a9ee444de3345d1a", + "reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a", "shasum": "" }, "require": { @@ -916,9 +916,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.0" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-09-29T13:56:26+00:00" }, { "name": "phar-io/manifest", @@ -1040,16 +1040,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.3", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0fcbf194ab63d8159bb70d9aa3e1350051632009", - "reference": "0fcbf194ab63d8159bb70d9aa3e1350051632009", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -1094,7 +1094,7 @@ "type": "github" } ], - "time": "2024-09-09T08:10:35+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { "name": "phpunit/php-code-coverage", From 37acb541089c6d24f80e1ca90f32c0a5b41786b1 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Wed, 9 Oct 2024 01:50:01 +0400 Subject: [PATCH 10/27] WIP: Implement Post Builder --- src/API/Com/Atrproto/Repo/UploadBlob.php | 2 +- src/Collections/FacetCollection.php | 15 ++ src/Collections/FeatureCollection.php | 14 ++ .../Types/NonPrimitive/FollowerAssetType.php | 2 +- .../Types/NonPrimitive/LabelAssetType.php | 2 +- .../Types/NonPrimitive/ProfileAssetType.php | 2 +- src/Contracts/BuilderInterface.php | 8 + src/Contracts/LexiconBuilder.php | 9 + .../App/Bsky/Embed/EmbedInterface.php | 9 + .../App/Bsky/Embed/ImageInterface.php | 12 + .../App/Bsky/Embed/VideoInterface.php | 15 ++ .../App/Bsky/Feed/PostBuilderContract.php | 25 +++ .../App/Bsky/RichText/ByteSliceContract.php | 11 + .../App/Bsky/RichText/FacetContract.php | 13 ++ src/Contracts/Stringable.php | 8 + src/Exceptions/RuntimeException.php | 7 + .../Requests/App/Bsky/Actor/GetProfile.php | 2 +- .../Requests/App/Bsky/Graph/GetFollowers.php | 2 +- src/Lexicons/App/Bsky/Embed/BlobHandler.php | 49 +++++ src/Lexicons/App/Bsky/Embed/Caption.php | 67 ++++++ .../Embed/Collections/CaptionCollection.php | 42 ++++ .../Embed/Collections/ImageCollection.php | 57 +++++ src/Lexicons/App/Bsky/Embed/File.php | 35 +++ src/Lexicons/App/Bsky/Embed/Image.php | 70 ++++++ src/Lexicons/App/Bsky/Embed/Video.php | 85 ++++++++ src/Lexicons/App/Bsky/Feed/Post.php | 205 ++++++++++++++++++ src/Lexicons/App/Bsky/RichText/ByteSlice.php | 35 +++ src/Lexicons/App/Bsky/RichText/Facet.php | 47 ++++ .../App/Bsky/RichText/FeatureAbstract.php | 28 ++- .../App/Bsky/RichText/FeatureFactory.php | 22 ++ src/Lexicons/App/Bsky/RichText/Link.php | 24 +- src/Lexicons/App/Bsky/RichText/Mention.php | 19 +- src/Lexicons/App/Bsky/RichText/Tag.php | 28 +-- src/Resources/Assets/FollowersAsset.php | 2 +- src/Resources/Assets/LabelsAsset.php | 2 +- src/Resources/BaseResource.php | 2 +- src/{Helpers => Support}/Arr.php | 2 +- src/{Helpers => Support}/File.php | 2 +- src/Support/Media.php | 10 + .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 195 +++++++++++++++++ .../App/Bsky/RichText/FeatureAbstractTest.php | 58 ++--- .../App/Bsky/RichText/FeatureTests.php | 110 ++++++++++ .../Lexicons/App/Bsky/RichText/LinkTest.php | 46 +--- .../App/Bsky/RichText/MentionTest.php | 15 +- .../Lexicons/App/Bsky/RichText/TagTest.php | 17 +- 45 files changed, 1276 insertions(+), 156 deletions(-) create mode 100644 src/Collections/FacetCollection.php create mode 100644 src/Collections/FeatureCollection.php rename src/{GenericCollection => Collections}/Types/NonPrimitive/FollowerAssetType.php (82%) rename src/{GenericCollection => Collections}/Types/NonPrimitive/LabelAssetType.php (81%) rename src/{GenericCollection => Collections}/Types/NonPrimitive/ProfileAssetType.php (81%) create mode 100644 src/Contracts/BuilderInterface.php create mode 100644 src/Contracts/LexiconBuilder.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php create mode 100644 src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php create mode 100644 src/Contracts/Lexicons/App/Bsky/RichText/ByteSliceContract.php create mode 100644 src/Contracts/Lexicons/App/Bsky/RichText/FacetContract.php create mode 100644 src/Contracts/Stringable.php create mode 100644 src/Exceptions/RuntimeException.php create mode 100644 src/Lexicons/App/Bsky/Embed/BlobHandler.php create mode 100644 src/Lexicons/App/Bsky/Embed/Caption.php create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php create mode 100644 src/Lexicons/App/Bsky/Embed/File.php create mode 100644 src/Lexicons/App/Bsky/Embed/Image.php create mode 100644 src/Lexicons/App/Bsky/Embed/Video.php create mode 100644 src/Lexicons/App/Bsky/Feed/Post.php create mode 100644 src/Lexicons/App/Bsky/RichText/ByteSlice.php create mode 100644 src/Lexicons/App/Bsky/RichText/Facet.php create mode 100644 src/Lexicons/App/Bsky/RichText/FeatureFactory.php rename src/{Helpers => Support}/Arr.php (99%) rename src/{Helpers => Support}/File.php (99%) create mode 100644 src/Support/Media.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php diff --git a/src/API/Com/Atrproto/Repo/UploadBlob.php b/src/API/Com/Atrproto/Repo/UploadBlob.php index ce205f2..a81d934 100644 --- a/src/API/Com/Atrproto/Repo/UploadBlob.php +++ b/src/API/Com/Atrproto/Repo/UploadBlob.php @@ -4,7 +4,7 @@ use Atproto\Contracts\HTTP\RequestContract; use Atproto\Exceptions\Http\Request\RequestBodyHasMissingRequiredFields; -use Atproto\Helpers\File; +use Atproto\Support\File; use Atproto\Resources\Com\Atproto\Repo\UploadBlobResource; use InvalidArgumentException; diff --git a/src/Collections/FacetCollection.php b/src/Collections/FacetCollection.php new file mode 100644 index 0000000..fd55269 --- /dev/null +++ b/src/Collections/FacetCollection.php @@ -0,0 +1,15 @@ + $item instanceof FacetContract, $collection); + } +} diff --git a/src/Collections/FeatureCollection.php b/src/Collections/FeatureCollection.php new file mode 100644 index 0000000..02e0569 --- /dev/null +++ b/src/Collections/FeatureCollection.php @@ -0,0 +1,14 @@ + $feature instanceof FeatureAbstract, $features); + } +} diff --git a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php b/src/Collections/Types/NonPrimitive/FollowerAssetType.php similarity index 82% rename from src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php rename to src/Collections/Types/NonPrimitive/FollowerAssetType.php index 9d0c79c..9e156e3 100644 --- a/src/GenericCollection/Types/NonPrimitive/FollowerAssetType.php +++ b/src/Collections/Types/NonPrimitive/FollowerAssetType.php @@ -1,6 +1,6 @@ path = $path; + $this->handle(); + } + + /** + * @throws InvalidArgumentException + */ + private function handle(): void + { + $this->isFile(); + $this->isReadable(); + $this->isValid(); + } + + /** + * @throws InvalidArgumentException + */ + private function isFile(): void + { + if (! is_file($this->path)) { + throw new InvalidArgumentException("$this->path is not a file."); + } + } + + /** + * @throws InvalidArgumentException + */ + private function isReadable(): void + { + if (! is_readable($this->path)) { + throw new InvalidArgumentException("$this->path is not readable."); + } + } +} \ No newline at end of file diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php new file mode 100644 index 0000000..9dd7fb6 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -0,0 +1,67 @@ +lang($lang); + $this->file($file); + } + + public function lang(string $lang = null): string + { + if (is_null($lang)) { + return $this->lang; + } + + $this->lang = $lang; + + return $this->lang; + } + + /** + * @throws InvalidArgumentException + */ + public function file(File $file = null) + { + if (is_null($file)) { + return $this->file; + } + + if ($file->size() > self::MAX_SIZE) { + throw new InvalidArgumentException($file->path().' is too large.'); + } + + if ($file->type() !== 'text/vtt') { + throw new InvalidArgumentException($file->path().' is not a text vtt.'); + } + + $this->file = $file; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return [ + 'lang' => $this->lang(), + 'file' => $this->file()->blob(), + ]; + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php new file mode 100644 index 0000000..e2c66b7 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -0,0 +1,42 @@ + $item instanceof Caption, $collection); + } + + public function validate($value): bool + { + return parent::validate($value) && $this->validateLength(); + } + + private function validateLength(): bool + { + return $this->count() <= self::MAX_SIZE; + } + + public function validateWithException($value): void + { + parent::validateWithException($value); + + if (! $this->validateLength()) { + throw new InvalidArgumentException("Caption length must be less than or equal " . self::MAX_SIZE); + } + } + + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php new file mode 100644 index 0000000..6f3b3aa --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -0,0 +1,57 @@ + $item instanceof ImageInterface, $collection); + } + + private function validateLength(): bool + { + return ($this->count() <= self::MAX_ITEM); + } + + private function validateSize(ImageInterface $image): bool + { + return $image->size() <= self::MAX_SIZE; + } + + public function validate($value): bool + { + return parent::validate($value) + && $this->validateSize($value) + && $this->validateLength(); + } + + /** + * @throws InvalidArgumentException + */ + public function validateWithException($value): void + { + parent::validateWithException($value); + + if (! $this->validateLength()) { + throw new InvalidArgumentException("Image limit exceeded. Maximum allowed images: ".self::MAX_ITEM); + } + + if (! $this->validateSize($value)) { + throw new InvalidArgumentException("Image size exceeded. Maximum allowed size: ".self::MAX_SIZE); + } + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/File.php new file mode 100644 index 0000000..61d1b93 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/File.php @@ -0,0 +1,35 @@ +path); + } + + public function type(): string + { + return mime_content_type($this->path); + } + + public function blob(): string + { + return file_get_contents($this->path); + } + + public function path(): string + { + return $this->path; + } + + public function __toString(): string + { + return $this->blob(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php new file mode 100644 index 0000000..7036263 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -0,0 +1,70 @@ +type(), 'image/')) { + throw new InvalidArgumentException($file->path()." is not a valid image file."); + } + + $this->file = $file; + $this->alt = $alt; + } + + public function alt(string $alt = null) + { + if (is_null($alt)) { + return $this->alt; + } + + $this->alt = $alt; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function aspectRatio(int $width = null, int $height = null) + { + if (is_null($width) && is_null($height)) { + return $this->aspectRatio; + } + + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException("Width and height must be at least 1"); + } + + $this->aspectRatio = [ + 'width' => $width, + 'height' => $height + ]; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return array_filter([ + 'alt' => $this->alt(), + 'image' => $this->file->blob(), + 'aspectRatio' => $this->aspectRatio() ?: null, + ]); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php new file mode 100644 index 0000000..f4de269 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -0,0 +1,85 @@ +type()) { + throw new InvalidArgumentException($file->path()." is not a valid video file."); + } + + $this->file = $file; + + $this->captions = new CaptionCollection(); + } + + /** + * @throws InvalidArgumentException + */ + public function jsonSerialize(): array + { + return array_filter([ + 'alt' => $this->alt() ?: null, + 'video' => $this->file->blob(), + 'aspectRatio' => $this->aspectRatio() ?: null, + 'captions' => $this->captions()->toArray() ?: null, + ]); + } + + public function alt(string $alt = null) + { + if (is_null($alt)) { + return $this->alt; + } + + $this->alt = $alt; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function aspectRatio(int $width = null, int $height = null) + { + if (is_null($width) && is_null($height)) { + return $this->aspectRatio; + } + + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException("Width and height must be greater than 1"); + } + + $this->aspectRatio = [ + 'width' => $width, + 'height' => $height + ]; + + return $this; + } + + public function captions(CaptionCollection $captions = null) + { + if (is_null($captions)) { + return $this->captions; + } + + $this->captions = $captions; + + return $this; + } +} diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php new file mode 100644 index 0000000..560b1af --- /dev/null +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -0,0 +1,205 @@ +facets = new FacetCollection(); + } + + /** + * @throws InvalidArgumentException + */ + public function text(...$items): PostBuilderContract + { + foreach ($items as $index => $item) { + $this->validate($item, $index); + $this->processItem($item); + } + + return $this; + } + + /** + * Adds a tag to the post. + * + * @throws InvalidArgumentException + */ + public function tag(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('tag', $reference, $label); + } + + /** + * Adds a link to the post. + * + * @throws InvalidArgumentException + */ + public function link(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('link', $reference, $label); + } + + /** + * Adds a mention to the post. + * + * @throws InvalidArgumentException + */ + public function mention(string $reference, string $label = null): PostBuilderContract + { + return $this->addFeatureItem('mention', $reference, $label); + } + + public function embed(...$embeds): PostBuilderContract + { + return $this; + } + + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract + { + $this->createdAt = $dateTime; + + return $this; + } + + public function jsonSerialize(): array + { + return [ + '$type' => self::TYPE_NAME, + 'createdAt' => $this->getFormattedCreatedAt(), + 'text' => $this->text, + 'facets' => $this->facets->toArray(), + ]; + } + + public function __toString(): string + { + return json_encode($this); + } + + private function isString($item): bool + { + return is_scalar($item); + } + + private function isFeature($item): bool + { + return $item instanceof FeatureAbstract; + } + + /** + * Validates the given item. + * + * @throws InvalidArgumentException + */ + private function validate($item, int $index = 0): void + { + if (!$this->isString($item) && !$this->isFeature($item)) { + throw new InvalidArgumentException( + sprintf( + 'Argument at index %d is invalid: must be a string or an instance of %s.', + $index + 1, + FeatureAbstract::class + ) + ); + } + + if ($this->isString($item) && mb_strlen($item) > self::TEXT_LIMIT) { + throw new InvalidArgumentException( + sprintf( + 'Text must be less than or equal to %d characters.', + self::TEXT_LIMIT + ) + ); + } + } + + /** + * Processes the item, adding it to the post text or as a feature. + * @throws InvalidArgumentException + */ + private function processItem($item): void + { + if ($this->isString($item)) { + $this->addString($item); + } else { + $this->addFeature($item); + } + } + + /** + * Adds a feature item like tag, link, or mention to the post. + * + * @throws InvalidArgumentException + */ + private function addFeatureItem(string $type, string $reference, ?string $label): PostBuilderContract + { + $feature = FeatureFactory::{$type}($reference, $label); + $this->addFeature($feature); + + return $this; + } + + /** + * Adds a string to the post text. + * + * @param string $string + */ + private function addString(string $string): void + { + $this->text .= $string; + } + + /** + * Adds a feature to the post. + * + * @throws InvalidArgumentException + */ + private function addFeature(FeatureAbstract $feature): void + { + $label = (string) $feature; + $this->text .= $label; + + try { + $facet = new Facet( + new FeatureCollection([$feature]), + new ByteSlice($this->text, $label) + ); + $this->facets[] = $facet; + } catch (\GenericCollection\Exceptions\InvalidArgumentException $e) { + throw new InvalidArgumentException( + sprintf('Feature must be an instance of %s.', FeatureAbstract::class) + ); + } + } + + /** + * Returns the formatted creation date. + */ + private function getFormattedCreatedAt(): string + { + $createdAt = $this->createdAt ?: Carbon::now(); + + return $createdAt->format(DATE_ATOM); + } +} diff --git a/src/Lexicons/App/Bsky/RichText/ByteSlice.php b/src/Lexicons/App/Bsky/RichText/ByteSlice.php new file mode 100644 index 0000000..9f5ab96 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/ByteSlice.php @@ -0,0 +1,35 @@ +text = $text; + $this->added = $added; + } + + public function start(): int + { + return mb_strpos($this->text, $this->added); + } + + public function end(): int + { + return mb_strpos($this->text, $this->added) + mb_strlen($this->added); + } + + public function jsonSerialize(): array + { + return [ + 'byteStart' => $this->start(), + 'byteEnd' => $this->end(), + ]; + } +} \ No newline at end of file diff --git a/src/Lexicons/App/Bsky/RichText/Facet.php b/src/Lexicons/App/Bsky/RichText/Facet.php new file mode 100644 index 0000000..dfbb9a4 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/Facet.php @@ -0,0 +1,47 @@ +features = $features; + $this->byteSlice = $byteSlice; + } + + public function features(): FeatureCollection + { + return $this->features; + } + + public function byteSlice(): ByteSliceContract + { + return $this->byteSlice; + } + + public function jsonSerialize(): array + { + return [ + 'index' => $this->byteSlice, + 'features' => $this->features->toArray() + ]; + } + + public function __toString(): string + { + return json_encode($this); + } +} diff --git a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php index 9ea75a0..bb50204 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureAbstract.php @@ -3,23 +3,29 @@ namespace Atproto\Lexicons\App\Bsky\RichText; use Atproto\Contracts\LexiconBuilder; -use Atproto\Contracts\Lexicons\App\Bsky\RichText\FeatureContract; +use Atproto\Contracts\Stringable; -abstract class FeatureAbstract implements LexiconBuilder +abstract class FeatureAbstract implements LexiconBuilder, Stringable { - public function jsonSerialize(): array + protected string $reference; + protected string $label; + + public function __construct(string $reference, string $label = null) { - return array_merge( - ['type' => $this->type(),], - $this->schema() - ); + if (is_null($label)) { + $label = $reference; + } + + $this->reference = $reference; + $this->label = $label; } - public function __toString(): string + final public function jsonSerialize(): array { - return json_encode($this); + return ['type' => $this->type()] + $this->schema(); } - abstract public function schema(): array; - abstract public function type(): string; + abstract protected function type(): string; + + abstract protected function schema(): array; } diff --git a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php new file mode 100644 index 0000000..01a9682 --- /dev/null +++ b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php @@ -0,0 +1,22 @@ +url = $url; + return [ + "label" => $this->label, + "uri" => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "uri" => $this->url, - ]; + return $this->label; } - public function type(): string + protected function type(): string { return "link"; } diff --git a/src/Lexicons/App/Bsky/RichText/Mention.php b/src/Lexicons/App/Bsky/RichText/Mention.php index dbbfc16..989626b 100644 --- a/src/Lexicons/App/Bsky/RichText/Mention.php +++ b/src/Lexicons/App/Bsky/RichText/Mention.php @@ -4,22 +4,23 @@ class Mention extends FeatureAbstract { - private string $did; + protected ?string $type = 'mention'; - public function __construct(string $did) + protected function schema(): array { - $this->did = $did; + return [ + 'label' => "@$this->label", + 'did' => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "did" => $this->did, - ]; + return "@$this->label"; } - public function type(): string + protected function type(): string { - return "mention"; + return 'mention'; } } diff --git a/src/Lexicons/App/Bsky/RichText/Tag.php b/src/Lexicons/App/Bsky/RichText/Tag.php index bcb2108..9f3291e 100644 --- a/src/Lexicons/App/Bsky/RichText/Tag.php +++ b/src/Lexicons/App/Bsky/RichText/Tag.php @@ -2,33 +2,23 @@ namespace Atproto\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; - class Tag extends FeatureAbstract { - private string $tag; - - /** - * @throws InvalidArgumentException - */ - public function __construct(string $tag) + protected function schema(): array { - if (mb_strlen($tag, "UTF-8") > 640) { - throw new InvalidArgumentException("Tag cannot be longer than 640 characters."); - } - - $this->tag = $tag; + return [ + "label" => "#$this->label", + "tag" => $this->reference, + ]; } - public function schema(): array + public function __toString(): string { - return [ - "tag" => $this->tag - ]; + return "#$this->label"; } - public function type(): string + protected function type(): string { - return "tag"; + return 'tag'; } } diff --git a/src/Resources/Assets/FollowersAsset.php b/src/Resources/Assets/FollowersAsset.php index b502797..637fcc3 100644 --- a/src/Resources/Assets/FollowersAsset.php +++ b/src/Resources/Assets/FollowersAsset.php @@ -3,7 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Contracts\HTTP\Resources\AssetContract; -use Atproto\GenericCollection\Types\NonPrimitive\FollowerAssetType; +use Atproto\Collections\Types\NonPrimitive\FollowerAssetType; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Resources/Assets/LabelsAsset.php b/src/Resources/Assets/LabelsAsset.php index 6c9dc19..9894d25 100644 --- a/src/Resources/Assets/LabelsAsset.php +++ b/src/Resources/Assets/LabelsAsset.php @@ -3,7 +3,7 @@ namespace Atproto\Resources\Assets; use Atproto\Contracts\HTTP\Resources\AssetContract; -use Atproto\GenericCollection\Types\NonPrimitive\LabelAssetType; +use Atproto\Collections\Types\NonPrimitive\LabelAssetType; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use GenericCollection\Interfaces\TypeInterface; diff --git a/src/Resources/BaseResource.php b/src/Resources/BaseResource.php index 0d0f346..fa1df67 100644 --- a/src/Resources/BaseResource.php +++ b/src/Resources/BaseResource.php @@ -4,7 +4,7 @@ use Atproto\Contracts\HTTP\Resources\AssetContract; use Atproto\Exceptions\Resource\BadAssetCallException; -use Atproto\Helpers\Arr; +use Atproto\Support\Arr; use Atproto\Traits\Castable; trait BaseResource diff --git a/src/Helpers/Arr.php b/src/Support/Arr.php similarity index 99% rename from src/Helpers/Arr.php rename to src/Support/Arr.php index 5bed62b..d8777ab 100644 --- a/src/Helpers/Arr.php +++ b/src/Support/Arr.php @@ -1,6 +1,6 @@ post = new Post(); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextMethodWithBasicUsage() + { + $this->post->text('Hello, world!'); + $result = json_decode($this->post, true); + $this->assertEquals('Hello, world!', $result['text']); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextMethod() + { + $this->post->text('Hello', ', ', new Mention('example:did:123', 'user'), "! It's ", 5, " o'clock now."); + $result = json_decode(json_encode($this->post), true); + $this->assertEquals("Hello, @user! It's 5 o'clock now.", $result['text']); + + $this->post->text('This ', new Mention('example:did:123', 'user'), '!'); + } + + /** + * @throws InvalidArgumentException + */ + public function testTagMethod() + { + $this->post->tag('example', 'test'); + $result = json_decode($this->post, true); + $this->assertEquals('#test', $result['text']); + $this->assertCount(1, $result['facets']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLinkMethod() + { + $this->post->link('https://example.com', 'Example'); + $result = json_decode($this->post, true); + $this->assertEquals('Example', $result['text']); + $this->assertCount(1, $result['facets']); + } + + /** + * @throws InvalidArgumentException + */ + public function testMentionMethod() + { + $this->post->mention('did:example:123', 'user'); + $result = json_decode($this->post, true); + $this->assertEquals('@user', $result['text']); + $this->assertCount(1, $result['facets']); + } + + public function testCombinationOfMethods() + { + $this->post = $this->post(); + + $result = json_decode($this->post, true); + $this->assertEquals('Hello @user! Check out this link #example_tag', $result['text']); + $this->assertCount(3, $result['facets']); + } + + public function testCoordinatesOfFacets(): void + { + $this->post = $this->post(); + + $result = json_decode($this->post, true); + + $text = $result['text']; + $facets = $result['facets']; + + $this->assertSame($facets[0]['index'], $this->bytes($text, "@user")); + $this->assertSame($facets[1]['index'], $this->bytes($text, "this link")); + $this->assertSame($facets[2]['index'], $this->bytes($text, "#example_tag")); + } + + private function bytes(string $haystack, string $needle): array + { + $pos = mb_strpos($haystack, $needle); + $len = $pos + mb_strlen($needle); + + return [ + 'byteStart' => $pos, + 'byteEnd' => $len, + ]; + } + + private function post(): Post + { + return $this->post->text('Hello ') + ->mention('did:example:123', 'user') + ->text('! Check out ') + ->link('https://example.com', 'this link') + ->text(' ') + ->tag('example_tag'); + } + + /** + * @throws InvalidArgumentException + */ + public function testTextThrowsExceptionWhenLimitIsExceeded() + { + $this->post->text(str_repeat('a', 3000)); + + $this->expectException(InvalidArgumentException::class); + + $this->post->text(str_repeat('a', 3001)); + } + + public function testTextThrowsExceptionWhenPassedInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class + ); + + $this->post->text(1, true, 'string', new Post); + } + + public function testCreatedAtField() + { + $result = json_decode($this->post, true); + $this->assertArrayHasKey('createdAt', $result); + $this->assertNotNull($result['createdAt']); + } + + public function testJsonSerialize() + { + $this->post->text('Test post'); + $result = $this->post->jsonSerialize(); + $this->assertArrayHasKey('$type', $result); + $this->assertEquals('app.bsky.feed.post', $result['$type']); + $this->assertArrayHasKey('text', $result); + $this->assertArrayHasKey('createdAt', $result); + $this->assertArrayHasKey('facets', $result); + } + + /** + * @throws InvalidArgumentException + */ + public function testCreatedAtAssignedByDefault(): void + { + $post = $this->post->text('Test post'); + $result = json_decode($post, true); + + $this->assertSame($result['createdAt'], Carbon::now()->toIso8601String()); + } + + public function testCreatedAtReturnsAssignedTime(): void + { + $timestamp = time() + 3600; // Get the current timestamp + $this->post->createdAt(new DateTimeImmutable("@$timestamp")); + + $actual = json_decode($this->post, false)->createdAt; + $expected = Carbon::now()->modify("+1 hour")->toIso8601String(); + + $this->assertSame( + $actual, + $expected, + ); + } + + public function testConstructorWorksCorrectlyOnDirectBuild(): void + { + $array = json_decode($this->post, true); + $json = json_encode($array); + + $this->assertTrue(is_array($array)); + $this->assertTrue(json_encode($array) === $json); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php index 5144cce..abdfb50 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureAbstractTest.php @@ -2,48 +2,52 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Lexicons\App\Bsky\RichText\Link; -use Atproto\Lexicons\App\Bsky\RichText\Mention; -use Atproto\Lexicons\App\Bsky\RichText\Tag; -use Faker\Factory; +use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use PHPUnit\Framework\TestCase; +use ReflectionException; use Tests\Supports\Reflection; class FeatureAbstractTest extends TestCase { use Reflection; - public function setUp(): void + /** + * @throws ReflectionException + */ + public function testConstructorAssignsReferenceAndLabel() { - $this->faker = Factory::create(); - } + $mock = $this->getMockBuilder(FeatureAbstract::class) + ->setConstructorArgs(['reference', 'label']) + ->onlyMethods(['schema']) + ->getMockForAbstractClass(); - /** @dataProvider featureProvider */ - public function testFeatureSerializationIsCorrect(string $class, string $input, array $expectedSchema): void - { - $instance = new $class($input); + $reference = $this->getPropertyValue('reference', $mock); + $label = $this->getPropertyValue('label', $mock); - $this->assertSame($this->buildExpectedJson($expectedSchema), json_encode($instance)); - $this->assertSame($this->buildExpectedJson($expectedSchema), (string)$instance); + $this->assertSame('reference', $reference); + $this->assertSame('label', $label); } - public function featureProvider(): array + public function testJsonSerializeReturnsCorrectArray() { - $faker = Factory::create(); + $mock = $this->getMockBuilder(FeatureAbstract::class) + ->setConstructorArgs(['reference', 'label']) + ->onlyMethods(['schema', 'type']) + ->getMockForAbstractClass(); - $url = $faker->url; - $did = $faker->uuid; - $tag = $faker->word; + $schema = ['key' => 'value']; - return [ - [Link::class, $url, ['type' => 'link', 'uri' => $url]], - [Mention::class, $did, ['type' => 'mention', 'did' => $did]], - [Tag::class, $tag, ['type' => 'tag', 'tag' => $tag]], - ]; - } + $mock->expects($this->once()) + ->method('schema') + ->willReturn($schema); - private function buildExpectedJson(array $schema): string - { - return json_encode($schema); + $mock->expects($this->once()) + ->method('type') + ->willReturn('feature'); + + $this->assertSame( + ['type' => 'feature'] + $schema, + $mock->jsonSerialize() + ); } } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php new file mode 100644 index 0000000..b6237d5 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/RichText/FeatureTests.php @@ -0,0 +1,110 @@ +label = 'label'; + $this->reference = 'reference'; + } + + /** + * @throws ReflectionException + */ + public function testTypeReturnsCorrectType(): void + { + $feature = $this->feature($this->reference); + $method = $this->method('type', $feature); + $expected = $this->type; + + $this->assertSame( + $expected, + $method->invoke($feature) + ); + } + + public function test__toStringReturnsCorrectLabelWhenPassedBothParameters(): void + { + $feature = $this->feature($this->reference, $this->label); + $this->assert__toString($this->label, $feature); + } + + public function test__toStringReturnsCorrectLabelWhenPassedSingleParameter(): void + { + $feature = $this->feature($this->reference); + + $this->assert__toString($this->reference, $feature); + } + + private function assert__toString(string $expected, FeatureAbstract $feature): void + { + $this->assertEquals( + $this->prefix . $expected, + $feature + ); + } + + public function testSchemaReturnsCorrectSchemaWhenPassedBothParameters(): void + { + $feature = $this->feature($this->reference, $this->label); + + $expected = [ + 'type' => $this->type, + 'label' => $this->prefix . $this->label, + $this->key => $this->reference, + ]; + + $this->assertSchema($expected, $feature); + } + + public function testSchemaReturnsCorrectSchemaWhenPassedSingleParameter(): void + { + $expected = $this->schema($this->reference); + + $this->assertSchema($expected, $this->feature($this->reference)); + } + + private function schema(string $label): array + { + return [ + 'type' => $this->type, + 'label' => $this->prefix . $label, + $this->key => $this->reference, + ]; + } + + private function assertSchema(array $expected, FeatureAbstract $feature): void + { + $this->assertSame( + $expected, + json_decode(json_encode($feature), true) + ); + + $this->assertSame( + $expected, + $feature->jsonSerialize() + ); + } + + private function feature(string $reference, string $label = null): FeatureAbstract + { + $namespace = $this->namespace; + + if (! is_null($label)) { + return new $namespace($reference, $label); + } + + return new $namespace($reference); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php index 9ae978f..1730e65 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/LinkTest.php @@ -2,50 +2,14 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\Link; -use Faker\Factory; -use Faker\Generator; -use ReflectionException; -use Tests\Supports\Reflection; class LinkTest extends FeatureAbstractTest { - use Reflection; + use FeatureTests; - private Generator $faker; - - public function setUp(): void - { - $this->faker = Factory::create(); - } - - public function testConstructorThrowsExceptionWhenPassedInvalidDataType() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid URI: 123"); - - new Link(123); - } - - public function testLinkThrowsExceptionWhenPassedInvalidURL() - { - $invalidURL = $this->faker->word; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid URI: $invalidURL"); - - new Link($invalidURL); - } - - /** - * @throws InvalidArgumentException|ReflectionException - */ - public function testLinkConstructorWorksCorrectly() - { - $url = $this->faker->url; - $link = new Link($url); - - $this->assertSame($url, $this->getPropertyValue('url', $link)); - } + private string $namespace = Link::class; + private string $type = 'link'; + private string $key = 'uri'; + private string $prefix = ''; } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php index b07447d..ba3bd46 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/MentionTest.php @@ -3,18 +3,13 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; use Atproto\Lexicons\App\Bsky\RichText\Mention; -use ReflectionException; class MentionTest extends FeatureAbstractTest { - /** - * @throws ReflectionException - */ - public function testConstructorWorksCorrectly(): void - { - $expected = $this->faker->word; - $mention = new Mention($expected); + use FeatureTests; - $this->assertSame($expected, $this->getPropertyValue('did', $mention)); - } + private string $namespace = Mention::class; + private string $type = 'mention'; + private string $key = 'did'; + private string $prefix = '@'; } diff --git a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php index e0507de..4996fad 100644 --- a/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php +++ b/tests/Unit/Lexicons/App/Bsky/RichText/TagTest.php @@ -2,21 +2,14 @@ namespace Tests\Unit\Lexicons\App\Bsky\RichText; -use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\Tag; -use ReflectionException; class TagTest extends FeatureAbstractTest { - /** - * @throws ReflectionException - * @throws InvalidArgumentException - */ - public function testConstructorWorksCorrectly(): void - { - $expected = $this->faker->word; - $tag = new Tag($expected); + use FeatureTests; - $this->assertSame($expected, $this->getPropertyValue('tag', $tag)); - } + private string $namespace = Tag::class; + private string $type = 'tag'; + private string $key = 'tag'; + private string $prefix = '#'; } From 4c3a7c7f77a173c888430e6406a517ebfb0e9491 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 10 Oct 2024 01:43:43 +0400 Subject: [PATCH 11/27] WIP: Implement components of post builder, improve validation, and add tests - Added `Stringable` interface to `File`, `Caption`, and `Image` for improved string handling. - Updated `BlobHandler` to remove unnecessary `isValid` check, simplifying file handling logic. - Enhanced `Caption` class with stricter validation for file size and type, using `InvalidArgumentException`. - Improved constructor and method handling in `Caption`, ensuring proper setting of file and language. - Created comprehensive unit tests for `Caption`, covering file validation, language setting, and JSON serialization. - Introduced `FileMocking` trait for reusable mock file generation in tests. --- .../App/Bsky/Embed/ImageInterface.php | 3 +- src/Lexicons/App/Bsky/Embed/BlobHandler.php | 3 +- src/Lexicons/App/Bsky/Embed/Caption.php | 16 +- src/Lexicons/App/Bsky/Embed/File.php | 2 +- src/Lexicons/App/Bsky/Embed/Image.php | 7 +- src/Support/File.php | 2 +- tests/Lexicons/App/Bsky/Embed/CaptionTest.php | 110 ++++++++++++++ tests/Lexicons/App/Bsky/Embed/FileMocking.php | 42 +++++ tests/Lexicons/App/Bsky/Embed/FileTest.php | 98 ++++++++++++ tests/Lexicons/App/Bsky/Embed/ImageTest.php | 143 ++++++++++++++++++ 10 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 tests/Lexicons/App/Bsky/Embed/CaptionTest.php create mode 100644 tests/Lexicons/App/Bsky/Embed/FileMocking.php create mode 100644 tests/Lexicons/App/Bsky/Embed/FileTest.php create mode 100644 tests/Lexicons/App/Bsky/Embed/ImageTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php index d4007ee..73d0a22 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php @@ -2,9 +2,10 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use JsonSerializable; -interface ImageInterface extends JsonSerializable +interface ImageInterface extends JsonSerializable, Stringable { public function jsonSerialize(): array; public function alt(string $alt = null); diff --git a/src/Lexicons/App/Bsky/Embed/BlobHandler.php b/src/Lexicons/App/Bsky/Embed/BlobHandler.php index 060947d..0c8e243 100644 --- a/src/Lexicons/App/Bsky/Embed/BlobHandler.php +++ b/src/Lexicons/App/Bsky/Embed/BlobHandler.php @@ -24,7 +24,6 @@ private function handle(): void { $this->isFile(); $this->isReadable(); - $this->isValid(); } /** @@ -46,4 +45,4 @@ private function isReadable(): void throw new InvalidArgumentException("$this->path is not readable."); } } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index 9dd7fb6..4f205aa 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -2,10 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use JsonSerializable; -class Caption implements JsonSerializable +class Caption implements JsonSerializable, Stringable { private const MAX_SIZE = 20000; @@ -21,7 +22,7 @@ public function __construct(string $lang, File $file) $this->file($file); } - public function lang(string $lang = null): string + public function lang(string $lang = null) { if (is_null($lang)) { return $this->lang; @@ -29,7 +30,7 @@ public function lang(string $lang = null): string $this->lang = $lang; - return $this->lang; + return $this; } /** @@ -42,11 +43,11 @@ public function file(File $file = null) } if ($file->size() > self::MAX_SIZE) { - throw new InvalidArgumentException($file->path().' is too large.'); + throw new InvalidArgumentException($file->path().' is too large. Max size: '.self::MAX_SIZE); } if ($file->type() !== 'text/vtt') { - throw new InvalidArgumentException($file->path().' is not a text vtt.'); + throw new InvalidArgumentException($file->path().' is not a text/vtt file.'); } $this->file = $file; @@ -64,4 +65,9 @@ public function jsonSerialize(): array 'file' => $this->file()->blob(), ]; } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/File.php index 61d1b93..6c4baf0 100644 --- a/src/Lexicons/App/Bsky/Embed/File.php +++ b/src/Lexicons/App/Bsky/Embed/File.php @@ -4,7 +4,7 @@ use Atproto\Contracts\Stringable; -class File +class File implements Stringable { use BlobHandler; diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 7036263..80d9d95 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -45,7 +45,7 @@ public function aspectRatio(int $width = null, int $height = null) } if ($width < 1 || $height < 1) { - throw new InvalidArgumentException("Width and height must be at least 1"); + throw new InvalidArgumentException("'\$width' and '\$height' must be greater than 0"); } $this->aspectRatio = [ @@ -67,4 +67,9 @@ public function jsonSerialize(): array 'aspectRatio' => $this->aspectRatio() ?: null, ]); } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Support/File.php b/src/Support/File.php index ebe0415..641b6d7 100644 --- a/src/Support/File.php +++ b/src/Support/File.php @@ -10,7 +10,7 @@ class File { /** @var string $file_path The path to the file. */ - private $file_path; + private string $file_path; /** * Constructor. diff --git a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php b/tests/Lexicons/App/Bsky/Embed/CaptionTest.php new file mode 100644 index 0000000..54c83ac --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/CaptionTest.php @@ -0,0 +1,110 @@ + 'lang']; + + /** + * @throws InvalidArgumentException + */ + protected function setUp(): void + { + $file = $this->createMockFile(); + + $this->dependencies['file'] = $file; + + $this->caption = $this->createCaption(); + } + + /** + * @return Caption + * @throws InvalidArgumentException + */ + private function createCaption(): Caption + { + return new Caption(...array_values($this->dependencies)); + } + + /** + * @throws InvalidArgumentException + */ + public function testFile() + { + $actual = $this->caption->file(); + $this->assertSame($this->dependencies['file'], $actual); + } + + /** + * @throws InvalidArgumentException + */ + public function testSetFile(): void + { + $expected = clone $this->dependencies['file']; + + $this->assertFalse(spl_object_hash($expected) === spl_object_hash($this->caption->file())); + + $this->caption->file($expected); + $actual = $this->caption->file(); + + $this->assertSame($expected, $actual); + } + + public function testLang() + { + $actual = $this->caption->lang(); + $expected = $this->dependencies['lang']; + + $this->assertSame($expected, $actual); + } + + public function testSetLang(): void + { + $expected = "language"; + $this->caption->lang($expected); + $actual = $this->caption->lang(); + + $this->assertSame($expected, $actual); + } + + public function testJsonSerialize() + { + $expected = [ + 'file' => $this->blob, + 'lang' => $this->dependencies['lang'], + ]; + + $actual = json_decode($this->caption, true); + + $this->assertFalse(is_bool($actual)); + $this->assertEquals($expected, $actual); + } + + public function test__constructThrowsInvalidArgumentWhenPassedInvalidFileType(): void + { + $this->type = 'image/png'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($this->dependencies['file']->path()." is not a text/vtt file."); + + $this->createCaption(); + } + + public function test__constructorThrowsExceptionWhePassedUnacceptableSizedFile(): void + { + $this->size = 20001; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($this->dependencies['file']->path()." is too large. Max size: 20000"); + + $this->createCaption(); + } +} diff --git a/tests/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Lexicons/App/Bsky/Embed/FileMocking.php new file mode 100644 index 0000000..7a08d6f --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/FileMocking.php @@ -0,0 +1,42 @@ +getMockBuilder(File::class) + ->disableOriginalConstructor() + ->getMock(); + + $file->expects($this->any()) + ->method('path') + ->will($this->returnCallback(fn() => $this->path)); + + $file->expects($this->any()) + ->method('type') + ->will($this->returnCallback(fn() => $this->type)); + + $file->expects($this->any()) + ->method('size') + ->will($this->returnCallback(fn() => $this->size)); + + $file->expects($this->any()) + ->method('blob') + ->will($this->returnCallback(fn() => $this->blob)); + + return $file; + } +} \ No newline at end of file diff --git a/tests/Lexicons/App/Bsky/Embed/FileTest.php b/tests/Lexicons/App/Bsky/Embed/FileTest.php new file mode 100644 index 0000000..c8960ba --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/FileTest.php @@ -0,0 +1,98 @@ +testFilePath = tempnam(sys_get_temp_dir(), 'testfile'); + file_put_contents($this->testFilePath, 'This is a test file.'); + $this->fileInstance = new File($this->testFilePath); + + $this->unreadableFilePath = tempnam(sys_get_temp_dir(), 'unreadable'); + file_put_contents($this->unreadableFilePath, 'This is an unreadable file.'); + chmod($this->unreadableFilePath, 0000); + + $this->nonFilePath = sys_get_temp_dir() . '/nonFile' . uniqid(); + mkdir($this->nonFilePath, 0777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if (file_exists($this->testFilePath)) { + unlink($this->testFilePath); + } + + if (file_exists($this->unreadableFilePath)) { + chmod($this->unreadableFilePath, 0644); + unlink($this->unreadableFilePath); + } + + if (is_dir($this->nonFilePath)) { + rmdir($this->nonFilePath); + } + } + + public function testFileSize(): void + { + $expectedSize = filesize($this->testFilePath); + $this->assertEquals($expectedSize, $this->fileInstance->size()); + } + + public function testMimeType(): void + { + $expectedType = mime_content_type($this->testFilePath); + $this->assertEquals($expectedType, $this->fileInstance->type()); + } + + public function testFileBlob(): void + { + $expectedContent = file_get_contents($this->testFilePath); + $this->assertEquals($expectedContent, $this->fileInstance->blob()); + } + + public function testToStringMethod(): void + { + $expectedContent = file_get_contents($this->testFilePath); + $this->assertEquals($expectedContent, (string) $this->fileInstance); + } + + public function testConstructorThrowsExceptionWhenPassedUnreadableFilePath(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('Test skipped because it is running as root.'); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("$this->unreadableFilePath is not readable."); + + $this->assertFalse(is_readable($this->unreadableFilePath), 'File should not be readable.'); + + new File($this->unreadableFilePath); + } + + public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("$this->nonFilePath is not a file."); + + new File($this->nonFilePath); + } +} diff --git a/tests/Lexicons/App/Bsky/Embed/ImageTest.php b/tests/Lexicons/App/Bsky/Embed/ImageTest.php new file mode 100644 index 0000000..87be7d4 --- /dev/null +++ b/tests/Lexicons/App/Bsky/Embed/ImageTest.php @@ -0,0 +1,143 @@ + 'alt']; + + /** + * @return array + */ + public function randAspectRatio(): array + { + return ['width' => rand(1, 50), 'height' => rand(1, 50)]; + } + + /** + * @return Image + * @throws InvalidArgumentException + */ + public function createImage(): Image + { + return new Image(...array_values($this->dependencies)); + } + + /** + * @throws InvalidArgumentException + */ + protected function setUp(): void + { + $this->type = 'image/png'; + + $this->dependencies = array_merge([ + 'file' => $this->createMockFile() + ], $this->dependencies); + + $this->image = $this->createImage(); + } + + /** + * @throws ReflectionException + */ + public function test__construct() + { + $this->assertSame($this->dependencies['file'], $this->getPropertyValue('file', $this->image)); + $this->assertSame($this->dependencies['alt'], $this->image->alt()); + } + + /** + * @throws InvalidArgumentException + */ + public function testAspectRatio() + { + $this->assertNull($this->image->aspectRatio()); + + $expected = $this->randAspectRatio(); + + $this->image->aspectRatio(...array_values($expected)); + + $this->assertSame($expected, $this->image->aspectRatio()); + } + + /** @dataProvider aspectRatioInvalidArguments */ + public function testAspectRatioThrowsInvalidArgumentException(...$arguments): void + { + $this->expectException(InvalidArgumentException::class); + + $this->image->aspectRatio(...$arguments); + } + + /** + * @return array + */ + public function aspectRatioInvalidArguments(): array + { + return [ + [0, 12], + [null, 0], + [12, null], + [0, 0], + [0, 54], + [76, 0], + [1] + ]; + } + + public function testAlt() + { + $expected = $this->dependencies['alt']; + + $this->assertSame($expected, $this->image->alt()); + + $new = uniqid(); + $this->image->alt($new); + + $this->assertSame($new, $this->image->alt()); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $expected = [ + 'alt' => $this->dependencies['alt'], + 'image' => $this->dependencies['file']->blob(), + ]; + + $image = $this->createImage(); + + $this->assertSame($expected, json_decode($image, true)); + + $aspectRatio = $this->randAspectRatio(); + + $expected['aspectRatio'] = $aspectRatio; + + $image->aspectRatio(...array_values($aspectRatio)); + + $this->assertSame($expected, json_decode($image, true)); + } + + /** + * @throws InvalidArgumentException + */ + public function testMethodChaining() + { + $result = $this->image + ->alt('new alt') + ->aspectRatio(16, 9); + + $this->assertSame($this->image, $result); + } +} From 7c50c29f4bc5708a6878e557aa066fd50e77dcb9 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Thu, 10 Oct 2024 23:26:10 +0400 Subject: [PATCH 12/27] WIP: PostBuilder Implementation. Add video test and move tests to correct directory - Add new test for the Video class related to embeds - Update namespaces of lexicon unit test classes related to embeds to fix being under the wrong namespace --- src/Lexicons/App/Bsky/Embed/Video.php | 13 +- .../Lexicons/App/Bsky/Embed/CaptionTest.php | 2 +- .../Lexicons/App/Bsky/Embed/FileMocking.php | 2 +- .../Lexicons/App/Bsky/Embed/FileTest.php | 2 +- .../Lexicons/App/Bsky/Embed/ImageTest.php | 2 +- .../Lexicons/App/Bsky/Embed/VideoTest.php | 123 ++++++++++++++++++ 6 files changed, 138 insertions(+), 6 deletions(-) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/CaptionTest.php (98%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/FileMocking.php (95%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/FileTest.php (98%) rename tests/{ => Unit}/Lexicons/App/Bsky/Embed/ImageTest.php (98%) create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index f4de269..9d3b905 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -3,10 +3,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements VideoInterface +class Video implements VideoInterface, Stringable { private File $file; private ?string $alt = null; @@ -32,12 +33,14 @@ public function __construct(File $file) */ public function jsonSerialize(): array { - return array_filter([ + $result = array_filter([ 'alt' => $this->alt() ?: null, 'video' => $this->file->blob(), 'aspectRatio' => $this->aspectRatio() ?: null, 'captions' => $this->captions()->toArray() ?: null, ]); + + return $result; } public function alt(string $alt = null) @@ -82,4 +85,10 @@ public function captions(CaptionCollection $captions = null) return $this; } + + public function __toString(): string + { + $result = json_encode($this); + return $result; + } } diff --git a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php similarity index 98% rename from tests/Lexicons/App/Bsky/Embed/CaptionTest.php rename to tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php index 54c83ac..303adf9 100644 --- a/tests/Lexicons/App/Bsky/Embed/CaptionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/CaptionTest.php @@ -1,6 +1,6 @@ type = 'video/mp4'; + + $this->file = $this->createMockFile(); + $this->video = new Video($this->file); + } + + public function testAlt(): void + { + $this->assertSame(null, $this->video->alt()); + + $expected = 'alt text'; + $this->video->alt($expected); + + $this->assertSame($expected, $this->video->alt()); + } + + /** @dataProvider invalidAspectRatioProvider */ + public function testAspectRatioThrowsInvalidArgumentExceptionWhenPassedInvalidArguments(...$args): void + { + $this->expectException(InvalidArgumentException::class); + + $this->video->aspectRatio(...array_values($args)); + } + + public function invalidAspectRatioProvider(): array + { + return [ + [-1, 0], + [0, -1], + [false, 1], + [true], + [1] + ]; + } + + public function testAspectRatio(): void + { + $this->assertSame([], $this->video->aspectRatio()); + + $expected = ['width' => 1, 'height' => 2]; + $this->video->aspectRatio(...array_values($expected)); + + $this->assertSame($expected, $this->video->aspectRatio()); + } + + public function testJsonSerializeReturnsCorrectSchema(): void + { + $this->assertSame($this->file->blob(), $this->blob); + + $expected = [ + 'video' => $this->file->blob(), + ]; + + $target = new Video($this->file); + + $this->assertSame($expected, json_decode($target, true)); + + $captions = $this->createCaptionsMock(); + $aspectRatio = $this->randAspectRatio(); + + $expected = [ + 'alt' => 'alt text', + 'video' => $expected['video'], + 'aspectRatio' => $aspectRatio, + 'captions' => $captions->toArray(), + ]; + + $target->captions($captions) + ->alt($expected['alt']) + ->aspectRatio(...array_values($expected['aspectRatio'])); + + $this->assertSame($expected, json_decode($target, true)); + } + + /** + * @return array + */ + private function randAspectRatio(): array + { + return ['width' => rand(1, 50), 'height' => rand(1, 50)]; + } + + /** + * @return (MockObject&CaptionCollection) + */ + private function createCaptionsMock(): CaptionCollection + { + $captions = $this->getMockBuilder(CaptionCollection::class) + ->disableOriginalConstructor() + ->onlyMethods(['toArray']) + ->getMock(); + + $captions->expects($this->any()) + ->method('toArray') + ->willReturn([ + ['lang' => 'lang', 'file' => $this->blob] + ]); + + return $captions; + } +} From 35e9abff4b9d12e3297b771e9ae8683d60a8175c Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Fri, 11 Oct 2024 00:07:52 +0400 Subject: [PATCH 13/27] Implement CaptionInterface for proper JSON serialization - Add CaptionInterface extending JsonSerializable and Stringable - Update Caption class to implement CaptionInterface - Modify CaptionCollection to use CaptionInterface - Adjust VideoTest to work with new interface structure Why: This change ensures proper serialization of Caption objects when json_encode is applied. By extending JsonSerializable and Stringable interfaces, CaptionInterface guarantees that Caption objects are converted to arrays during JSON serialization. --- .../App/Bsky/Embed/CaptionInterface.php | 13 +++++++++++++ src/Lexicons/App/Bsky/Embed/Caption.php | 4 ++-- .../Embed/Collections/CaptionCollection.php | 4 ++-- .../Unit/Lexicons/App/Bsky/Embed/VideoTest.php | 17 ++++++++++++++--- 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php new file mode 100644 index 0000000..fcd6144 --- /dev/null +++ b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php @@ -0,0 +1,13 @@ + $item instanceof Caption, $collection); + parent::__construct(fn ($item) => $item instanceof CaptionInterface, $collection); } public function validate($value): bool diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index 528f032..94a0306 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\App\Bsky\Embed\Caption; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; use Atproto\Lexicons\App\Bsky\Embed\File; use Atproto\Lexicons\App\Bsky\Embed\Video; @@ -107,6 +109,17 @@ private function randAspectRatio(): array */ private function createCaptionsMock(): CaptionCollection { + $caption = $this->getMockBuilder(CaptionInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $caption->expects($this->any()) + ->method('jsonSerialize') + ->willReturn([ + 'lang' => 'lang', + 'file' => $this->file->blob(), + ]); + $captions = $this->getMockBuilder(CaptionCollection::class) ->disableOriginalConstructor() ->onlyMethods(['toArray']) @@ -114,9 +127,7 @@ private function createCaptionsMock(): CaptionCollection $captions->expects($this->any()) ->method('toArray') - ->willReturn([ - ['lang' => 'lang', 'file' => $this->blob] - ]); + ->willReturn($caption->jsonSerialize()); return $captions; } From 641b63251694ebb04d79656109eaa5e7a74d3600 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 02:28:16 +0400 Subject: [PATCH 14/27] Add size() method to Image class and interface --- src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php | 1 + src/Lexicons/App/Bsky/Embed/Image.php | 5 +++++ tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php index 73d0a22..2935cfc 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/ImageInterface.php @@ -10,4 +10,5 @@ interface ImageInterface extends JsonSerializable, Stringable public function jsonSerialize(): array; public function alt(string $alt = null); public function aspectRatio(); + public function size(): int; } diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 80d9d95..0846f1d 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -68,6 +68,11 @@ public function jsonSerialize(): array ]); } + public function size(): int + { + return $this->file->size(); + } + public function __toString(): string { return json_encode($this); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php index 2b8e9a5..6f2a78c 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ImageTest.php @@ -140,4 +140,9 @@ public function testMethodChaining() $this->assertSame($this->image, $result); } + + public function testSize(): void + { + $this->assertSame($this->size, $this->image->size()); + } } From 14076f65504b66d2db0e4143c878976ab23fb626 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 02:31:27 +0400 Subject: [PATCH 15/27] Refactor collections by introducing `EmbedCollection` trait for code reuse. - Updated `CaptionCollection` and `ImageCollection` to use `EmbedCollection` for handling validation logic and ensuring consistent error handling. - Simplified the validation process by using closures in the `validator` method to ensure objects implement their respective interfaces. - Introduced custom exception handling for type errors to provide more meaningful feedback. - Ensured that collections adhere to size limits, with clear error messages for exceeding maximum size or incorrect object types. --- .../Embed/Collections/CaptionCollection.php | 35 +++------ .../Embed/Collections/EmbedCollection.php | 45 +++++++++++ .../Embed/Collections/ImageCollection.php | 52 ++++--------- .../Collections/CaptionCollectionTest.php | 16 ++++ .../Embed/Collections/EmbedCollectionTest.php | 78 +++++++++++++++++++ .../Embed/Collections/ImageCollectionTest.php | 17 ++++ 6 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/EmbedCollectionTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php index e8b5f34..6c22ad9 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/CaptionCollection.php @@ -3,40 +3,23 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\CaptionInterface; -use GenericCollection\Exceptions\InvalidArgumentException; +use Atproto\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; use JsonSerializable; class CaptionCollection extends GenericCollection implements JsonSerializable { + use EmbedCollection; private const MAX_SIZE = 20; - public function __construct(iterable $collection = []) + protected function validator(): \Closure { - parent::__construct(fn ($item) => $item instanceof CaptionInterface, $collection); - } - - public function validate($value): bool - { - return parent::validate($value) && $this->validateLength(); - } - - private function validateLength(): bool - { - return $this->count() <= self::MAX_SIZE; - } - - public function validateWithException($value): void - { - parent::validateWithException($value); + return function (CaptionInterface $caption) { + if ($this->count() > self::MAX_SIZE) { + throw new InvalidArgumentException(self::class.' collection exceeds maximum size: ' .self::MAX_SIZE); + } - if (! $this->validateLength()) { - throw new InvalidArgumentException("Caption length must be less than or equal " . self::MAX_SIZE); - } - } - - public function jsonSerialize() - { - return $this->toArray(); + return true; + }; } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php new file mode 100644 index 0000000..8eb17d7 --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -0,0 +1,45 @@ +collection = $collection; + + try { + parent::__construct($this->validator(), $collection); + } catch (\TypeError|\GenericCollection\Exceptions\InvalidArgumentException $e) { + $this->throw($e); + } + } + + /** + * @throws InvalidArgumentException + */ + private function throw($exception): void + { + throw new InvalidArgumentException( + str_replace( + "::".__NAMESPACE__."\{closure}()", + "", + $exception->getMessage() + ), + 0, + $exception + ); + } + + abstract protected function validator(): \Closure; + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 6f3b3aa..9996bc6 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -9,49 +9,23 @@ class ImageCollection extends GenericCollection implements JsonSerializable { - private const MAX_ITEM = 4; - private const MAX_SIZE = 1000000; - - public function __construct(iterable $collection = []) - { - parent::__construct(fn ($item) => $item instanceof ImageInterface, $collection); - } - - private function validateLength(): bool - { - return ($this->count() <= self::MAX_ITEM); - } - - private function validateSize(ImageInterface $image): bool - { - return $image->size() <= self::MAX_SIZE; - } + use EmbedCollection; - public function validate($value): bool - { - return parent::validate($value) - && $this->validateSize($value) - && $this->validateLength(); - } + private const MAX_LENGTH = 4; + private const MAX_SIZE = 1000000; - /** - * @throws InvalidArgumentException - */ - public function validateWithException($value): void + protected function validator(): \Closure { - parent::validateWithException($value); - - if (! $this->validateLength()) { - throw new InvalidArgumentException("Image limit exceeded. Maximum allowed images: ".self::MAX_ITEM); - } + return function (ImageInterface $image) { + if ($this->count() > self::MAX_LENGTH) { + throw new InvalidArgumentException(self::class.' collection exceeds maximum size: ' .self::MAX_LENGTH); + } - if (! $this->validateSize($value)) { - throw new InvalidArgumentException("Image size exceeded. Maximum allowed size: ".self::MAX_SIZE); - } - } + if ($image->size() > self::MAX_SIZE) { + throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_LENGTH); + } - public function jsonSerialize(): array - { - return $this->toArray(); + return true; + }; } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php new file mode 100644 index 0000000..fbd248f --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/CaptionCollectionTest.php @@ -0,0 +1,16 @@ +getMockBuilder($this->dependency) + ->disableOriginalConstructor() + ->getMock(); + + $item->expects($this->any()) + ->method('jsonSerialize') + ->willReturn(['foo' => 'bar']); + + $items[] = $item; + } + + return $items; + } + + /** + * @throws InvalidArgumentException + */ + public function test__constructThatFillsCorrectlyCollection(): void + { + $expected = $this->items(); + $collection = new $this->target($expected); + + $this->assertSame($expected, $collection->toArray()); + } + + /** + * @throws InvalidArgumentException + */ + public function testValidateThrowsExceptionWhenPassedInvalidArgument(): void + { + $given = $this->createMock(Video::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + "must implement interface %s, instance of %s given", + $this->dependency, + get_class($given) + )); + + new $this->target([$given]); + } + + public function testValidateThrowsExceptionWhenLimitExceed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("collection exceeds maximum size"); + + new $this->target($this->items(++$this->maxLength)); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerialize() + { + $items = new $this->target($this->items(2)); + + $expected = json_encode(array_map(fn () => ['foo' => 'bar'], $items->toArray())); + $actual = json_encode($items); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php new file mode 100644 index 0000000..efb6411 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php @@ -0,0 +1,17 @@ + Date: Sat, 12 Oct 2024 02:39:10 +0400 Subject: [PATCH 16/27] Fix typo in exception message for class and add unit test for size validation. --- .../Bsky/Embed/Collections/ImageCollection.php | 2 +- .../Embed/Collections/ImageCollectionTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 9996bc6..5ddea2f 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -22,7 +22,7 @@ protected function validator(): \Closure } if ($image->size() > self::MAX_SIZE) { - throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_LENGTH); + throw new InvalidArgumentException(self::class.' collection only accepts images with size less than '. self::MAX_SIZE); } return true; diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php index efb6411..548e198 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/Collections/ImageCollectionTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed\Collections; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; +use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\ImageCollection; use PHPUnit\Framework\TestCase; @@ -14,4 +15,20 @@ class ImageCollectionTest extends TestCase private string $dependency = ImageInterface::class; private int $maxLength = 4; private int $maxSizeOfItem = 1000000; + + public function testValidateThrowsExceptionWherePassedThatSizeGreaterThanLimit(): void + { + $dependency = $this->getMockBuilder($this->dependency) + ->disableOriginalConstructor() + ->getMock(); + + $dependency->expects($this->once()) + ->method('size') + ->willReturn(++$this->maxSizeOfItem); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("collection only accepts images with size less than"); + + new ImageCollection([$dependency]); + } } From af507a8dc52cbe42315f803d7bcf1beb5cd31af8 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 20:59:49 +0400 Subject: [PATCH 17/27] Add External embed class and corresponding test file - Create src/Lexicons/App/Bsky/Embed/External.php for External embed implementation - Add tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php for unit tests On branch embeds Changes to be committed: renamed: src/Lexicons/App/Bsky/Embed/File.php -> src/Lexicons/App/Bsky/Embed/Blob.php --- .../App/Bsky/Embed/CaptionInterface.php | 4 +- .../App/Bsky/Embed/{File.php => Blob.php} | 2 +- src/Lexicons/App/Bsky/Embed/Caption.php | 6 +- src/Lexicons/App/Bsky/Embed/External.php | 90 +++++++++++++++ src/Lexicons/App/Bsky/Embed/Image.php | 4 +- src/Lexicons/App/Bsky/Embed/Video.php | 4 +- .../Lexicons/App/Bsky/Embed/ExternalTest.php | 104 ++++++++++++++++++ .../Lexicons/App/Bsky/Embed/FileMocking.php | 6 +- .../Unit/Lexicons/App/Bsky/Embed/FileTest.php | 10 +- .../Lexicons/App/Bsky/Embed/VideoTest.php | 4 +- 10 files changed, 214 insertions(+), 20 deletions(-) rename src/Lexicons/App/Bsky/Embed/{File.php => Blob.php} (94%) create mode 100644 src/Lexicons/App/Bsky/Embed/External.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php index fcd6144..ed45198 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/CaptionInterface.php @@ -3,11 +3,11 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; use Atproto\Contracts\Stringable; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use JsonSerializable; interface CaptionInterface extends JsonSerializable, Stringable { public function lang(string $lang = null); - public function file(File $file = null); + public function file(Blob $file = null); } diff --git a/src/Lexicons/App/Bsky/Embed/File.php b/src/Lexicons/App/Bsky/Embed/Blob.php similarity index 94% rename from src/Lexicons/App/Bsky/Embed/File.php rename to src/Lexicons/App/Bsky/Embed/Blob.php index 6c4baf0..d6c4421 100644 --- a/src/Lexicons/App/Bsky/Embed/File.php +++ b/src/Lexicons/App/Bsky/Embed/Blob.php @@ -4,7 +4,7 @@ use Atproto\Contracts\Stringable; -class File implements Stringable +class Blob implements Stringable { use BlobHandler; diff --git a/src/Lexicons/App/Bsky/Embed/Caption.php b/src/Lexicons/App/Bsky/Embed/Caption.php index c7c548b..816ab91 100644 --- a/src/Lexicons/App/Bsky/Embed/Caption.php +++ b/src/Lexicons/App/Bsky/Embed/Caption.php @@ -11,12 +11,12 @@ class Caption implements CaptionInterface private const MAX_SIZE = 20000; private string $lang; - private File $file; + private Blob $file; /** * @throws InvalidArgumentException */ - public function __construct(string $lang, File $file) + public function __construct(string $lang, Blob $file) { $this->lang($lang); $this->file($file); @@ -36,7 +36,7 @@ public function lang(string $lang = null) /** * @throws InvalidArgumentException */ - public function file(File $file = null) + public function file(Blob $file = null) { if (is_null($file)) { return $this->file; diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php new file mode 100644 index 0000000..20e37be --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -0,0 +1,90 @@ +uri($uri) + ->title($title) + ->description($description); + } + + /** + * @throws InvalidArgumentException + */ + public function uri(string $uri = null) + { + if (is_null($uri)) { + return $this->uri; + } + + if (! filter_var($uri, FILTER_VALIDATE_URL)) { + throw new InvalidArgumentException("'$uri' is not a valid URL"); + } + + $this->uri = $uri; + + return $this; + } + + public function title(string $title = null) + { + if (is_null($title)) { + return $this->title; + } + + $this->title = $title; + + return $this; + } + + public function description(string $description = null) + { + if (is_null($description)) { + return $this->description; + } + + $this->description = $description; + + return $this; + } + + /** + * @throws InvalidArgumentException + */ + public function thumb(Blob $blob = null) + { + if (is_null($blob)) { + return $this->blob; + } + + if (! str_starts_with($blob->type(), 'image/*')) { + throw new InvalidArgumentException(sprintf( + "'%s' is not a valid image type: %s", + $blob->path(), + $blob->type() + )); + } + + if (1000000 < $blob->size()) { + throw new InvalidArgumentException(sprintf( + "'%s' size is too big than maximum allowed: %d", + $blob->path(), + $blob->size() + )); + } + + $this->blob = $blob; + + return $this; + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Image.php b/src/Lexicons/App/Bsky/Embed/Image.php index 0846f1d..894ba2c 100644 --- a/src/Lexicons/App/Bsky/Embed/Image.php +++ b/src/Lexicons/App/Bsky/Embed/Image.php @@ -7,14 +7,14 @@ class Image implements ImageInterface { - private File $file; + private Blob $file; private string $alt; private ?array $aspectRatio = null; /** * @throws InvalidArgumentException */ - public function __construct(File $file, string $alt) + public function __construct(Blob $file, string $alt) { if (true !== str_starts_with($file->type(), 'image/')) { throw new InvalidArgumentException($file->path()." is not a valid image file."); diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 9d3b905..10cff40 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -9,7 +9,7 @@ class Video implements VideoInterface, Stringable { - private File $file; + private Blob $file; private ?string $alt = null; private CaptionCollection $captions; private array $aspectRatio = []; @@ -17,7 +17,7 @@ class Video implements VideoInterface, Stringable /** * @throws InvalidArgumentException */ - public function __construct(File $file) + public function __construct(Blob $file) { if ("video/mp4" !== $file->type()) { throw new InvalidArgumentException($file->path()." is not a valid video file."); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php new file mode 100644 index 0000000..f7e8877 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -0,0 +1,104 @@ +external = new External('https://shahmal1yev.dev', 'foo', 'bar'); + $this->blob = $this->getMockBuilder(Blob::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->blob->expects($this->any()) + ->method('size') + ->will($this->returnCallback(fn () => $this->maximumAllowedBlobSize)); + + $this->blob->expects($this->any()) + ->method('type') + ->will($this->returnCallback(fn () => $this->allowedMimes)); + } + + public function testDescription() + { + $this->assertSame('bar', $this->external->description()); + $this->external->description('foo'); + $this->assertSame('foo', $this->external->description()); + } + + /** + * @throws InvalidArgumentException + */ + public function testThumb() + { + $this->external->thumb($this->blob); + $this->assertSame($this->blob, $this->external->thumb()); + } + + /** + * @throws InvalidArgumentException + */ + public function testThumbReturnsNull(): void + { + $this->assertNull($this->external->thumb()); + } + + public function testThumbThrowsExceptionWhenPassedBlobWithInvalidMimeType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid image type: invalid/*'); + + $this->allowedMimes = 'invalid/*'; + + $this->external->thumb($this->blob); + } + + public function testThumbThrowsExceptionWhenPassedBlobExceedsMaximumAllowedSize(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('size is too big than maximum allowed:'); + + $this->maximumAllowedBlobSize = ++$this->maximumAllowedBlobSize; + + $this->external->thumb($this->blob); + } + + /** + * @throws InvalidArgumentException + */ + public function testUri() + { + $this->assertSame('https://shahmal1yev.dev', $this->external->uri()); + $this->external->uri('https://google.com'); + $this->assertSame('https://google.com', $this->external->uri()); + } + + public function testUriThrowsExceptionWhenPassedInvalidUrl(): void + { + $trigger = 'invalid url'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'$trigger' is not a valid URL"); + + $this->external->uri($trigger); + } + + public function testTitle() + { + $this->assertSame('foo', $this->external->title()); + $this->external->title('bar'); + $this->assertSame('bar', $this->external->title()); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php index bb5f3ab..9e43b22 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use PHPUnit\Framework\MockObject\MockObject; trait FileMocking @@ -13,11 +13,11 @@ trait FileMocking private string $type = 'text/vtt'; /** - * @return (File&MockObject) + * @return (Blob&MockObject) */ private function createMockFile() { - $file = $this->getMockBuilder(File::class) + $file = $this->getMockBuilder(Blob::class) ->disableOriginalConstructor() ->getMock(); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php index 454762d..b95b503 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Lexicons\App\Bsky\Embed; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use PHPUnit\Framework\TestCase; class FileTest extends TestCase @@ -11,7 +11,7 @@ class FileTest extends TestCase private string $testFilePath; private string $unreadableFilePath; private string $nonFilePath; - private File $fileInstance; + private Blob $fileInstance; /** * @throws InvalidArgumentException @@ -22,7 +22,7 @@ protected function setUp(): void $this->testFilePath = tempnam(sys_get_temp_dir(), 'testfile'); file_put_contents($this->testFilePath, 'This is a test file.'); - $this->fileInstance = new File($this->testFilePath); + $this->fileInstance = new Blob($this->testFilePath); $this->unreadableFilePath = tempnam(sys_get_temp_dir(), 'unreadable'); file_put_contents($this->unreadableFilePath, 'This is an unreadable file.'); @@ -85,7 +85,7 @@ public function testConstructorThrowsExceptionWhenPassedUnreadableFilePath(): vo $this->assertFalse(is_readable($this->unreadableFilePath), 'File should not be readable.'); - new File($this->unreadableFilePath); + new Blob($this->unreadableFilePath); } public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void @@ -93,6 +93,6 @@ public function testConstructorThrowsExceptionWhenPassedNonFilePath(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("$this->nonFilePath is not a file."); - new File($this->nonFilePath); + new Blob($this->nonFilePath); } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php index 94a0306..e69051c 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/VideoTest.php @@ -6,7 +6,7 @@ use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Caption; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use Atproto\Lexicons\App\Bsky\Embed\File; +use Atproto\Lexicons\App\Bsky\Embed\Blob; use Atproto\Lexicons\App\Bsky\Embed\Video; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ class VideoTest extends TestCase use FileMocking; private Video $video; - private File $file; + private Blob $file; protected function setUp(): void { From 871a9670d4af70cd8f3307e6d7dc4608e815a510 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:16:16 +0400 Subject: [PATCH 18/27] Implement JsonSerializable and Stringable for External class --- src/Lexicons/App/Bsky/Embed/External.php | 19 ++++++++++- .../Lexicons/App/Bsky/Embed/ExternalTest.php | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 20e37be..6d9363c 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -2,9 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; +use JsonSerializable; -class External +class External implements JsonSerializable, Stringable { private string $uri; private string $title; @@ -87,4 +89,19 @@ public function thumb(Blob $blob = null) return $this; } + + public function jsonSerialize(): array + { + return array_filter([ + 'uri' => $this->uri, + 'title' => $this->title, + 'description' => $this->description, + 'blob' => ($b = $this->blob) ? $b->blob() : null, + ]); + } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php index f7e8877..0939e22 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/ExternalTest.php @@ -18,6 +18,7 @@ class ExternalTest extends TestCase public function setUp(): void { $this->external = new External('https://shahmal1yev.dev', 'foo', 'bar'); + $this->blob = $this->getMockBuilder(Blob::class) ->disableOriginalConstructor() ->getMock(); @@ -29,6 +30,10 @@ public function setUp(): void $this->blob->expects($this->any()) ->method('type') ->will($this->returnCallback(fn () => $this->allowedMimes)); + + $this->blob->expects($this->any()) + ->method('blob') + ->willReturn('blob'); } public function testDescription() @@ -101,4 +106,32 @@ public function testTitle() $this->external->title('bar'); $this->assertSame('bar', $this->external->title()); } + + public function testJsonSerializeWithoutSetBlob(): void + { + $expected = [ + 'uri' => 'https://shahmal1yev.dev', + 'title' => 'foo', + 'description' => 'bar', + ]; + + $this->assertSame($expected, json_decode($this->external, true)); + } + + /** + * @throws InvalidArgumentException + */ + public function testJsonSerializeWithSetBlob(): void + { + $this->external->thumb($this->blob); + + $expected = [ + 'uri' => 'https://shahmal1yev.dev', + 'title' => 'foo', + 'description' => 'bar', + 'blob' => 'blob', + ]; + + $this->assertSame($expected, json_decode($this->external, true)); + } } From 8d4ecc5b983c8c81a4c8ddfe41d18a42d526bdfd Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:31:18 +0400 Subject: [PATCH 19/27] Implement Record: Add Record and StrongRef classes with tests - Add StrongRef class for com.atproto.repo.strongRef - Implement JsonSerializable and Stringable interfaces for both classes - Add unit tests for Record and StrongRef classes - Include getter/setter methods and JSON serialization --- src/Lexicons/App/Bsky/Embed/Record.php | 29 ++++++++++ src/Lexicons/Com/Atproto/Repo/StrongRef.php | 53 +++++++++++++++++++ .../Com/Atproto/Repo/StrongRefTest.php | 39 ++++++++++++++ .../Lexicons/App/Bsky/Embed/RecordTest.php | 36 +++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/Lexicons/App/Bsky/Embed/Record.php create mode 100644 src/Lexicons/Com/Atproto/Repo/StrongRef.php create mode 100644 tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php new file mode 100644 index 0000000..dd873bb --- /dev/null +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -0,0 +1,29 @@ +ref = $ref; + } + + public function __toString(): string + { + return json_encode($this); + } + + public function jsonSerialize(): array + { + return [ + 'record' => $this->ref, + ]; + } +} diff --git a/src/Lexicons/Com/Atproto/Repo/StrongRef.php b/src/Lexicons/Com/Atproto/Repo/StrongRef.php new file mode 100644 index 0000000..037a584 --- /dev/null +++ b/src/Lexicons/Com/Atproto/Repo/StrongRef.php @@ -0,0 +1,53 @@ +uri($uri) + ->cid($cid); + } + + public function uri(string $uri = null) + { + if (is_null($uri)) { + return $this->uri; + } + + $this->uri = $uri; + + return $this; + } + + public function cid(string $cid = null) + { + if (is_null($cid)) { + return $this->cid; + } + + $this->cid = $cid; + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'uri' => $this->uri, + 'cid' => $this->cid, + ]; + } + + public function __toString(): string + { + return json_encode($this); + } +} diff --git a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php b/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php new file mode 100644 index 0000000..b98c359 --- /dev/null +++ b/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php @@ -0,0 +1,39 @@ +strongRef = new StrongRef('foo', 'bar'); + } + + public function testUri() + { + $this->assertSame('foo', $this->strongRef->uri()); + $this->strongRef->uri('bar'); + $this->assertSame('bar', $this->strongRef->uri()); + } + + public function testCid() + { + $this->assertSame('bar', $this->strongRef->cid()); + $this->strongRef->cid('foo'); + $this->assertSame('foo', $this->strongRef->cid()); + } + + public function testJsonSerialize(): void + { + $expected = [ + 'uri' => 'foo', + 'cid' => 'bar', + ]; + + $this->assertSame($expected, $this->strongRef->jsonSerialize()); + } +} diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php new file mode 100644 index 0000000..65423a9 --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordTest.php @@ -0,0 +1,36 @@ + [ + 'uri' => 'foo', + 'cid' => 'bar' + ] + + ]; + + public function setUp(): void + { + $this->record = new Record(new StrongRef('foo', 'bar')); + } + + public function test__toStringWorksWithJsonDecodeDirectly(): void + { + $this->assertSame($this->expected, json_decode($this->record, true)); + } + + public function test__toStringWorksWithJsonEncodeDirectly(): void + { + $this->assertSame(json_encode($this->expected), json_encode($this->record)); + } +} From 4b0bda196df704c5a2ebd0bf83335c3989b4cbc6 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 21:45:32 +0400 Subject: [PATCH 20/27] Implement RecordWithMedia and MediaInterface - Add MediaInterface for unifying different media types - Implement MediaInterface in ImageCollection, External, and Video classes - Create RecordWithMedia class to combine Record with media content - Add unit tests for RecordWithMedia - Update existing classes to implement new MediaInterface --- .../App/Bsky/Embed/MediaInterface.php | 8 +++++ .../Embed/Collections/ImageCollection.php | 3 +- src/Lexicons/App/Bsky/Embed/External.php | 3 +- .../App/Bsky/Embed/RecordWithMedia.php | 35 ++++++++++++++++++ src/Lexicons/App/Bsky/Embed/Video.php | 3 +- .../App/Bsky/Embed/RecordWithMediaTest.php | 36 +++++++++++++++++++ 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php create mode 100644 src/Lexicons/App/Bsky/Embed/RecordWithMedia.php create mode 100644 tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php new file mode 100644 index 0000000..6fd2101 --- /dev/null +++ b/src/Contracts/Lexicons/App/Bsky/Embed/MediaInterface.php @@ -0,0 +1,8 @@ +media($media); + } + + public function media(MediaInterface $media = null) + { + if (is_null($media)) { + return $this->media; + } + + $this->media = $media; + + return $this; + } + + public function jsonSerialize(): array + { + return array_merge(parent::jsonSerialize(), [ + 'media' => $this->media, + ]); + } +} diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index 10cff40..bc97a39 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -2,12 +2,13 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements VideoInterface, Stringable +class Video implements MediaInterface, VideoInterface, Stringable { private Blob $file; private ?string $alt = null; diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php new file mode 100644 index 0000000..870b73b --- /dev/null +++ b/tests/Unit/Lexicons/App/Bsky/Embed/RecordWithMediaTest.php @@ -0,0 +1,36 @@ +media = $this->createMock(MediaInterface::class); + $this->recordWithMedia = new RecordWithMedia($this->createMock(StrongRef::class), $this->media); + } + + public function testMedia() + { + $this->assertSame($this->media, $this->recordWithMedia->media()); + $this->recordWithMedia->media($expected = $this->createMock(MediaInterface::class)); + $this->assertSame($expected, $this->recordWithMedia->media()); + } + + public function testJsonSerialize() + { + $target = json_decode($this->recordWithMedia, true); + + $this->assertArrayHasKey('record', $target); + $this->assertArrayHasKey('media', $target); + $this->assertIsArray($target['media']); + } +} From e2ed997576880cd770eddc364dc772ab2c0fab58 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 22:45:16 +0400 Subject: [PATCH 21/27] Implemented `embed` method on PostBuilder API and refactored embeds - EmbedInterface was introduced. All embeds were refactored to implement EmbedInterface. This interface extends two essential interfaces - JsonSerializable and Stringable, which are mandatory for Lexicon implementations. The reason is that when building a post with the PostBuilder API, each entity that can be used as an embed is a lexicon, and a lexicon should be serializable to an object or array type according to SDK design policy when necessary. The Lexicon interface to be introduced in the future will also extend these two interfaces, and all necessary entities will be refactored. - An embed method was added to the PostBuilder API, related tests were refactored/written. --- .../App/Bsky/Embed/EmbedInterface.php | 2 +- .../App/Bsky/Embed/VideoInterface.php | 5 +-- .../App/Bsky/Feed/PostBuilderContract.php | 6 ++-- .../Embed/Collections/EmbedCollection.php | 5 +++ .../Embed/Collections/ImageCollection.php | 4 +-- src/Lexicons/App/Bsky/Embed/External.php | 5 ++- src/Lexicons/App/Bsky/Embed/Record.php | 5 ++- src/Lexicons/App/Bsky/Embed/Video.php | 3 +- src/Lexicons/App/Bsky/Feed/Post.php | 14 ++++++--- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 31 ++++++++++++++++++- 10 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php index 0f20293..4667dd7 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/EmbedInterface.php @@ -4,6 +4,6 @@ use Atproto\Contracts\Stringable; -interface EmbedInterface extends Stringable +interface EmbedInterface extends \JsonSerializable, Stringable { } diff --git a/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php b/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php index 4e46a35..69ac85b 100644 --- a/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php +++ b/src/Contracts/Lexicons/App/Bsky/Embed/VideoInterface.php @@ -2,13 +2,10 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Stringable; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -use JsonSerializable; -interface VideoInterface extends JsonSerializable +interface VideoInterface extends EmbedInterface { - public function jsonSerialize(): array; public function captions(CaptionCollection $captions = null); public function alt(string $alt = null); public function aspectRatio(); diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 601a088..0db79b1 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -3,10 +3,8 @@ namespace Atproto\Contracts\Lexicons\App\Bsky\Feed; use Atproto\Contracts\LexiconBuilder; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; -use Atproto\Lexicons\App\Bsky\RichText\Link; -use Atproto\Lexicons\App\Bsky\RichText\Mention; -use Atproto\Lexicons\App\Bsky\RichText\Tag; use DateTimeImmutable; interface PostBuilderContract extends LexiconBuilder, Stringable @@ -21,5 +19,5 @@ public function mention(string $reference, string $label = null): PostBuilderCon public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; - public function embed(...$embeds): PostBuilderContract; + public function embed(EmbedInterface $embed): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php index 8eb17d7..e8c9e4c 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/EmbedCollection.php @@ -42,4 +42,9 @@ public function jsonSerialize(): array { return $this->toArray(); } + + public function __toString(): string + { + return json_encode($this); + } } diff --git a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php index 23c678f..57c641e 100644 --- a/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php +++ b/src/Lexicons/App/Bsky/Embed/Collections/ImageCollection.php @@ -2,13 +2,13 @@ namespace Atproto\Lexicons\App\Bsky\Embed\Collections; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\ImageInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use GenericCollection\Exceptions\InvalidArgumentException; use GenericCollection\GenericCollection; -use JsonSerializable; -class ImageCollection extends GenericCollection implements MediaInterface, JsonSerializable +class ImageCollection extends GenericCollection implements EmbedInterface, MediaInterface { use EmbedCollection; diff --git a/src/Lexicons/App/Bsky/Embed/External.php b/src/Lexicons/App/Bsky/Embed/External.php index 8cbba67..8c380ed 100644 --- a/src/Lexicons/App/Bsky/Embed/External.php +++ b/src/Lexicons/App/Bsky/Embed/External.php @@ -2,12 +2,11 @@ namespace Atproto\Lexicons\App\Bsky\Embed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; -use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; -use JsonSerializable; -class External implements MediaInterface, JsonSerializable, Stringable +class External implements EmbedInterface, MediaInterface { private string $uri; private string $title; diff --git a/src/Lexicons/App/Bsky/Embed/Record.php b/src/Lexicons/App/Bsky/Embed/Record.php index dd873bb..eabe245 100644 --- a/src/Lexicons/App/Bsky/Embed/Record.php +++ b/src/Lexicons/App/Bsky/Embed/Record.php @@ -2,11 +2,10 @@ namespace Atproto\Lexicons\App\Bsky\Embed; -use Atproto\Contracts\Stringable; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; -use JsonSerializable; -class Record implements JsonSerializable, Stringable +class Record implements EmbedInterface { private StrongRef $ref; diff --git a/src/Lexicons/App/Bsky/Embed/Video.php b/src/Lexicons/App/Bsky/Embed/Video.php index bc97a39..2b799c8 100644 --- a/src/Lexicons/App/Bsky/Embed/Video.php +++ b/src/Lexicons/App/Bsky/Embed/Video.php @@ -4,11 +4,10 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\MediaInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; -use Atproto\Contracts\Stringable; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Embed\Collections\CaptionCollection; -class Video implements MediaInterface, VideoInterface, Stringable +class Video implements VideoInterface, MediaInterface { private Blob $file; private ?string $alt = null; diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 560b1af..644a810 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -4,6 +4,7 @@ use Atproto\Collections\FacetCollection; use Atproto\Collections\FeatureCollection; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Feed\PostBuilderContract; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\RichText\ByteSlice; @@ -21,6 +22,7 @@ class Post implements PostBuilderContract private string $text = ''; private ?DateTimeImmutable $createdAt = null; private FacetCollection $facets; + private ?EmbedInterface $embed = null; public function __construct() { @@ -70,8 +72,10 @@ public function mention(string $reference, string $label = null): PostBuilderCon return $this->addFeatureItem('mention', $reference, $label); } - public function embed(...$embeds): PostBuilderContract + public function embed(EmbedInterface $embed = null): PostBuilderContract { + $this->embed = $embed; + return $this; } @@ -84,12 +88,13 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract public function jsonSerialize(): array { - return [ + return array_filter([ '$type' => self::TYPE_NAME, 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), - ]; + 'embed' => ($e = $this->embed) ? $e : null, + ]); } public function __toString(): string @@ -181,11 +186,10 @@ private function addFeature(FeatureAbstract $feature): void $this->text .= $label; try { - $facet = new Facet( + $this->facets[] = new Facet( new FeatureCollection([$feature]), new ByteSlice($this->text, $label) ); - $this->facets[] = $facet; } catch (\GenericCollection\Exceptions\InvalidArgumentException $e) { throw new InvalidArgumentException( sprintf('Feature must be an instance of %s.', FeatureAbstract::class) diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index fb65b3f..9582c94 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -2,7 +2,11 @@ namespace Tests\Unit\Lexicons\App\Bsky\Feed; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; +use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; +use Atproto\Lexicons\App\Bsky\Embed\Blob; +use Atproto\Lexicons\App\Bsky\Embed\Video; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; @@ -148,15 +152,40 @@ public function testCreatedAtField() $this->assertNotNull($result['createdAt']); } + public function testEmbed(): void + { + $video = $this->getMockBuilder(VideoInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $video->expects($this->once()) + ->method('jsonSerialize') + ->willReturn(['foo' => 'bar']); + + $this->post->embed($video); + + $expected = ['foo' => 'bar']; + $actual = json_decode($this->post, true); + + $this->assertArrayHasKey('embed', $actual); + $this->assertSame($expected, $actual['embed']); + } + + /** + * @throws InvalidArgumentException + */ public function testJsonSerialize() { - $this->post->text('Test post'); + $this->post->text('Test post: ', new Mention('reference', 'label')); + $this->post->embed($embed = $this->createMock(EmbedInterface::class)); + $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); $this->assertEquals('app.bsky.feed.post', $result['$type']); $this->assertArrayHasKey('text', $result); $this->assertArrayHasKey('createdAt', $result); $this->assertArrayHasKey('facets', $result); + $this->assertArrayHasKey('embed', $result); } /** From f03e978d7f715ad1951ea35359a25286ae8db152 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sat, 12 Oct 2024 22:55:51 +0400 Subject: [PATCH 22/27] Code cleanup and test refinements --- src/Lexicons/App/Bsky/RichText/ByteSlice.php | 2 +- src/Lexicons/App/Bsky/RichText/FeatureFactory.php | 1 - tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php | 10 +++++----- tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php | 4 +--- .../Lexicons/Com/Atproto/Repo/StrongRefTest.php | 3 ++- 5 files changed, 9 insertions(+), 11 deletions(-) rename tests/{ => Unit}/Lexicons/Com/Atproto/Repo/StrongRefTest.php (89%) diff --git a/src/Lexicons/App/Bsky/RichText/ByteSlice.php b/src/Lexicons/App/Bsky/RichText/ByteSlice.php index 9f5ab96..75884ec 100644 --- a/src/Lexicons/App/Bsky/RichText/ByteSlice.php +++ b/src/Lexicons/App/Bsky/RichText/ByteSlice.php @@ -32,4 +32,4 @@ public function jsonSerialize(): array 'byteEnd' => $this->end(), ]; } -} \ No newline at end of file +} diff --git a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php index 01a9682..3000d05 100644 --- a/src/Lexicons/App/Bsky/RichText/FeatureFactory.php +++ b/src/Lexicons/App/Bsky/RichText/FeatureFactory.php @@ -4,7 +4,6 @@ class FeatureFactory { - public static function link(string $reference, string $label = null): Link { return new Link($reference, $label); diff --git a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php index 9e43b22..b1ba4cf 100644 --- a/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php +++ b/tests/Unit/Lexicons/App/Bsky/Embed/FileMocking.php @@ -23,20 +23,20 @@ private function createMockFile() $file->expects($this->any()) ->method('path') - ->will($this->returnCallback(fn() => $this->path)); + ->will($this->returnCallback(fn () => $this->path)); $file->expects($this->any()) ->method('type') - ->will($this->returnCallback(fn() => $this->type)); + ->will($this->returnCallback(fn () => $this->type)); $file->expects($this->any()) ->method('size') - ->will($this->returnCallback(fn() => $this->size)); + ->will($this->returnCallback(fn () => $this->size)); $file->expects($this->any()) ->method('blob') - ->will($this->returnCallback(fn() => $this->blob)); + ->will($this->returnCallback(fn () => $this->blob)); return $file; } -} \ No newline at end of file +} diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 9582c94..b2d6296 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -5,8 +5,6 @@ use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; -use Atproto\Lexicons\App\Bsky\Embed\Blob; -use Atproto\Lexicons\App\Bsky\Embed\Video; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; @@ -142,7 +140,7 @@ public function testTextThrowsExceptionWhenPassedInvalidArgument(): void "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class ); - $this->post->text(1, true, 'string', new Post); + $this->post->text(1, true, 'string', new Post()); } public function testCreatedAtField() diff --git a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php b/tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php similarity index 89% rename from tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php rename to tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php index b98c359..6a9165a 100644 --- a/tests/Lexicons/Com/Atproto/Repo/StrongRefTest.php +++ b/tests/Unit/Lexicons/Com/Atproto/Repo/StrongRefTest.php @@ -1,7 +1,8 @@ Date: Sat, 12 Oct 2024 23:43:32 +0400 Subject: [PATCH 23/27] Implement 'reply' method in PostBuilder API and fix text length validation --- .../App/Bsky/Feed/PostBuilderContract.php | 3 + src/Lexicons/App/Bsky/Feed/Post.php | 17 +- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 146 ++++++++---------- 3 files changed, 85 insertions(+), 81 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 0db79b1..ecc7517 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -5,6 +5,7 @@ use Atproto\Contracts\LexiconBuilder; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use DateTimeImmutable; interface PostBuilderContract extends LexiconBuilder, Stringable @@ -20,4 +21,6 @@ public function mention(string $reference, string $label = null): PostBuilderCon public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; public function embed(EmbedInterface $embed): PostBuilderContract; + + public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 644a810..454f0a6 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -11,6 +11,7 @@ use Atproto\Lexicons\App\Bsky\RichText\Facet; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -23,6 +24,7 @@ class Post implements PostBuilderContract private ?DateTimeImmutable $createdAt = null; private FacetCollection $facets; private ?EmbedInterface $embed = null; + private ?array $reply = null; public function __construct() { @@ -79,6 +81,16 @@ public function embed(EmbedInterface $embed = null): PostBuilderContract return $this; } + public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract + { + $this->reply = [ + 'root' => $root, + 'parent' => $parent + ]; + + return $this; + } + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract { $this->createdAt = $dateTime; @@ -93,7 +105,8 @@ public function jsonSerialize(): array 'createdAt' => $this->getFormattedCreatedAt(), 'text' => $this->text, 'facets' => $this->facets->toArray(), - 'embed' => ($e = $this->embed) ? $e : null, + 'embed' => $this->embed, + 'replyRef' => $this->reply, ]); } @@ -129,7 +142,7 @@ private function validate($item, int $index = 0): void ); } - if ($this->isString($item) && mb_strlen($item) > self::TEXT_LIMIT) { + if ($this->isString($item) && (mb_strlen($item) + mb_strlen($this->text)) > self::TEXT_LIMIT) { throw new InvalidArgumentException( sprintf( 'Text must be less than or equal to %d characters.', diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index b2d6296..3bcac96 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -3,11 +3,11 @@ namespace Tests\Unit\Lexicons\App\Bsky\Feed; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; -use Atproto\Contracts\Lexicons\App\Bsky\Embed\VideoInterface; use Atproto\Exceptions\InvalidArgumentException; use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; +use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ class PostTest extends TestCase { private Post $post; - public function setUp(): void + protected function setUp(): void { parent::setUp(); $this->post = new Post(); @@ -28,20 +28,18 @@ public function setUp(): void public function testTextMethodWithBasicUsage() { $this->post->text('Hello, world!'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('Hello, world!', $result['text']); } /** * @throws InvalidArgumentException */ - public function testTextMethod() + public function testTextMethodWithMultipleItems() { $this->post->text('Hello', ', ', new Mention('example:did:123', 'user'), "! It's ", 5, " o'clock now."); - $result = json_decode(json_encode($this->post), true); + $result = json_decode((string) $this->post, true); $this->assertEquals("Hello, @user! It's 5 o'clock now.", $result['text']); - - $this->post->text('This ', new Mention('example:did:123', 'user'), '!'); } /** @@ -50,7 +48,7 @@ public function testTextMethod() public function testTagMethod() { $this->post->tag('example', 'test'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('#test', $result['text']); $this->assertCount(1, $result['facets']); } @@ -60,8 +58,8 @@ public function testTagMethod() */ public function testLinkMethod() { - $this->post->link('https://example.com', 'Example'); - $result = json_decode($this->post, true); + $this->post->link('https://shahmal1yev.dev', 'Example'); + $result = json_decode((string) $this->post, true); $this->assertEquals('Example', $result['text']); $this->assertCount(1, $result['facets']); } @@ -72,51 +70,55 @@ public function testLinkMethod() public function testMentionMethod() { $this->post->mention('did:example:123', 'user'); - $result = json_decode($this->post, true); + $result = json_decode((string) $this->post, true); $this->assertEquals('@user', $result['text']); $this->assertCount(1, $result['facets']); } public function testCombinationOfMethods() { - $this->post = $this->post(); + $post = $this->createSamplePost(); - $result = json_decode($this->post, true); + $result = json_decode((string) $post, true); $this->assertEquals('Hello @user! Check out this link #example_tag', $result['text']); $this->assertCount(3, $result['facets']); } - public function testCoordinatesOfFacets(): void + public function testCoordinatesOfFacets() { - $this->post = $this->post(); + $post = $this->createSamplePost(); - $result = json_decode($this->post, true); + $result = json_decode((string) $post, true); $text = $result['text']; $facets = $result['facets']; - $this->assertSame($facets[0]['index'], $this->bytes($text, "@user")); - $this->assertSame($facets[1]['index'], $this->bytes($text, "this link")); - $this->assertSame($facets[2]['index'], $this->bytes($text, "#example_tag")); + $this->assertSame($this->calculateByteSlice($text, '@user'), $facets[0]['index']); + $this->assertSame($this->calculateByteSlice($text, 'this link'), $facets[1]['index']); + $this->assertSame($this->calculateByteSlice($text, '#example_tag'), $facets[2]['index']); } - private function bytes(string $haystack, string $needle): array + private function calculateByteSlice(string $text, string $substring): array { - $pos = mb_strpos($haystack, $needle); - $len = $pos + mb_strlen($needle); + $byteStart = mb_strpos($text, $substring); + $byteEnd = $byteStart + mb_strlen($substring); return [ - 'byteStart' => $pos, - 'byteEnd' => $len, + 'byteStart' => $byteStart, + 'byteEnd' => $byteEnd, ]; } - private function post(): Post + /** + * @throws InvalidArgumentException + */ + private function createSamplePost(): Post { - return $this->post->text('Hello ') + return (new Post()) + ->text('Hello ') ->mention('did:example:123', 'user') ->text('! Check out ') - ->link('https://example.com', 'this link') + ->link('https://shahmal1yev.dev', 'this link') ->text(' ') ->tag('example_tag'); } @@ -129,44 +131,52 @@ public function testTextThrowsExceptionWhenLimitIsExceeded() $this->post->text(str_repeat('a', 3000)); $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Text must be less than or equal to 3000 characters.'); - $this->post->text(str_repeat('a', 3001)); + // This should cause the total text length to exceed the limit + $this->post->text('a'); } - public function testTextThrowsExceptionWhenPassedInvalidArgument(): void + public function testTextThrowsExceptionWhenPassedInvalidArgument() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - "Argument at index 4 is invalid: must be a string or an instance of " . FeatureAbstract::class - ); + $this->expectExceptionMessageMatches('/Argument at index \d+ is invalid/'); $this->post->text(1, true, 'string', new Post()); } - public function testCreatedAtField() + public function testCreatedAtFieldIsSetByDefault() { - $result = json_decode($this->post, true); + $this->post->text('Test post'); + $result = json_decode((string) $this->post, true); + $this->assertArrayHasKey('createdAt', $result); - $this->assertNotNull($result['createdAt']); + + $createdAt = Carbon::parse($result['createdAt']); + $now = Carbon::now(); + + $this->assertTrue($createdAt->diffInSeconds($now) < 5, 'createdAt should be within the last 5 seconds'); } - public function testEmbed(): void + public function testCreatedAtReturnsAssignedTime() { - $video = $this->getMockBuilder(VideoInterface::class) - ->disableOriginalConstructor() - ->getMock(); + $futureTime = Carbon::now()->addHour(); + $this->post->createdAt($futureTime->toDateTimeImmutable()); - $video->expects($this->once()) - ->method('jsonSerialize') - ->willReturn(['foo' => 'bar']); + $result = json_decode((string) $this->post, true); + $this->assertEquals($futureTime->toIso8601String(), $result['createdAt']); + } - $this->post->embed($video); + public function testEmbedMethod() + { + $embed = $this->createMock(EmbedInterface::class); + $embed->method('jsonSerialize')->willReturn(['embedData' => 'value']); - $expected = ['foo' => 'bar']; - $actual = json_decode($this->post, true); + $this->post->embed($embed); - $this->assertArrayHasKey('embed', $actual); - $this->assertSame($expected, $actual['embed']); + $result = json_decode((string) $this->post, true); + $this->assertArrayHasKey('embed', $result); + $this->assertEquals(['embedData' => 'value'], $result['embed']); } /** @@ -175,7 +185,11 @@ public function testEmbed(): void public function testJsonSerialize() { $this->post->text('Test post: ', new Mention('reference', 'label')); - $this->post->embed($embed = $this->createMock(EmbedInterface::class)); + $embed = $this->createMock(EmbedInterface::class); + $embed->method('jsonSerialize')->willReturn(['embedKey' => 'embedValue']); + $this->post->embed($embed); + $sRef = new StrongRef('foo', 'bar'); + $this->post->reply($sRef, clone $sRef); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -184,39 +198,13 @@ public function testJsonSerialize() $this->assertArrayHasKey('createdAt', $result); $this->assertArrayHasKey('facets', $result); $this->assertArrayHasKey('embed', $result); + $this->assertArrayHasKey('replyRef', $result); } - /** - * @throws InvalidArgumentException - */ - public function testCreatedAtAssignedByDefault(): void - { - $post = $this->post->text('Test post'); - $result = json_decode($post, true); - - $this->assertSame($result['createdAt'], Carbon::now()->toIso8601String()); - } - - public function testCreatedAtReturnsAssignedTime(): void + public function testConstructorWorksCorrectlyOnDirectBuild() { - $timestamp = time() + 3600; // Get the current timestamp - $this->post->createdAt(new DateTimeImmutable("@$timestamp")); - - $actual = json_decode($this->post, false)->createdAt; - $expected = Carbon::now()->modify("+1 hour")->toIso8601String(); - - $this->assertSame( - $actual, - $expected, - ); - } - - public function testConstructorWorksCorrectlyOnDirectBuild(): void - { - $array = json_decode($this->post, true); - $json = json_encode($array); - - $this->assertTrue(is_array($array)); - $this->assertTrue(json_encode($array) === $json); + $result = json_decode((string) $this->post, true); + $this->assertIsArray($result); + $this->assertEquals($result, json_decode(json_encode($result), true)); } } From 612b6a583c964fbe5061712f84990248d99b1395 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 00:23:22 +0400 Subject: [PATCH 24/27] Add language support to PostBuilderContract and Post class - Added `langs` method to PostBuilderContract interface to handle language settings for a post. - Implemented `langs` method in the Post class to set and validate language codes with a limit of 3 languages. - Added validation for language codes using a regex to ensure conformity with the standard language tag format. --- .../App/Bsky/Feed/PostBuilderContract.php | 2 + src/Lexicons/App/Bsky/Feed/Post.php | 58 +++++++++++++++++++ .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 37 ++++++++++++ 3 files changed, 97 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index ecc7517..b90afc6 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -23,4 +23,6 @@ public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract; public function embed(EmbedInterface $embed): PostBuilderContract; public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; + + public function langs(array $languages): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 454f0a6..6a1d450 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -25,6 +25,7 @@ class Post implements PostBuilderContract private FacetCollection $facets; private ?EmbedInterface $embed = null; private ?array $reply = null; + private ?array $languages = null; public function __construct() { @@ -91,6 +92,62 @@ public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract return $this; } + /** + * Sets the languages for the post. + * + * @param array $languages + * @return PostBuilderContract + * @throws InvalidArgumentException + */ + public function langs(array $languages): PostBuilderContract + { + if (count($languages) > 3) { + throw new InvalidArgumentException('A maximum of 3 language codes is allowed.'); + } + + foreach($languages as $lang) { + if (! $this->isValidLanguageCode($lang)) { + throw new InvalidArgumentException(sprintf('Invalid language code: %s', $lang)); + } + } + + if (empty($languages)) { + $languages = null; + } + + $this->languages = $languages; + + return $this; + } + + /** + * Validates the format of a language code. + * + * @param string $lang + * @return bool + */ + private function isValidLanguageCode(string $lang): bool + { + $regular = '(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)'; + $irregular = '(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)'; + $grandfathered = '(' . $irregular . '|' . $regular . ')'; + $privateUse = '(x(-[A-Za-z0-9]{1,8})+)'; + $privateUse2 = '(x(-[A-Za-z0-9]{1,8})+)'; + $singleton = '[0-9A-WY-Za-wy-z]'; + $extension = '(' . $singleton . '(-[A-Za-z0-9]{2,8})+)'; + $variant = '([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})'; + $region = '([A-Za-z]{2}|[0-9]{3})'; + $script = '([A-Za-z]{4})'; + $extlang = '([A-Za-z]{3}(-[A-Za-z]{3}){0,2})'; + $language = '(([A-Za-z]{2,3}(-' . $extlang . ')?)|[A-Za-z]{4}|[A-Za-z]{5,8})'; + $langtag = '(' . $language . '(-' . $script . ')?(-' . $region . ')?(-' . $variant . ')*(-' . $extension . ')*(-' . $privateUse . ')?)'; + $languageTag = '(' . $grandfathered . '|' . $langtag . '|' . $privateUse2 . ')'; + + $regex = '/^' . $languageTag . '$/'; + + return preg_match($regex, $lang); + } + public function createdAt(DateTimeImmutable $dateTime): PostBuilderContract { $this->createdAt = $dateTime; @@ -107,6 +164,7 @@ public function jsonSerialize(): array 'facets' => $this->facets->toArray(), 'embed' => $this->embed, 'replyRef' => $this->reply, + 'langs' => $this->languages, ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 3bcac96..2f8b657 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -179,6 +179,41 @@ public function testEmbedMethod() $this->assertEquals(['embedData' => 'value'], $result['embed']); } + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodWithValidInput() + { + $this->post->langs(['en', 'fr', 'es']); + $result = json_decode($this->post, true); + + $this->assertArrayHasKey('langs', $result); + $this->assertEquals(['en', 'fr', 'es'], $result['langs']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodThrowsExceptionOnTooManyLanguages() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A maximum of 3 language codes is allowed.'); + + $this->post->langs(['en', 'fr', 'es', 'de']); + } + + /** + * @throws InvalidArgumentException + */ + public function testLangsMethodThrowsExceptionOnInvalidLanguageCode() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid language code: d'); + + $this->post->langs(['en', 'd']); + } + + /** * @throws InvalidArgumentException */ @@ -190,6 +225,7 @@ public function testJsonSerialize() $this->post->embed($embed); $sRef = new StrongRef('foo', 'bar'); $this->post->reply($sRef, clone $sRef); + $this->post->langs(['en', 'fr', 'es']); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -199,6 +235,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('facets', $result); $this->assertArrayHasKey('embed', $result); $this->assertArrayHasKey('replyRef', $result); + $this->assertArrayHasKey('langs', $result); } public function testConstructorWorksCorrectlyOnDirectBuild() From b249eb9a7ce5c12d3a675f826000e05f40768b75 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:04:45 +0400 Subject: [PATCH 25/27] SelfLabels lexicon and corresponding unit tests --- src/Lexicons/Com/Atproto/Label/SelfLabels.php | 74 +++++++++++++++++++ .../Com/Atproto/Label/SelfLabelsTest.php | 73 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/Lexicons/Com/Atproto/Label/SelfLabels.php create mode 100644 tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php diff --git a/src/Lexicons/Com/Atproto/Label/SelfLabels.php b/src/Lexicons/Com/Atproto/Label/SelfLabels.php new file mode 100644 index 0000000..f2a0fd0 --- /dev/null +++ b/src/Lexicons/Com/Atproto/Label/SelfLabels.php @@ -0,0 +1,74 @@ +collection = $collection; + + try { + parent::__construct($this->validator(), $collection); + } catch (\TypeError|\GenericCollection\Exceptions\InvalidArgumentException $e) { + $this->throw($e); + } + } + + private function validator(): \Closure + { + return function (string $val): bool { + if ($this->count() + 1 > self::MAXLENGTH) { + throw new InvalidArgumentException("Maximum allowed length is " . self::MAXLENGTH); + } + + if (strlen($val) > self::MAXLENGTH_BY_ITEM) { + throw new InvalidArgumentException("Length exceeded for $val. Max ".self::MAXLENGTH_BY_ITEM." characters allowed."); + } + + return true; + }; + } + + /** + * @throws InvalidArgumentException + */ + private function throw($exception): void + { + throw new InvalidArgumentException( + str_replace( + "::".__NAMESPACE__."\{closure}()", + "", + $exception->getMessage() + ), + 0, + $exception + ); + } + + public function __toString(): string + { + return json_encode($this); + } + + public function jsonSerialize() + { + return $this->toArray(); + } + + public function toArray(): array + { + return array_map(fn (string $val) => ['val' => $val], $this->collection); + } +} diff --git a/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php new file mode 100644 index 0000000..b5291be --- /dev/null +++ b/tests/Unit/Lexicons/Com/Atproto/Label/SelfLabelsTest.php @@ -0,0 +1,73 @@ +selfLabels = new SelfLabels(); + } + + /** + * @throws InvalidArgumentException + */ + public function test__constructFillsDataCorrectly(): void + { + $expected = array_map(fn (string $val) => ['val' => $val], ['val 1', 'val 2', 'val 3']); + + $this->selfLabels = new SelfLabels(array_column( + $expected, + 'val' + )); + + $this->assertEquals($expected, $this->selfLabels->toArray()); + } + + public function test__constructorThrowsInvalidArgumentExceptionWhenPassedInvalidArgument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("must be of the type string"); + + $this->selfLabels = new SelfLabels(['val 1', new stdClass()]); + } + + public function test__constructThrowsInvalidArgumentExceptionWhenLimitExceeded(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Maximum allowed length is ".$this->maxlength); + + $trigger = str_split(str_pad('', ++$this->maxlength, 'a')); + $this->selfLabels = new SelfLabels($trigger); + } + + public function test__constructorThrowsExceptionWhenPassedArgumentThatExceedsLimit(): void + { + $trigger = [str_pad('', ++$this->maxlengthByItem, 'a')]; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Length exceeded for " . current($trigger)); + + $this->selfLabels = new SelfLabels($trigger); + } + + public function testAddThrowsExceptionWhenPassedArgumentExceedsMaxLengthLimit(): void + { + $trigger = str_split(str_pad('', ++$this->maxlength, 'a')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Maximum allowed length is ".--$this->maxlength); + + foreach($trigger as $item) { + $this->selfLabels[] = $item; + } + } +} From b21eb1367da8366ea1c3fc2c8dcca0c5721e7bea Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:15:05 +0400 Subject: [PATCH 26/27] Add support for labels in PostBuilderContract and Post class --- .../App/Bsky/Feed/PostBuilderContract.php | 3 +++ src/Lexicons/App/Bsky/Feed/Post.php | 11 +++++++++ .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index b90afc6..9058dc6 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -5,6 +5,7 @@ use Atproto\Contracts\LexiconBuilder; use Atproto\Contracts\Lexicons\App\Bsky\Embed\EmbedInterface; use Atproto\Contracts\Stringable; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use DateTimeImmutable; @@ -25,4 +26,6 @@ public function embed(EmbedInterface $embed): PostBuilderContract; public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; public function langs(array $languages): PostBuilderContract; + + public function labels(SelfLabels $labels): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index 6a1d450..b028d64 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -11,6 +11,7 @@ use Atproto\Lexicons\App\Bsky\RichText\Facet; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\FeatureFactory; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -26,6 +27,8 @@ class Post implements PostBuilderContract private ?EmbedInterface $embed = null; private ?array $reply = null; private ?array $languages = null; + private ?SelfLabels $labels = null; + public function __construct() { @@ -120,6 +123,13 @@ public function langs(array $languages): PostBuilderContract return $this; } + public function labels(SelfLabels $labels): PostBuilderContract + { + $this->labels = $labels; + + return $this; + } + /** * Validates the format of a language code. * @@ -165,6 +175,7 @@ public function jsonSerialize(): array 'embed' => $this->embed, 'replyRef' => $this->reply, 'langs' => $this->languages, + 'labels' => $this->labels ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 2f8b657..8678a23 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -7,6 +7,7 @@ use Atproto\Lexicons\App\Bsky\Feed\Post; use Atproto\Lexicons\App\Bsky\RichText\FeatureAbstract; use Atproto\Lexicons\App\Bsky\RichText\Mention; +use Atproto\Lexicons\Com\Atproto\Label\SelfLabels; use Atproto\Lexicons\Com\Atproto\Repo\StrongRef; use Carbon\Carbon; use DateTimeImmutable; @@ -213,6 +214,23 @@ public function testLangsMethodThrowsExceptionOnInvalidLanguageCode() $this->post->langs(['en', 'd']); } + /** + * @throws InvalidArgumentException + */ + public function testLabelsMethod(): void + { + $labels = new SelfLabels(['v 1', 'v 2', 'v 3']); + + $this->post->labels($labels); + + $result = json_decode($this->post, true); + $this->assertArrayHasKey('labels', $result); + $this->assertEquals([ + ['val' => 'v 1'], + ['val' => 'v 2'], + ['val' => 'v 3'], + ], $result['labels']); + } /** * @throws InvalidArgumentException @@ -226,6 +244,10 @@ public function testJsonSerialize() $sRef = new StrongRef('foo', 'bar'); $this->post->reply($sRef, clone $sRef); $this->post->langs(['en', 'fr', 'es']); + $this->post->labels(new SelfLabels([ + 'val 1', + 'val 2' + ])); $result = $this->post->jsonSerialize(); $this->assertArrayHasKey('$type', $result); @@ -236,6 +258,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('embed', $result); $this->assertArrayHasKey('replyRef', $result); $this->assertArrayHasKey('langs', $result); + $this->assertArrayHasKey('labels', $result); } public function testConstructorWorksCorrectlyOnDirectBuild() From c2ef9b7813769cb7e6f020ded7901a184e9d9cba Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 13 Oct 2024 22:30:16 +0400 Subject: [PATCH 27/27] Add tags support to PostBuilder API --- .../App/Bsky/Feed/PostBuilderContract.php | 2 + src/Lexicons/App/Bsky/Feed/Post.php | 35 +++++++++++++- .../Unit/Lexicons/App/Bsky/Feed/PostTest.php | 46 ++++++++++++++++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php index 9058dc6..835447b 100644 --- a/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php +++ b/src/Contracts/Lexicons/App/Bsky/Feed/PostBuilderContract.php @@ -28,4 +28,6 @@ public function reply(StrongRef $root, StrongRef $parent): PostBuilderContract; public function langs(array $languages): PostBuilderContract; public function labels(SelfLabels $labels): PostBuilderContract; + + public function tags(array $tags): PostBuilderContract; } diff --git a/src/Lexicons/App/Bsky/Feed/Post.php b/src/Lexicons/App/Bsky/Feed/Post.php index b028d64..9737904 100644 --- a/src/Lexicons/App/Bsky/Feed/Post.php +++ b/src/Lexicons/App/Bsky/Feed/Post.php @@ -28,6 +28,7 @@ class Post implements PostBuilderContract private ?array $reply = null; private ?array $languages = null; private ?SelfLabels $labels = null; + private ?array $tags = null; public function __construct() @@ -130,6 +131,37 @@ public function labels(SelfLabels $labels): PostBuilderContract return $this; } + /** + * @throws InvalidArgumentException + */ + public function tags(array $tags): PostBuilderContract + { + $maxLength = 8; + $maxLengthByTag = 640; + + if (count($tags) > $maxLength) { + throw new InvalidArgumentException('A maximum of 8 tags is allowed.'); + } + + $invalid = array_filter($tags, function ($tag) { + if (mb_strlen($tag) > 640) { + return true; + } + }); + + if (! empty($invalid)) { + throw new InvalidArgumentException(sprintf( + "Invalid tags: %s. A tag maximum of %s characters is allowed.", + implode(', ', $invalid), + $maxLengthByTag + )); + } + + $this->tags = $tags; + + return $this; + } + /** * Validates the format of a language code. * @@ -175,7 +207,8 @@ public function jsonSerialize(): array 'embed' => $this->embed, 'replyRef' => $this->reply, 'langs' => $this->languages, - 'labels' => $this->labels + 'labels' => $this->labels, + 'tags' => $this->tags, ]); } diff --git a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php index 8678a23..5e40030 100644 --- a/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php +++ b/tests/Unit/Lexicons/App/Bsky/Feed/PostTest.php @@ -232,6 +232,41 @@ public function testLabelsMethod(): void ], $result['labels']); } + public function testTagsThrowsExceptionWhenPassedTagExceedsAllowedLength(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid tags: "); + + $trigger = [str_pad('', 641, 'a')]; + $this->post->tags($trigger); + } + + public function testTagsMethodThrowsExceptionWhenPassedArrayExceedsAllowedLength(): void + { + $max = 8; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("A maximum of $max tags is allowed."); + + $trigger = str_split(str_pad('', ++$max, 'a')); + + $this->post->tags($trigger); + } + + /** + * @throws InvalidArgumentException + */ + public function testTags(): void + { + $tags = [str_pad('', 640, 'a')]; + $this->post->tags($tags); + + $result = json_decode($this->post, true); + + $this->assertArrayHasKey('tags', $result); + $this->assertEquals($tags, $result['tags']); + } + /** * @throws InvalidArgumentException */ @@ -241,15 +276,13 @@ public function testJsonSerialize() $embed = $this->createMock(EmbedInterface::class); $embed->method('jsonSerialize')->willReturn(['embedKey' => 'embedValue']); $this->post->embed($embed); - $sRef = new StrongRef('foo', 'bar'); - $this->post->reply($sRef, clone $sRef); + $this->post->reply($sRef = new StrongRef('foo', 'bar'), clone $sRef); $this->post->langs(['en', 'fr', 'es']); - $this->post->labels(new SelfLabels([ - 'val 1', - 'val 2' - ])); + $this->post->labels(new SelfLabels(str_split(str_pad('', 2, 'val')))); + $this->post->tags(str_split(str_pad('', 2, 'tag'))); $result = $this->post->jsonSerialize(); + $this->assertArrayHasKey('$type', $result); $this->assertEquals('app.bsky.feed.post', $result['$type']); $this->assertArrayHasKey('text', $result); @@ -259,6 +292,7 @@ public function testJsonSerialize() $this->assertArrayHasKey('replyRef', $result); $this->assertArrayHasKey('langs', $result); $this->assertArrayHasKey('labels', $result); + $this->assertArrayHasKey('tags', $result); } public function testConstructorWorksCorrectlyOnDirectBuild()