Skip to content

Commit f61e003

Browse files
committed
feat(runtime): add dispatch helper and refactor HttpRepository
Add new dispatch() runtime helper function to handle event dispatching through the container's EventDispatcherInterface. This prevents circular dependency issues when HttpRepository was directly injecting the dispatcher. Refactor HttpRepository to use the new dispatch() function instead of injecting EventDispatcherInterface, resolving application loop issues. Add comprehensive tests validating that dispatch() correctly uses the EventDispatcherInterface from the container with proper mock injection and cleanup.
1 parent 7e07ac4 commit f61e003

File tree

4 files changed

+138
-94
lines changed

4 files changed

+138
-94
lines changed

src/Infrastructure/Repository/HttpRepository.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use GuzzleHttp\Exception\ClientException;
1010
use GuzzleHttp\Exception\GuzzleException;
1111
use Hyperf\Guzzle\ClientFactory;
12-
use Psr\EventDispatcher\EventDispatcherInterface;
1312
use Psr\Http\Message\ResponseInterface;
1413
use Serendipity\Domain\Contract\Support\ThrownFactory;
1514
use Serendipity\Domain\Event\RequestExecutedEvent;
@@ -19,26 +18,23 @@
1918

2019
use function Constructo\Json\encode;
2120
use function Hyperf\Support\make;
21+
use function Serendipity\Runtime\dispatch;
2222

2323
abstract class HttpRepository
2424
{
2525
private readonly Client $client;
2626

2727
private readonly array $options;
2828

29-
private readonly EventDispatcherInterface $dispatcher;
30-
3129
private readonly ThrownFactory $thrownFactory;
3230

3331
public function __construct(
3432
ClientFactory $clientFactory,
35-
?EventDispatcherInterface $dispatcher = null,
3633
?ThrownFactory $thrownFactory = null,
3734
) {
3835
$this->options = $this->options();
3936
$this->client = $clientFactory->create($this->options);
4037

41-
$this->dispatcher = $dispatcher ?? make(EventDispatcherInterface::class);
4238
$this->thrownFactory = $thrownFactory ?? make(HyperfThrownFactory::class);
4339
}
4440

@@ -106,6 +102,6 @@ private function dispatch(array $options, string $method, string $uri, ?Message
106102
{
107103
$options = array_merge($this->options, $options);
108104
$event = new RequestExecutedEvent($method, $uri, $options, $message);
109-
$this->dispatcher->dispatch($event);
105+
dispatch($event);
110106
}
111107
}

src/_/runtime.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
namespace Serendipity\Runtime;
66

77
use Hyperf\Coroutine\Coroutine;
8+
use Psr\EventDispatcher\EventDispatcherInterface;
9+
10+
use function Hyperf\Support\make;
811

912
if (! function_exists(__NAMESPACE__ . '\invoke')) {
1013
function invoke(callable $callback, mixed ...$args): mixed
@@ -20,3 +23,11 @@ function coroutine(callable $callback): int
2023
return Coroutine::create($callback);
2124
}
2225
}
26+
27+
if (! function_exists(__NAMESPACE__ . '\dispatch')) {
28+
function dispatch(object $event): void
29+
{
30+
$dispatcher = make(EventDispatcherInterface::class);
31+
$dispatcher->dispatch($event);
32+
}
33+
}

tests/Infrastructure/Repository/HttpRepositoryTest.php

Lines changed: 2 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,12 @@
1010
use GuzzleHttp\Exception\ConnectException;
1111
use Hyperf\Guzzle\ClientFactory;
1212
use PHPUnit\Framework\TestCase;
13-
use Psr\EventDispatcher\EventDispatcherInterface;
1413
use Psr\Http\Message\RequestInterface;
1514
use Psr\Http\Message\ResponseInterface;
1615
use Psr\Http\Message\StreamInterface;
1716
use Serendipity\Domain\Contract\Support\ThrownFactory;
18-
use Serendipity\Domain\Event\RequestExecutedEvent;
1917
use Serendipity\Domain\Exception\Parser\Thrown;
2018
use Serendipity\Domain\Exception\RepositoryException;
21-
use Serendipity\Domain\Exception\ThrowableType;
2219

2320
class HttpRepositoryTest extends TestCase
2421
{
@@ -156,12 +153,8 @@ public function testShouldHandleClientExceptionWithResponse(): void
156153
->with($exception)
157154
->willReturn(Thrown::createFrom($exception));
158155

159-
$dispatcher = $this->createMock(EventDispatcherInterface::class);
160-
$dispatcher->expects($this->once())
161-
->method('dispatch');
162-
163156
try {
164-
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher, $thrownFactory);
157+
$repository = new HttpRepositoryTestMock($clientFactory, $thrownFactory);
165158
$repository->exposeRequest();
166159
$this->fail('Expected RepositoryException to be thrown');
167160
} catch (RepositoryException $e) {
@@ -195,86 +188,7 @@ public function testShouldHandleNonClientException(): void
195188
->with($exception)
196189
->willReturn(Thrown::createFrom($exception));
197190

198-
$repository = new HttpRepositoryTestMock($clientFactory, null, $thrownFactory);
191+
$repository = new HttpRepositoryTestMock($clientFactory, $thrownFactory);
199192
$repository->exposeRequest();
200193
}
201-
202-
public function testShouldDispatchEventOnSuccess(): void
203-
{
204-
$stream = $this->createMock(StreamInterface::class);
205-
$stream->expects($this->once())
206-
->method('getContents')
207-
->willReturn('{"message": "Success"}');
208-
209-
$response = $this->createMock(ResponseInterface::class);
210-
$response->expects($this->once())
211-
->method('getHeaders')
212-
->willReturn([]);
213-
$response->expects($this->once())
214-
->method('getBody')
215-
->willReturn($stream);
216-
217-
$client = $this->createMock(Client::class);
218-
$client->expects($this->once())
219-
->method('request')
220-
->with('POST', '/api', ['json' => ['key' => 'value']])
221-
->willReturn($response);
222-
223-
$clientFactory = $this->createMock(ClientFactory::class);
224-
$clientFactory->expects($this->once())
225-
->method('create')
226-
->willReturn($client);
227-
228-
$dispatcher = $this->createMock(EventDispatcherInterface::class);
229-
$dispatcher->expects($this->once())
230-
->method('dispatch')
231-
->with($this->callback(fn($event) => $event instanceof RequestExecutedEvent
232-
&& $event->method === 'POST'
233-
&& $event->uri === '/api'
234-
&& isset($event->options['json'])
235-
&& $event->message !== null));
236-
237-
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher);
238-
$repository->exposeRequest('POST', '/api', ['json' => ['key' => 'value']]);
239-
}
240-
241-
public function testShouldDispatchEventOnFailure(): void
242-
{
243-
$exception = new ConnectException(
244-
'Connection refused',
245-
$this->createMock(RequestInterface::class)
246-
);
247-
248-
$client = $this->createMock(Client::class);
249-
$client->expects($this->once())
250-
->method('request')
251-
->with('POST', '/api', [])
252-
->willThrowException($exception);
253-
254-
$clientFactory = $this->createMock(ClientFactory::class);
255-
$clientFactory->expects($this->once())
256-
->method('create')
257-
->willReturn($client);
258-
259-
$dispatcher = $this->createMock(EventDispatcherInterface::class);
260-
$dispatcher->expects($this->once())
261-
->method('dispatch')
262-
->with($this->callback(fn($event) => $event instanceof RequestExecutedEvent
263-
&& $event->method === 'POST'
264-
&& $event->uri === '/api'
265-
&& $event->message !== null));
266-
267-
$thrownFactory = $this->createMock(ThrownFactory::class);
268-
$thrownFactory->expects($this->once())
269-
->method('make')
270-
->with($exception)
271-
->willReturn(Thrown::createFrom($exception));
272-
273-
try {
274-
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher, $thrownFactory);
275-
$repository->exposeRequest('POST', '/api');
276-
} catch (RepositoryException) {
277-
// Expected exception
278-
}
279-
}
280194
}

tests/_/FunctionsRuntimeTest.php

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44

55
namespace Serendipity\Test\_;
66

7+
use Hyperf\Context\ApplicationContext;
78
use PHPUnit\Framework\TestCase;
9+
use Psr\Container\ContainerInterface;
10+
use Psr\EventDispatcher\EventDispatcherInterface;
11+
use stdClass;
812

913
use function Serendipity\Runtime\coroutine;
14+
use function Serendipity\Runtime\dispatch;
1015
use function Serendipity\Runtime\invoke;
1116

1217
final class FunctionsRuntimeTest extends TestCase
@@ -27,4 +32,122 @@ public function testCoroutineShouldReturnId(): void
2732
{
2833
$this->assertIsInt(coroutine(fn () => null));
2934
}
35+
36+
public function testDispatchShouldCallEventDispatcher(): void
37+
{
38+
$event = new class {
39+
public string $type = 'test-event';
40+
};
41+
42+
$tracker = new class {
43+
public bool $dispatchCalled = false;
44+
45+
public ?object $receivedEvent = null;
46+
};
47+
48+
$mockDispatcher = new readonly class ($tracker) implements EventDispatcherInterface {
49+
public function __construct(
50+
private object $tracker,
51+
) {
52+
}
53+
54+
public function dispatch(object $event): object
55+
{
56+
$this->tracker->dispatchCalled = true;
57+
$this->tracker->receivedEvent = $event;
58+
return $event;
59+
}
60+
};
61+
62+
$originalContainer = $this->swapContainerDispatcher($mockDispatcher);
63+
64+
try {
65+
dispatch($event);
66+
$this->assertTrue($tracker->dispatchCalled, 'EventDispatcher::dispatch() should be called');
67+
$this->assertSame($event, $tracker->receivedEvent, 'Event should be passed to the dispatcher');
68+
} finally {
69+
$this->restoreContainer($originalContainer);
70+
}
71+
}
72+
73+
public function testDispatchShouldAcceptAnyObject(): void
74+
{
75+
$event1 = new class {
76+
public string $type = 'event1';
77+
};
78+
$event2 = new stdClass();
79+
80+
$tracker = new class {
81+
public int $dispatchCount = 0;
82+
83+
public array $receivedEvents = [];
84+
};
85+
86+
$mockDispatcher = new readonly class ($tracker) implements EventDispatcherInterface {
87+
public function __construct(
88+
private object $tracker,
89+
) {
90+
}
91+
92+
public function dispatch(object $event): object
93+
{
94+
++$this->tracker->dispatchCount;
95+
$this->tracker->receivedEvents[] = $event;
96+
return $event;
97+
}
98+
};
99+
100+
$originalContainer = $this->swapContainerDispatcher($mockDispatcher);
101+
102+
try {
103+
dispatch($event1);
104+
dispatch($event2);
105+
$this->assertEquals(2, $tracker->dispatchCount, 'EventDispatcher::dispatch() should be called twice');
106+
$this->assertCount(2, $tracker->receivedEvents, 'Should receive both events');
107+
$this->assertSame($event1, $tracker->receivedEvents[0], 'The first event should match');
108+
$this->assertSame($event2, $tracker->receivedEvents[1], 'The second event should match');
109+
} finally {
110+
$this->restoreContainer($originalContainer);
111+
}
112+
}
113+
114+
private function swapContainerDispatcher(EventDispatcherInterface $dispatcher): ?ContainerInterface
115+
{
116+
$originalContainer = null;
117+
if (ApplicationContext::hasContainer()) {
118+
$originalContainer = ApplicationContext::getContainer();
119+
}
120+
121+
$container = new readonly class ($dispatcher) implements ContainerInterface {
122+
public function __construct(private EventDispatcherInterface $dispatcher)
123+
{
124+
}
125+
126+
public function get(string $id): EventDispatcherInterface
127+
{
128+
return $this->dispatcher;
129+
}
130+
131+
public function has(string $id): bool
132+
{
133+
return $id === EventDispatcherInterface::class;
134+
}
135+
136+
public function make(string $name, array $parameters = []): EventDispatcherInterface
137+
{
138+
return $this->dispatcher;
139+
}
140+
};
141+
142+
ApplicationContext::setContainer($container);
143+
144+
return $originalContainer;
145+
}
146+
147+
private function restoreContainer(?ContainerInterface $container): void
148+
{
149+
if ($container !== null) {
150+
ApplicationContext::setContainer($container);
151+
}
152+
}
30153
}

0 commit comments

Comments
 (0)