From 40242bc2875ca2930d79234281f2b01c2ec4b7f4 Mon Sep 17 00:00:00 2001 From: Eldar Shahmaliyev Date: Sun, 22 Sep 2024 00:55:25 +0400 Subject: [PATCH] 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); + } }