diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 3eb01b6..fb293b8 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -27,6 +27,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: "${{ matrix.php }}" + extensions: swoole coverage: pcov ini-values: assert.exception=1, zend.assertions=1, error_reporting=-1, log_errors_max_len=0, display_errors=On tools: composer:v2, cs2pr diff --git a/.laminas-ci/pre-run.sh b/.laminas-ci/pre-run.sh new file mode 100755 index 0000000..b7ba4af --- /dev/null +++ b/.laminas-ci/pre-run.sh @@ -0,0 +1,5 @@ +JOB=$3 +PHP_VERSION=$(echo "${JOB}" | jq -r '.php') + +apt update +apt install -y "php${PHP_VERSION}-swoole" diff --git a/composer.json b/composer.json index 61b8d47..cc845be 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,8 @@ }, "autoload-dev": { "psr-4": { - "QueueTest\\Swoole\\": "test/Swoole" + "QueueTest\\App\\": "test/App/", + "QueueTest\\Swoole\\": "test/Swoole/" } }, "scripts": { diff --git a/src/Swoole/Command/GetFailedMessagesCommand.php b/src/Swoole/Command/GetFailedMessagesCommand.php index 21fab0a..83da758 100644 --- a/src/Swoole/Command/GetFailedMessagesCommand.php +++ b/src/Swoole/Command/GetFailedMessagesCommand.php @@ -28,7 +28,8 @@ )] class GetFailedMessagesCommand extends Command { - protected static string $defaultName = 'failed'; + /** @var string $defaultName */ + protected static $defaultName = 'failed'; #[Inject()] public function __construct() diff --git a/src/Swoole/Command/GetProcessedMessagesCommand.php b/src/Swoole/Command/GetProcessedMessagesCommand.php index 9732c66..846dfe7 100644 --- a/src/Swoole/Command/GetProcessedMessagesCommand.php +++ b/src/Swoole/Command/GetProcessedMessagesCommand.php @@ -28,7 +28,8 @@ )] class GetProcessedMessagesCommand extends Command { - protected static string $defaultName = 'processed'; + /** @var string $defaultName */ + protected static $defaultName = 'processed'; #[Inject()] public function __construct() diff --git a/src/Swoole/Command/GetQueuedMessagesCommand.php b/src/Swoole/Command/GetQueuedMessagesCommand.php index a40227d..ed039cd 100644 --- a/src/Swoole/Command/GetQueuedMessagesCommand.php +++ b/src/Swoole/Command/GetQueuedMessagesCommand.php @@ -25,7 +25,8 @@ )] class GetQueuedMessagesCommand extends Command { - protected static string $defaultName = 'inventory'; + /** @var string $defaultName */ + protected static $defaultName = 'inventory'; private Redis $redis; diff --git a/test/App/AppConfigProviderTest.php b/test/App/AppConfigProviderTest.php new file mode 100644 index 0000000..62590b6 --- /dev/null +++ b/test/App/AppConfigProviderTest.php @@ -0,0 +1,23 @@ +config = (new ConfigProvider())(); + } + + public function testHasDependencies(): void + { + $this->assertArrayHasKey('dependencies', $this->config); + } +} diff --git a/test/App/Message/MessageHandlerTest.php b/test/App/Message/MessageHandlerTest.php new file mode 100644 index 0000000..9e50151 --- /dev/null +++ b/test/App/Message/MessageHandlerTest.php @@ -0,0 +1,153 @@ +bus = $this->createMock(MessageBusInterface::class); + $this->logger = new Logger([ + 'writers' => [ + 'FileWriter' => [ + 'name' => 'null', + 'level' => Logger::ALERT, + ], + ], + ]); + $this->config = [ + 'fail-safe' => [ + 'first_retry' => 1000, + 'second_retry' => 2000, + 'third_retry' => 3000, + ], + 'notification' => [ + 'server' => [ + 'protocol' => 'tcp', + 'host' => 'localhost', + 'port' => '8556', + 'eof' => "\n", + ], + ], + 'application' => [ + 'name' => 'dotkernel', + ], + ]; + + $this->handler = new MessageHandler($this->bus, $this->logger, $this->config); + } + + /** + * @throws Exception + */ + public function testInvokeSuccessfulProcessing(): void + { + $payload = ['foo' => 'control']; + $message = $this->createMock(Message::class); + $message->method('getPayload')->willReturn($payload); + + $this->handler->__invoke($message); + + $this->expectNotToPerformAssertions(); + } + + /** + * @throws Exception + */ + public function testInvokeFailureTriggersFirstRetry(): void + { + $payload = ['foo' => 'fail']; + $message = $this->createMock(Message::class); + $message->method('getPayload')->willReturn($payload); + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with( + $this->callback(function ($msg) { + return $msg instanceof Message + && $msg->getPayload()['foo'] === 'fail' + && $msg->getPayload()['retry'] === 1; + }), + $this->callback(function ($stamps) { + return isset($stamps[0]) && $stamps[0] instanceof DelayStamp + && $stamps[0]->getDelay() === 1000; + }) + ) + ->willReturn(new Envelope($message)); + + $this->handler->__invoke($message); + } + + /** + * @throws ExceptionInterface + */ + public function testRetrySecondTime(): void + { + $payload = ['foo' => 'retry_test', 'retry' => 1]; + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with( + $this->callback(function ($msg) { + return $msg instanceof Message + && $msg->getPayload()['retry'] === 2 + && $msg->getPayload()['foo'] === 'retry_test'; + }), + $this->callback(function ($stamps) { + return isset($stamps[0]) && $stamps[0] instanceof DelayStamp + && $stamps[0]->getDelay() === 2000; + }) + ) + ->willReturn(new Envelope(new Message($payload))); + + $this->handler->retry($payload); + } + + /** + * @throws ExceptionInterface + */ + public function testRetryThirdTime(): void + { + $payload = ['foo' => 'retry_test', 'retry' => 2]; + + $this->bus->expects($this->once()) + ->method('dispatch') + ->with( + $this->callback(function ($msg) { + return $msg instanceof Message + && $msg->getPayload()['retry'] === 3 + && $msg->getPayload()['foo'] === 'retry_test'; + }), + $this->callback(function ($stamps) { + return isset($stamps[0]) && $stamps[0] instanceof DelayStamp + && $stamps[0]->getDelay() === 3000; + }) + ) + ->willReturn(new Envelope(new Message($payload))); + + $this->handler->retry($payload); + } +} diff --git a/test/App/Message/MessageTest.php b/test/App/Message/MessageTest.php new file mode 100644 index 0000000..bf98557 --- /dev/null +++ b/test/App/Message/MessageTest.php @@ -0,0 +1,17 @@ + "test message payload"]); + $this->assertSame(["payload" => "test message payload"], $admin->getPayload()); + } +} diff --git a/test/Swoole/Command/Factory/StartCommandFactoryTest.php b/test/Swoole/Command/Factory/StartCommandFactoryTest.php new file mode 100644 index 0000000..40e63a0 --- /dev/null +++ b/test/Swoole/Command/Factory/StartCommandFactoryTest.php @@ -0,0 +1,27 @@ +createMock(ContainerInterface::class); + + $factory = new StartCommandFactory(); + $command = $factory($container); + + $this->assertContainsOnlyInstancesOf(StartCommand::class, [$command]); + } +} diff --git a/test/Swoole/Command/Factory/StopCommandFactoryTest.php b/test/Swoole/Command/Factory/StopCommandFactoryTest.php new file mode 100644 index 0000000..a2ca055 --- /dev/null +++ b/test/Swoole/Command/Factory/StopCommandFactoryTest.php @@ -0,0 +1,35 @@ +createMock(PidManager::class); + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once()) + ->method('get') + ->with(PidManager::class) + ->willReturn($pidManager); + + $factory = new StopCommandFactory(); + + $command = $factory($container); + + $this->assertContainsOnlyInstancesOf(StopCommand::class, [$command]); + } +} diff --git a/test/Swoole/Command/GetDataFromLogsCommandTest.php b/test/Swoole/Command/GetDataFromLogsCommandTest.php new file mode 100644 index 0000000..92803d2 --- /dev/null +++ b/test/Swoole/Command/GetDataFromLogsCommandTest.php @@ -0,0 +1,202 @@ +logDir = dirname(__DIR__, 3) . '/log'; + $this->logPath = $this->logDir . '/queue-log.log'; + if (! is_dir($this->logDir)) { + mkdir($this->logDir, 0777, true); + } + file_put_contents($this->logPath, ''); + } + + protected function tearDown(): void + { + if (file_exists($this->logPath)) { + unlink($this->logPath); + } + } + + /** + * @dataProvider commandProvider + */ + public function testInvalidDateFormat(string $commandClass): void + { + $command = new $commandClass(); + $input = new ArrayInput(['--start' => 'not-a-date']); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::FAILURE, $exit); + $this->assertStringContainsString('Invalid date format', $output->fetch()); + } + + /** + * @dataProvider commandProvider + */ + public function testStartAfterEnd(string $commandClass): void + { + $command = new $commandClass(); + $input = new ArrayInput([ + '--start' => '2024-01-02 00:00:00', + '--end' => '2024-01-01 00:00:00', + ]); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::FAILURE, $exit); + $this->assertStringContainsString('start date cannot be after the end date', $output->fetch()); + } + + /** + * @dataProvider commandProvider + */ + public function testMissingLogFile(string $commandClass): void + { + unlink($this->logPath); + + $command = new $commandClass(); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::FAILURE, $exit); + $this->assertStringContainsString('Log file not found', $output->fetch()); + } + + /** + * @dataProvider commandProvider + */ + public function testNoMatchingEntries(string $commandClass): void + { + file_put_contents($this->logPath, json_encode([ + 'levelName' => 'debug', + 'timestamp' => '2024-01-01 12:00:00', + ]) . PHP_EOL); + + $command = new $commandClass(); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $exit); + $this->assertStringContainsString('No matching log entries found', $output->fetch()); + } + + /** + * @dataProvider commandProvider + */ + public function testMalformedLogLineIgnored(string $commandClass): void + { + file_put_contents($this->logPath, "not-a-json\n"); + + $command = new $commandClass(); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $exit); + $this->assertStringContainsString('No matching log entries found', $output->fetch()); + } + + /** + * @dataProvider levelProvider + */ + public function testMatchEntryOutput(string $commandClass, string $expectedLevel): void + { + $line = json_encode([ + 'levelName' => $expectedLevel, + 'timestamp' => (new \DateTime())->format('Y-m-d H:i:s'), + 'message' => 'Message here', + ]); + file_put_contents($this->logPath, $line . PHP_EOL); + + $command = new $commandClass(); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $exit); + $this->assertStringContainsString('Message here', $output->fetch()); + } + + /** + * @throws ExceptionInterface + * @throws \DateMalformedStringException + */ + public function testLimitAddsDaysToStartDateOnly(): void + { + $start = '2024-01-01 00:00:00'; + $limit = 5; + + $command = new GetProcessedMessagesCommand(); + $input = new ArrayInput([ + '--start' => $start, + '--limit' => $limit, + ]); + + $output = new BufferedOutput(); + $logDate = (new DateTimeImmutable($start))->modify("+{$limit} days")->format('Y-m-d H:i:s'); + + file_put_contents($this->logPath, json_encode([ + 'levelName' => 'info', + 'timestamp' => $logDate, + 'message' => 'Auto-inferred end', + ]) . PHP_EOL); + + $exit = $command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $exit); + $this->assertStringContainsString('Auto-inferred end', $output->fetch()); + } + + public static function commandProvider(): array + { + return [ + [GetProcessedMessagesCommand::class], + [GetFailedMessagesCommand::class], + ]; + } + + public static function levelProvider(): array + { + return [ + [GetProcessedMessagesCommand::class, 'info'], + [GetFailedMessagesCommand::class, 'error'], + ]; + } +} diff --git a/test/Swoole/Command/GetQueuedMessagesCommandTest.php b/test/Swoole/Command/GetQueuedMessagesCommandTest.php new file mode 100644 index 0000000..a8edf4b --- /dev/null +++ b/test/Swoole/Command/GetQueuedMessagesCommandTest.php @@ -0,0 +1,103 @@ +redisMock = $this->createMock(Redis::class); + } + + public function testExecuteWithNoMessages(): void + { + $this->redisMock + ->expects($this->once()) + ->method('xRange') + ->with('messages', '-', '+') + ->willReturn([]); + + $command = new GetQueuedMessagesCommand($this->redisMock); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + $outputText = $output->fetch(); + + $this->assertEquals(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('No messages queued found', $outputText); + } + + /** + * @throws ExceptionInterface + */ + public function testExecuteWithMessages(): void + { + $fakeMessages = [ + '1691000000000-0' => ['type' => 'testEmail', 'payload' => '{"to":"test@dotkernel.com"}'], + '1691000000001-0' => ['type' => 'testSms', 'payload' => '{"to":"+123456789"}'], + ]; + + $this->redisMock + ->expects($this->once()) + ->method('xRange') + ->with('messages', '-', '+') + ->willReturn($fakeMessages); + + $command = new GetQueuedMessagesCommand($this->redisMock); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + $outputText = $output->fetch(); + + $this->assertEquals(Command::SUCCESS, $exitCode); + + foreach (array_keys($fakeMessages) as $id) { + $this->assertStringContainsString("Message ID:", $outputText); + $this->assertStringContainsString($id, $outputText); + } + + $this->assertStringContainsString('Total queued messages in stream', $outputText); + $this->assertStringContainsString((string) count($fakeMessages), $outputText); + } + + /** + * @throws ExceptionInterface + */ + public function testRedisThrowsException(): void + { + $this->redisMock + ->expects($this->once()) + ->method('xRange') + ->willThrowException(new RedisException("Redis unavailable")); + + $command = new GetQueuedMessagesCommand($this->redisMock); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $this->expectException(RedisException::class); + $command->run($input, $output); + } +} diff --git a/test/Swoole/Command/IsRunningTraitTest.php b/test/Swoole/Command/IsRunningTraitTest.php new file mode 100644 index 0000000..ef59e0d --- /dev/null +++ b/test/Swoole/Command/IsRunningTraitTest.php @@ -0,0 +1,41 @@ +traitUser = new class { + use IsRunningTrait; + + public PidManager $pidManager; + }; + + $this->traitUser->pidManager = $this->createMock(PidManager::class); + } + + public function testIsRunningReturnsFalseWhenNoPids(): void + { + $this->traitUser->pidManager->method('read')->willReturn([]); + $this->assertFalse($this->traitUser->isRunning()); + } + + public function testIsRunningReturnsFalseWhenPidsAreZero(): void + { + $this->traitUser->pidManager->method('read')->willReturn([0, 0]); + $this->assertFalse($this->traitUser->isRunning()); + } +} diff --git a/test/Swoole/Command/StartCommandTest.php b/test/Swoole/Command/StartCommandTest.php new file mode 100644 index 0000000..246976b --- /dev/null +++ b/test/Swoole/Command/StartCommandTest.php @@ -0,0 +1,92 @@ +createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $pidManager = $this->createMock(PidManager::class); + $server = $this->createMock(Server::class); + + $pidManager->method('read')->willReturn([]); + + $server->master_pid = 1234; + $server->manager_pid = 4321; + + $server->expects($this->once())->method('on'); + $server->expects($this->once())->method('start'); + + $config = [ + 'dotkernel-queue-swoole' => [ + 'swoole-server' => [ + 'process-name' => 'test-process', + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback(function (string $id) use ($pidManager, $server, $config) { + return match ($id) { + PidManager::class => $pidManager, + Server::class => $server, + 'config' => $config, + default => null, + }; + }); + + $command = new StartCommand($container); + $statusCode = $command->run($input, $output); + + $this->assertSame(0, $statusCode); + } + + /** + * @throws ExceptionInterface + * @throws Exception + */ + public function testExecuteWhenServerIsAlreadyRunning(): void + { + $container = $this->createMock(ContainerInterface::class); + $pidManager = $this->createMock(PidManager::class); + $container->method('get') + ->with(PidManager::class) + ->willReturn($pidManager); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $output->expects($this->once()) + ->method('writeln') + ->with('Server is already running!'); + + $command = $this->getMockBuilder(StartCommand::class) + ->setConstructorArgs([$container]) + ->onlyMethods(['isRunning']) + ->getMock(); + + $command->method('isRunning')->willReturn(true); + + $exitCode = $command->run($input, $output); + + $this->assertSame(1, $exitCode); + } +} diff --git a/test/Swoole/Command/StopCommandTest.php b/test/Swoole/Command/StopCommandTest.php new file mode 100644 index 0000000..dbdcca2 --- /dev/null +++ b/test/Swoole/Command/StopCommandTest.php @@ -0,0 +1,90 @@ +createMock(PidManager::class); + + $command = $this->getMockBuilder(StopCommand::class) + ->setConstructorArgs([$pidManager]) + ->onlyMethods(['isRunning']) + ->getMock(); + + $command->method('isRunning')->willReturn(false); + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('Server is not running', $tester->getDisplay()); + } + + /** + * @throws Exception + */ + public function testExecuteWhenServerStopsSuccessfully(): void + { + $pidManager = $this->createMock(PidManager::class); + $pidManager->method('read')->willReturn(['1234']); + $pidManager->expects($this->once())->method('delete'); + + $command = $this->getMockBuilder(StopCommand::class) + ->setConstructorArgs([$pidManager]) + ->onlyMethods(['isRunning']) + ->getMock(); + + $command->method('isRunning')->willReturn(true); + + $command->killProcess = function (int $pid, ?int $signal = null): bool { + return true; + }; + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('Server stopped', $tester->getDisplay()); + } + + /** + * @throws Exception + */ + public function testExecuteWhenServerFailsToStop(): void + { + $pidManager = $this->createMock(PidManager::class); + $pidManager->method('read')->willReturn(['1234']); + $pidManager->expects($this->never())->method('delete'); + + $command = $this->getMockBuilder(StopCommand::class) + ->setConstructorArgs([$pidManager]) + ->onlyMethods(['isRunning']) + ->getMock(); + + $command->method('isRunning')->willReturn(true); + $command->waitThreshold = 1; + + $command->killProcess = function (int $pid, ?int $signal = null): bool { + return $signal === 0; + }; + + $tester = new CommandTester($command); + $exitCode = $tester->execute([]); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Error stopping server', $tester->getDisplay()); + } +} diff --git a/test/Swoole/Delegators/DummySwooleServer.php b/test/Swoole/Delegators/DummySwooleServer.php new file mode 100644 index 0000000..d864bb9 --- /dev/null +++ b/test/Swoole/Delegators/DummySwooleServer.php @@ -0,0 +1,37 @@ + */ + public array $callbacks = []; + + public function __construct() + { + } + + /** + * @param string $eventName + * @param callable $callback + */ + public function on($eventName, $callback): bool + { + $this->callbacks[$eventName] = $callback; + return true; + } + + /** + * @param int|string $fd + * @param string $data + * @param int $serverSocket + */ + public function send($fd, $data, $serverSocket = -1): bool + { + return true; + } +} diff --git a/test/Swoole/Delegators/TCPServerDelegatorTest.php b/test/Swoole/Delegators/TCPServerDelegatorTest.php new file mode 100644 index 0000000..dd90db2 --- /dev/null +++ b/test/Swoole/Delegators/TCPServerDelegatorTest.php @@ -0,0 +1,230 @@ +logger = new Logger([ + 'writers' => [ + 'FileWriter' => [ + 'name' => 'null', + 'level' => Logger::ALERT, + ], + ], + ]); + + $this->bus = $this->createMock(MessageBusInterface::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->server = new DummySwooleServer(); + } + + public function testCallbacksAreRegistered(): void + { + $callback = fn() => $this->server; + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + ]); + + $delegator = new TCPServerDelegator(); + $result = $delegator($this->container, 'tcp-server', $callback); + + $this->assertSame($this->server, $result); + $this->assertArrayHasKey('Connect', $this->server->callbacks); + $this->assertArrayHasKey('receive', $this->server->callbacks); + $this->assertArrayHasKey('Close', $this->server->callbacks); + + foreach (['Connect', 'receive', 'Close'] as $event) { + $this->assertIsCallable($this->server->callbacks[$event]); + } + } + + public function testConnectOutputsExpectedString(): void + { + $callback = fn() => $this->server; + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + ]); + + $delegator = new TCPServerDelegator(); + $delegator($this->container, 'tcp-server', $callback); + + $this->expectOutputString("Client: Connect.\n"); + + $connectCb = $this->server->callbacks['Connect']; + $connectCb($this->server, 1); + } + + public function testCloseOutputsExpectedString(): void + { + $callback = fn() => $this->server; + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + ]); + + $delegator = new TCPServerDelegator(); + $delegator($this->container, 'tcp-server', $callback); + + $this->expectOutputString("Client: Close.\n"); + + $closeCb = $this->server->callbacks['Close']; + $closeCb($this->server, 1); + } + + public function testReceiveDispatchesMessagesAndLogsWhenUnknownCommand(): void + { + $callback = fn() => $this->server; + + $this->bus->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(function ($message) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + $this->assertInstanceOf(Message::class, $message); + $this->assertEquals('hello', $message->getPayload()['foo']); + } elseif ($callCount === 2) { + $this->assertInstanceOf(Message::class, $message); + $this->assertEquals('with 5 seconds delay', $message->getPayload()['foo']); + } else { + $this->fail('dispatch called more than twice'); + } + + return new Envelope($message); + }); + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + ]); + + $delegator = new TCPServerDelegator(); + $delegator($this->container, 'tcp-server', $callback); + + $receiveCb = $this->server->callbacks['receive']; + + $receiveCb($this->server, 42, 5, "hello"); + } + + public function testReceiveExecutesKnownCommandSuccessfully(): void + { + $callback = fn() => $this->server; + + $commandMock = $this->getMockBuilder(GetProcessedMessagesCommand::class) + ->onlyMethods(['execute']) + ->getMock(); + + $commandMock->method('execute')->willReturnCallback(function ($input, $output) { + $output->writeln('processed output text'); + return 0; + }); + + $this->server = new class extends DummySwooleServer { + public ?string $sentData = null; + + /** + * @param int $fd + * @param string $data + * @param int $serverSocket + */ + public function send($fd, $data, $serverSocket = -1): bool + { + $this->sentData = $data; + return true; + } + }; + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + [GetProcessedMessagesCommand::class, $commandMock], + ]); + + $delegator = new TCPServerDelegator(); + $delegator($this->container, 'tcp-server', $callback); + + $receiveCb = $this->server->callbacks['receive']; + + $receiveCb($this->server, 1, 1, "processed"); + + $this->assertNotNull($this->server->sentData); + $this->assertStringContainsString('processed output text', $this->server->sentData); + } + + public function testReceiveParsesKnownOptions(): void + { + $callback = fn() => $this->server; + + $sentData = null; + $this->server = new class extends DummySwooleServer { + public ?string $sentData = null; + + /** + * @param int $fd + * @param string $data + * @param int $serverSocket + */ + public function send($fd, $data, $serverSocket = -1): bool + { + $this->sentData = $data; + return true; + } + }; + + $commandMock = $this->getMockBuilder(GetProcessedMessagesCommand::class) + ->onlyMethods(['execute']) + ->getMock(); + + $commandMock->method('execute')->willReturnCallback(function ($input, $output) { + $output->writeln('processed output text with known options'); + return 0; + }); + + $this->container->method('get')->willReturnMap([ + [MessageBusInterface::class, $this->bus], + ['dot-log.queue-log', $this->logger], + [GetProcessedMessagesCommand::class, $commandMock], + ]); + + $delegator = new TCPServerDelegator(); + $delegator($this->container, 'tcp-server', $callback); + + $receiveCb = $this->server->callbacks['receive']; + + $receiveCb($this->server, 1, 1, "processed --start=1 --end=5"); + + $this->assertNotNull($this->server->sentData); + $this->assertStringContainsString('processed output text with known options', $this->server->sentData); + } +} diff --git a/test/Swoole/Exception/InvalidStaticResourceMiddlewareExceptionTest.php b/test/Swoole/Exception/InvalidStaticResourceMiddlewareExceptionTest.php new file mode 100644 index 0000000..461edda --- /dev/null +++ b/test/Swoole/Exception/InvalidStaticResourceMiddlewareExceptionTest.php @@ -0,0 +1,32 @@ +assertContainsOnlyInstancesOf(InvalidStaticResourceMiddlewareException::class, [$exception]); + + $expectedMessage = sprintf( + 'Static resource middleware must be callable; received middleware of type "%s" in position %s', + get_debug_type($middleware), + $position + ); + + $this->assertSame($expectedMessage, $exception->getMessage()); + } +} diff --git a/test/Swoole/PidManagerFactoryTest.php b/test/Swoole/PidManagerFactoryTest.php new file mode 100644 index 0000000..b5e52db --- /dev/null +++ b/test/Swoole/PidManagerFactoryTest.php @@ -0,0 +1,54 @@ + [ + 'swoole-tcp-server' => [ + 'options' => [ + 'pid_file' => $expectedPath, + ], + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->with('config') + ->willReturn($config); + + $factory = new PidManagerFactory(); + $pidManager = $factory($container); + + $pidFilePath = $this->getPrivateProperty($pidManager); + $this->assertSame($expectedPath, $pidFilePath); + } + + /** + * @throws ReflectionException + */ + private function getPrivateProperty(object $object): mixed + { + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty('pidFile'); + return $property->getValue($object); + } +} diff --git a/test/Swoole/PidManagerTest.php b/test/Swoole/PidManagerTest.php new file mode 100644 index 0000000..f6c22e4 --- /dev/null +++ b/test/Swoole/PidManagerTest.php @@ -0,0 +1,92 @@ +tempPidFile = sys_get_temp_dir() . '/test.pid'; + if (file_exists($this->tempPidFile)) { + unlink($this->tempPidFile); + } + } + + protected function tearDown(): void + { + if (file_exists($this->tempPidFile)) { + unlink($this->tempPidFile); + } + } + + public function testWriteAndReadPids(): void + { + $manager = new PidManager($this->tempPidFile); + + $manager->write(12345, 67890); + + $result = $manager->read(); + + $this->assertSame(['12345', '67890'], $result); + $this->assertFileExists($this->tempPidFile); + } + + public function testDeleteRemovesPidFile(): void + { + file_put_contents($this->tempPidFile, 'dummyData'); + + $manager = new PidManager($this->tempPidFile); + $deleted = $manager->delete(); + + $this->assertTrue($deleted); + $this->assertFileDoesNotExist($this->tempPidFile); + } + + public function testDeleteReturnsFalseIfFileNotWritable(): void + { + file_put_contents($this->tempPidFile, 'dummyData'); + chmod($this->tempPidFile, 0444); + + $manager = new PidManager($this->tempPidFile); + $result = $manager->delete(); + + $this->assertFalse($result); + + chmod($this->tempPidFile, 0644); + } + + public function testWriteThrowsWhenFileNotWritable(): void + { + $unwritableDir = sys_get_temp_dir() . '/unwritable_dir'; + mkdir($unwritableDir, 0444); + $unwritableFile = $unwritableDir . '/file.pid'; + + $manager = new PidManager($unwritableFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not writable/'); + + try { + $manager->write(1, 2); + } finally { + chmod($unwritableDir, 0755); + rmdir($unwritableDir); + } + } +} diff --git a/test/Swoole/ServerFactoryTest.php b/test/Swoole/ServerFactoryTest.php new file mode 100644 index 0000000..a1346f5 --- /dev/null +++ b/test/Swoole/ServerFactoryTest.php @@ -0,0 +1,147 @@ +factory = new ServerFactory(); + } + + /** + * @throws Exception|ErrorException + */ + #[RunInSeparateProcess] + public function testInvokeWithMinimalValidConfig(): void + { + $config = [ + 'dotkernel-queue-swoole' => [ + 'swoole-tcp-server' => [], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with('config')->willReturn($config); + + $server = $this->factory->__invoke($container); + + $this->assertContainsOnlyInstancesOf(Server::class, [$server]); + } + + /** + * @throws Exception + * @throws ErrorException + */ + #[RunInSeparateProcess] + public function testInvokeWithCustomValidConfig(): void + { + $config = [ + 'dotkernel-queue-swoole' => [ + 'enable_coroutine' => true, + 'swoole-tcp-server' => [ + 'host' => '127.0.0.1', + 'port' => 9502, + 'mode' => SWOOLE_BASE, + 'protocol' => SWOOLE_SOCK_TCP, + 'options' => [ + 'worker_num' => 1, + ], + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with('config')->willReturn($config); + + $server = $this->factory->__invoke($container); + + $this->assertContainsOnlyInstancesOf(Server::class, [$server]); + } + + /** + * @throws Exception + * @throws ErrorException + */ + public function testThrowsOnInvalidPort(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid port'); + + $config = [ + 'dotkernel-queue-swoole' => [ + 'swoole-tcp-server' => [ + 'port' => 70000, + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with('config')->willReturn($config); + + $this->factory->__invoke($container); + } + + /** + * @throws Exception|ErrorException + */ + public function testThrowsOnInvalidMode(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid server mode'); + + $config = [ + 'dotkernel-queue-swoole' => [ + 'swoole-tcp-server' => [ + 'mode' => -1, + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with('config')->willReturn($config); + + $this->factory->__invoke($container); + } + + /** + * @throws Exception + * @throws ErrorException + */ + public function testThrowsOnInvalidProtocol(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid server protocol'); + + $config = [ + 'dotkernel-queue-swoole' => [ + 'swoole-tcp-server' => [ + 'protocol' => -99, + ], + ], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with('config')->willReturn($config); + + $this->factory->__invoke($container); + } +}