Skip to content

Commit 564527f

Browse files
authored
Refactor BotEventListener: extract methods and add loop tests (#4)
1 parent 8a71ded commit 564527f

2 files changed

Lines changed: 182 additions & 23 deletions

File tree

src/BotEventListener.php

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -153,29 +153,7 @@ public function listen(
153153
);
154154

155155
try {
156-
if ($eventType === EventTypeEnum::MessageNew) {
157-
$text = isset($event['payload']['text']) && is_string($event['payload']['text'])
158-
? $event['payload']['text']
159-
: '';
160-
161-
foreach ($this->commandHandlers as $command => $handler) {
162-
if (
163-
$text === $command
164-
|| str_starts_with($text, $command . ' ')
165-
|| str_starts_with($text, $command . '@')
166-
) {
167-
$handler($this->bot, $eventDto);
168-
169-
continue 2;
170-
}
171-
}
172-
}
173-
174-
if (array_key_exists($eventType->value, $this->handlers)) {
175-
foreach ($this->handlers[$eventType->value] as $handler) {
176-
$handler($this->bot, $eventDto);
177-
}
178-
}
156+
$this->handleEvent($eventDto);
179157
} catch (\Exception $e) {
180158
if ($onException === null) {
181159
throw $e;
@@ -187,6 +165,48 @@ public function listen(
187165
}
188166
}
189167

168+
private function handleEvent(
169+
EventDto $event,
170+
): void {
171+
if ($event->type === EventTypeEnum::MessageNew) {
172+
$text = isset($event->payload['text']) && is_string($event->payload['text'])
173+
? $event->payload['text']
174+
: '';
175+
176+
$commandHandler = $this->matchCommand($text);
177+
if ($commandHandler !== null) {
178+
$commandHandler($this->bot, $event);
179+
180+
return;
181+
}
182+
}
183+
184+
if (array_key_exists($event->type->value, $this->handlers)) {
185+
foreach ($this->handlers[$event->type->value] as $handler) {
186+
$handler($this->bot, $event);
187+
}
188+
}
189+
}
190+
191+
/**
192+
* @return (\Closure(Bot, EventDto): void)|null
193+
*/
194+
private function matchCommand(
195+
string $text,
196+
): ?\Closure {
197+
foreach ($this->commandHandlers as $command => $handler) {
198+
if (
199+
$text === $command
200+
|| str_starts_with($text, $command . ' ')
201+
|| str_starts_with($text, $command . '@')
202+
) {
203+
return $handler;
204+
}
205+
}
206+
207+
return null;
208+
}
209+
190210
/**
191211
* @param int $pollTime Maximum polling request duration (1-60 sec)
192212
* @return list<array{

test/Unit/BotEventListenerTest.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,145 @@ public function testExceptionBubblesWithoutOnExceptionCallback(): void
219219
$listener->listen(1);
220220
}
221221

222+
public function testStopFromHandlerStopsLoop(): void
223+
{
224+
$httpClient = $this->createMock(HttpClient::class);
225+
$bot = new Bot($httpClient);
226+
$listener = new BotEventListener($bot);
227+
228+
$pollCount = 0;
229+
$httpClient
230+
->method('get')
231+
->willReturnCallback(function () use (&$pollCount, $listener) {
232+
$pollCount++;
233+
return [
234+
'events' => [
235+
[
236+
'eventId' => $pollCount,
237+
'type' => 'newMessage',
238+
'payload' => ['text' => 'msg'],
239+
],
240+
],
241+
];
242+
});
243+
244+
$listener->onMessage(function (Bot $bot, EventDto $event) use ($listener) {
245+
if ($event->eventId === 2) {
246+
$listener->stop();
247+
}
248+
});
249+
250+
$listener->listen(1);
251+
252+
$this->assertSame(2, $pollCount, 'Loop should stop after handler calls stop()');
253+
}
254+
255+
public function testUnknownEventTypeIsSkipped(): void
256+
{
257+
$httpClient = $this->createMock(HttpClient::class);
258+
$bot = new Bot($httpClient);
259+
$listener = new BotEventListener($bot);
260+
261+
$callCount = 0;
262+
$this->mockEventsGet($httpClient, $listener, $callCount, [
263+
'events' => [
264+
[
265+
'eventId' => 1,
266+
'type' => 'unknownEventType',
267+
'payload' => ['text' => 'hello'],
268+
],
269+
[
270+
'eventId' => 2,
271+
'type' => 'newMessage',
272+
'payload' => ['text' => 'world'],
273+
],
274+
],
275+
]);
276+
277+
$receivedEvents = [];
278+
$listener->onMessage(function (Bot $bot, EventDto $event) use (&$receivedEvents) {
279+
$receivedEvents[] = $event->eventId;
280+
});
281+
282+
$listener->listen(1);
283+
284+
$this->assertSame([2], $receivedEvents, 'Unknown event should be skipped, known event processed');
285+
}
286+
287+
public function testLastEventIdIsPassedToNextPoll(): void
288+
{
289+
$httpClient = $this->createMock(HttpClient::class);
290+
$bot = new Bot($httpClient);
291+
$listener = new BotEventListener($bot);
292+
293+
$receivedLastEventIds = [];
294+
$pollCount = 0;
295+
$httpClient
296+
->method('get')
297+
->willReturnCallback(function (string $path, array $params) use (&$receivedLastEventIds, &$pollCount, $listener) {
298+
$receivedLastEventIds[] = $params['lastEventId'];
299+
$pollCount++;
300+
if ($pollCount === 1) {
301+
return [
302+
'events' => [
303+
['eventId' => 42, 'type' => 'newMessage', 'payload' => ['text' => 'hi']],
304+
],
305+
];
306+
}
307+
$listener->stop();
308+
return ['events' => []];
309+
});
310+
311+
$listener->onMessage(function () {});
312+
$listener->listen(1);
313+
314+
$this->assertSame(0, $receivedLastEventIds[0], 'First poll should use lastEventId=0');
315+
$this->assertSame(42, $receivedLastEventIds[1], 'Second poll should use lastEventId from previous batch');
316+
}
317+
318+
#[\PHPUnit\Framework\Attributes\DataProvider('signalProvider')]
319+
public function testStopOnSignal(int $signal): void
320+
{
321+
if (!function_exists('pcntl_signal')) {
322+
$this->markTestSkipped('pcntl extension required');
323+
}
324+
325+
$httpClient = $this->createMock(HttpClient::class);
326+
$bot = new Bot($httpClient);
327+
$listener = new BotEventListener($bot);
328+
329+
$pollCount = 0;
330+
$httpClient
331+
->method('get')
332+
->willReturnCallback(function () use (&$pollCount, $signal) {
333+
$pollCount++;
334+
if ($pollCount === 1) {
335+
posix_kill(posix_getpid(), $signal);
336+
return [
337+
'events' => [
338+
['eventId' => 1, 'type' => 'newMessage', 'payload' => ['text' => 'hi']],
339+
],
340+
];
341+
}
342+
$this->fail('Loop should have stopped after signal');
343+
return ['events' => []];
344+
});
345+
346+
$listener->onMessage(function () {});
347+
$listener->listen(1);
348+
349+
$this->assertSame(1, $pollCount, 'Loop should stop after signal');
350+
}
351+
352+
/**
353+
* @return iterable<string, array{int}>
354+
*/
355+
public static function signalProvider(): iterable
356+
{
357+
yield 'SIGTERM' => [SIGTERM];
358+
yield 'SIGINT' => [SIGINT];
359+
}
360+
222361
/**
223362
* Configure HttpClient mock to return $eventsBatch on first call,
224363
* then empty events + stop() on subsequent calls.

0 commit comments

Comments
 (0)