From a4de1ec90b83fe8ace1aad1f73a18a4bb170e9cd Mon Sep 17 00:00:00 2001 From: Iosif Chiriluta Date: Wed, 8 Oct 2025 21:39:31 +0300 Subject: [PATCH 1/2] chore: Upgrade dependencies, environment, and modernize codebase - Upgrades PHP to 8.4 and the MongoDB extension in the Docker environment. - Updates major versions of key dependencies, including PHPStan, Symfony components, and the MongoDB library. - Modernizes the CI workflow by updating GitHub Actions. - Refactors the codebase to align with the new dependency versions, improving type safety and code style. - Adds GEMINI.md for AI agent context. --- .github/workflows/ci.yml | 10 +-- GEMINI.md | 55 +++++++++++++++ composer.json | 16 ++--- docker-compose.yaml | 3 - docker/Dockerfile | 6 +- phpstan.neon.dist | 3 - src/MongoTransport.php | 5 +- tests/Unit/MongoTransportTest.php | 112 +++++++++++++++++++++++------- 8 files changed, 160 insertions(+), 50 deletions(-) create mode 100644 GEMINI.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dcc202..a2e3b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,18 +9,18 @@ jobs: php-version: ['8.1', '8.2', '8.3', '8.4'] name: PHP ${{ matrix.php-version }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: coverage: pcov php-version: ${{ matrix.php-version }} tools: pecl, composer:v2 - extensions: mongodb-1.13 + extensions: mongodb - name: Composer config run: composer config --no-plugins allow-plugins.infection/extension-installer true - name: Composer install - run: composer install --no-interaction --no-progress --no-suggest + run: composer install --no-interaction --no-progress - name: PHPUnit run: ./vendor/bin/phpunit --coverage-clover coverage.xml - name: PHPStan @@ -30,7 +30,7 @@ jobs: env: INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} - name: Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml + files: ./coverage.xml diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..f980e9f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,55 @@ +# Project Overview + +This project is a Symfony Bundle that provides a MongoDB transport for the Symfony Messenger component. It allows developers to use MongoDB as a message queue for their Symfony applications. This is particularly useful when an application already uses MongoDB and wants to avoid adding another dependency for a message queue. + +The core of the bundle is the `MongoTransport` class, which implements the Symfony `TransportInterface`. This class handles the sending, receiving, acknowledging, and rejecting of messages using a MongoDB collection. + +The project uses Composer for dependency management, PHPUnit for testing, Infection for mutation testing, and PHPStan for static analysis. + +# Building and Running + +## Dependencies + +Install dependencies using Composer: + +```bash +composer install +``` + +## Running Tests + +Run the test suite using PHPUnit: + +```bash +vendor/bin/phpunit +``` + +## Static Analysis + +Run PHPStan for static analysis: + +```bash +vendor/bin/phpstan analyse src tests +``` + +## Mutation Testing + +Run Infection for mutation testing: + +```bash +vendor/bin/infection +``` + +# Development Conventions + +## Coding Style + +The project follows the PSR-12 coding style guide. + +## Testing + +The project has a comprehensive test suite using PHPUnit. Tests are located in the `tests` directory and are separated into unit and integration tests. + +## Contribution + +Contributions are welcome. Please open an issue or create a pull request on the [GitHub repository](https://github.com/eMAGTechLabs/messenger-mongo-bundle). diff --git a/composer.json b/composer.json index f3ffa68..77d4d58 100644 --- a/composer.json +++ b/composer.json @@ -19,18 +19,18 @@ "require": { "php": "^8.1", "ext-json": "*", - "ext-mongodb": "*", - "symfony/messenger": "^5.0 || ^6.0 || ^7.0", - "mongodb/mongodb": "^1.12" + "ext-mongodb": "^2.1", + "symfony/messenger": "^6.0 || ^7.0", + "mongodb/mongodb": "^2.1" }, "require-dev": { - "symfony/serializer": "^5.0 || ^6.0 || ^7.0", - "symfony/property-access": "^5.0 || ^6.0 || ^7.0", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0", - "symfony/framework-bundle": "^5.0 || ^6.0 || ^7.0", + "symfony/serializer": "^6.0 || ^7.0", + "symfony/property-access": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.0 || ^7.0", + "symfony/framework-bundle": "^6.0 || ^7.0", "phpunit/phpunit": "^10.5", "infection/infection": "^0.27.9", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.1" }, "autoload": { "psr-4": { diff --git a/docker-compose.yaml b/docker-compose.yaml index 2f99f29..dc9d86e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.6' services: php: build: @@ -14,5 +13,3 @@ services: environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=rootpass - - diff --git a/docker/Dockerfile b/docker/Dockerfile index b66450c..3f6073d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.3-alpine +FROM php:8.4-alpine WORKDIR /var/www/html @@ -7,7 +7,7 @@ RUN apk update \ && apk add --no-cache git zip make autoconf g++ openssl-dev linux-headers \ && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS -RUN pecl -q install mongodb-1.17.2 \ +RUN pecl -q install mongodb-2.1.4 \ && docker-php-ext-enable mongodb \ && pecl -q install xdebug \ && docker-php-ext-enable xdebug @@ -18,4 +18,4 @@ RUN curl --silent --show-error https://getcomposer.org/installer | php \ && mv composer.phar /usr/local/bin/composer \ && chmod o+x /usr/local/bin/composer -ENTRYPOINT ["tail", "-f", "/dev/null"] \ No newline at end of file +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1b2da08..46b0f47 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,8 +1,5 @@ parameters: level: 5 - checkMissingIterableValueType: false paths: - src - tests - ignoreErrors: - - '#Access to an undefined property MongoDB\\Collection::\$documents.#' diff --git a/src/MongoTransport.php b/src/MongoTransport.php index b2d0e7e..7163014 100644 --- a/src/MongoTransport.php +++ b/src/MongoTransport.php @@ -102,7 +102,6 @@ public function get(): iterable */ private function removeMessage(Envelope $envelope): void { - /** @var TransportMessageIdStamp $transportMessageIdStamp */ $transportMessageIdStamp = $envelope->last(TransportMessageIdStamp::class); if (!$transportMessageIdStamp instanceof TransportMessageIdStamp) { @@ -161,7 +160,7 @@ public function send(Envelope $envelope): Envelope return $envelope->with(new TransportMessageIdStamp($objectId)); } - public function all(int $limit = null): iterable + public function all(?int $limit = null): iterable { $documents = $this->collection->find([], ['limit' => $limit]); @@ -170,7 +169,7 @@ public function all(int $limit = null): iterable } } - public function find($id): ?Envelope + public function find(mixed $id): ?Envelope { $document = $this->collection->findOne(['_id' => is_string($id) ? new ObjectId($id) : $id]); diff --git a/tests/Unit/MongoTransportTest.php b/tests/Unit/MongoTransportTest.php index 7abf596..c1b75e3 100644 --- a/tests/Unit/MongoTransportTest.php +++ b/tests/Unit/MongoTransportTest.php @@ -6,11 +6,15 @@ use EmagTechLabs\MessengerMongoBundle\MongoTransport; use EmagTechLabs\MessengerMongoBundle\Tests\Unit\Fixtures\HelloMessage; +use MongoDB\BSON\Int64; use MongoDB\BSON\ObjectId; use MongoDB\Collection; +use MongoDB\Driver\CursorInterface; +use MongoDB\Driver\Server; use MongoDB\InsertOneResult; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Stamp\DelayStamp; use Symfony\Component\Messenger\Stamp\TransportMessageIdStamp; @@ -128,11 +132,11 @@ public function itShouldListAllMessages(): void $collection = $this->createMock(Collection::class); $collection->method('find') - ->willReturn([ + ->willReturn($this->createCursor([ $this->createDocument(), $this->createDocument(), $this->createDocument(), - ]); + ])); $transport = new MongoTransport( $collection, @@ -195,7 +199,24 @@ public function itShouldReturnNothingIfIdCouldNotBeFound(): void #[Test] public function itShouldSendAMessage(): void { - $collection = $this->createCollection(); + $collection = new class extends Collection { + public array $documents = []; + + public function __construct() + { + } + + public function insertOne($document, array $options = []): InsertOneResult + { + $this->documents[] = $document; + + return new class extends InsertOneResult { + public function __construct() + { + } + }; + } + }; $transport = new MongoTransport( $collection, @@ -252,28 +273,6 @@ public function itShouldDeleteTheDocumentOnAckOrReject(): void $transport->reject($envelope); } - private function createCollection(array $documents = []): Collection - { - return new class extends Collection { - public array $documents = []; - - public function __construct() - { - } - - public function insertOne($document, array $options = []): InsertOneResult - { - $this->documents[] = $document; - - return new class extends InsertOneResult { - public function __construct() - { - } - }; - } - }; - } - private function createDocument(): array { return [ @@ -290,4 +289,67 @@ private function createSerializer(): SerializerInterface { return new Serializer(); } + + private function createCursor(array $documents): CursorInterface + { + return new class($documents) implements CursorInterface + { + private array $data; + private int $position = 0; + + public function __construct(array $data) + { + $this->data = array_values($data); + } + + public function current(): array|null|object + { + return $this->data[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + $this->position++; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return array_key_exists($this->position, $this->data); + } + + public function toArray(): array + { + return $this->data; + } + + public function isDead(): bool + { + return false; + } + + public function setTypeMap(array $typemap): void + { + } + + public function getId(): Int64 + { + throw new RuntimeException('Not implemented in test cursor.'); + } + + public function getServer(): Server + { + throw new RuntimeException('Not implemented in test cursor.'); + } + }; + } } From 326b8e1706e3a62ef2358c73c01c5b0c6650b88d Mon Sep 17 00:00:00 2001 From: iosifch Date: Thu, 9 Oct 2025 12:48:49 +0300 Subject: [PATCH 2/2] test: Improve mutation score by strengthening tests - Refactored `itShouldSendAMessage` to be more specific and deterministic. - Added `itShouldSendAMessageWithoutDelay` to cover the case where no delay is applied. - These changes kill several escaped mutants in the `send` method and improve the overall mutation score. --- tests/Unit/MongoTransportTest.php | 82 ++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/tests/Unit/MongoTransportTest.php b/tests/Unit/MongoTransportTest.php index c1b75e3..db5dc67 100644 --- a/tests/Unit/MongoTransportTest.php +++ b/tests/Unit/MongoTransportTest.php @@ -48,7 +48,34 @@ public function itShouldFetchAndDecodeADocumentFromDb(): void $document = $this->createDocument(); $collection = $this->createMock(Collection::class); - $collection->method('findOneAndUpdate') + $collection->expects($this->once()) + ->method('findOneAndUpdate') + ->with( + $this->callback(function (array $filter) { + $this->assertArrayHasKey('$or', $filter); + $this->assertCount(2, $filter['$or']); + $this->assertEquals(['delivered_at' => null], $filter['$or'][0]); + $this->assertArrayHasKey('delivered_at', $filter['$or'][1]); + $this->assertArrayHasKey('$lt', $filter['$or'][1]['delivered_at']); + $this->assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $filter['$or'][1]['delivered_at']['$lt']); + + $this->assertEquals('default', $filter['queue_name']); + $this->assertArrayHasKey('$lte', $filter['available_at']); + return true; + }), + $this->callback(function (array $update) { + $this->assertArrayHasKey('$set', $update); + $this->assertEquals('consumer_id', $update['$set']['consumer_id']); + $this->assertArrayHasKey('delivered_at', $update['$set']); + return true; + }), + $this->callback(function (array $options) { + $this->assertEquals(['available_at' => 1], $options['sort']); + $this->assertEquals(\MongoDB\Operation\FindOneAndUpdate::RETURN_DOCUMENT_AFTER, $options['returnDocument']); + $this->assertInstanceOf(\MongoDB\Driver\WriteConcern::class, $options['writeConcern']); + return true; + }) + ) ->willReturn($document); $transport = new MongoTransport( @@ -197,7 +224,7 @@ public function itShouldReturnNothingIfIdCouldNotBeFound(): void } #[Test] - public function itShouldSendAMessage(): void + public function itShouldSendAMessageWithDelay(): void { $collection = new class extends Collection { public array $documents = []; @@ -222,15 +249,15 @@ public function __construct() $collection, $this->createSerializer(), 'consumer_id', - [ - 'queue' => 'default' - ] + ['queue' => 'default'] ); $envelope = $transport->send( (new Envelope(new HelloMessage('hello'))) ->with(new DelayStamp(4000)) ); + $this->assertArrayHasKey('_id', $collection->documents[0]); + $this->assertInstanceOf(ObjectId::class, $collection->documents[0]['_id']); $this->assertSame('{"text":"hello"}', $collection->documents[0]['body']); $this->assertEquals( [ @@ -242,12 +269,51 @@ public function __construct() ); $this->assertSame('default', $collection->documents[0]['queue_name']); $this->assertInstanceOf(TransportMessageIdStamp::class, $envelope->last(TransportMessageIdStamp::class)); - $this->assertSame( + $this->assertEqualsWithDelta( 4, $collection->documents[0]['available_at'] ->toDateTime() - ->diff($collection->documents[0]['created_at']->toDateTime()) - ->s + ->getTimestamp() - $collection->documents[0]['created_at']->toDateTime()->getTimestamp(), + 1 + ); + } + + #[Test] + public function itShouldSendAMessageWithoutDelay(): void + { + $collection = new class extends Collection { + public array $documents = []; + + public function __construct() + { + } + + public function insertOne($document, array $options = []): InsertOneResult + { + $this->documents[] = $document; + + return new class extends InsertOneResult { + public function __construct() + { + } + }; + } + }; + + $transport = new MongoTransport( + $collection, + $this->createSerializer(), + 'consumer_id', + ['queue' => 'default'] + ); + $transport->send(new Envelope(new HelloMessage('hello'))); + + $this->assertEqualsWithDelta( + 0, + $collection->documents[0]['available_at'] + ->toDateTime() + ->getTimestamp() - $collection->documents[0]['created_at']->toDateTime()->getTimestamp(), + 1 ); }