From daf19a4e5cd561e7cfd524b4efb0ba4872cde02a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 13 Apr 2026 21:44:45 -0300 Subject: [PATCH] feat: implement container reaper and shutdown hook functionality. --- composer.json | 14 +- infection.json.dist | 24 +++ src/DockerContainer.php | 3 + src/FlywayDockerContainer.php | 20 +-- src/GenericDockerContainer.php | 53 ++++--- .../ContainerCommandHandler.php | 5 +- src/Internal/Commands/DockerList.php | 2 +- src/Internal/Commands/DockerReaper.php | 49 ++++++ src/Internal/Containers/ContainerLookup.php | 3 +- src/Internal/Containers/ContainerReaper.php | 41 +++++ src/Internal/Containers/Reused.php | 57 +++++++ src/Internal/Containers/ShutdownHook.php | 13 ++ src/Internal/Containers/Started.php | 13 +- src/Waits/ContainerWaitForDependency.php | 11 +- tests/Unit/FlywayDockerContainerTest.php | 39 +++-- tests/Unit/GenericDockerContainerTest.php | 80 +++++++++- .../Containers/ContainerReaperTest.php | 30 ++++ .../Overrides/file_exists_outside_docker.php | 11 ++ .../register_shutdown_function_spy.php | 14 ++ .../Internal/Containers/ShutdownHookTest.php | 37 +++++ tests/Unit/Mocks/ClientMock.php | 2 + .../Mocks/TestableGenericDockerContainer.php | 18 ++- .../Mocks/TestableMySQLDockerContainer.php | 16 +- tests/Unit/MySQLDockerContainerTest.php | 148 +++++++++++++++++- 24 files changed, 626 insertions(+), 77 deletions(-) create mode 100644 infection.json.dist create mode 100644 src/Internal/Commands/DockerReaper.php create mode 100644 src/Internal/Containers/ContainerReaper.php create mode 100644 src/Internal/Containers/Reused.php create mode 100644 src/Internal/Containers/ShutdownHook.php create mode 100644 tests/Unit/Internal/Containers/ContainerReaperTest.php create mode 100644 tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php create mode 100644 tests/Unit/Internal/Containers/Overrides/register_shutdown_function_spy.php create mode 100644 tests/Unit/Internal/Containers/ShutdownHookTest.php diff --git a/composer.json b/composer.json index d4b11de..f8a4c50 100644 --- a/composer.json +++ b/composer.json @@ -24,9 +24,6 @@ "issues": "https://github.com/tiny-blocks/docker-container/issues", "source": "https://github.com/tiny-blocks/docker-container" }, - "config": { - "sort-packages": true - }, "autoload": { "psr-4": { "TinyBlocks\\DockerContainer\\": "src/" @@ -49,13 +46,21 @@ "phpunit/phpunit": "^11.5", "phpstan/phpstan": "^2.1", "dg/bypass-finals": "^1.9", + "infection/infection": "^0.32", "squizlabs/php_codesniffer": "^4.0" }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, "scripts": { "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "unit-tests-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --testsuite unit", "review": [ @@ -63,7 +68,8 @@ "@phpstan" ], "tests": [ - "@test" + "@test", + "@mutation-test" ], "tests-no-coverage": [ "@test-no-coverage" diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..0e2cd7b --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,24 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/cache/", + "minMsi": 100, + "timeout": 120, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit", + "testFrameworkOptions": "--testsuite=unit" +} diff --git a/src/DockerContainer.php b/src/DockerContainer.php index 7e2c8e7..21cdb40 100644 --- a/src/DockerContainer.php +++ b/src/DockerContainer.php @@ -36,6 +36,9 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS /** * Runs the container only if a container with the same name does not already exist. + * The returned instance treats the container as shared: calling stopOnShutdown() or + * remove() on it has no effect, allowing the container to persist across multiple + * PHP processes (e.g., mutation testing). * * @param array $commands Commands to execute on container startup. * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after diff --git a/src/FlywayDockerContainer.php b/src/FlywayDockerContainer.php index e2d8a0c..d0b3bd0 100644 --- a/src/FlywayDockerContainer.php +++ b/src/FlywayDockerContainer.php @@ -78,16 +78,6 @@ public function withConnectRetries(int $retries): static return $this; } - public function withValidateMigrationNaming(bool $enabled): static - { - $this->container->withEnvironmentVariable( - key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', - value: $enabled ? 'true' : 'false' - ); - - return $this; - } - public function cleanAndMigrate(): ContainerStarted { return $this->container->run( @@ -104,6 +94,16 @@ public function withMigrations(string $pathOnHost): static return $this; } + public function withValidateMigrationNaming(bool $enabled): static + { + $this->container->withEnvironmentVariable( + key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', + value: $enabled ? 'true' : 'false' + ); + + return $this; + } + public function withSource(MySQLContainerStarted $container, string $username, string $password): static { $schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index 5a12cca..044232e 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -4,14 +4,16 @@ namespace TinyBlocks\DockerContainer; -use Symfony\Component\Process\Process; use TinyBlocks\DockerContainer\Contracts\ContainerStarted; use TinyBlocks\DockerContainer\Internal\Client\DockerClient; use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler; use TinyBlocks\DockerContainer\Internal\Commands\DockerPull; use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; +use TinyBlocks\DockerContainer\Internal\Containers\Reused; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted; @@ -19,21 +21,24 @@ class GenericDockerContainer implements DockerContainer { protected ContainerDefinition $definition; - private ?Process $imagePullProcess = null; - private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; - protected function __construct(ContainerDefinition $definition, private CommandHandler $commandHandler) - { + protected function __construct( + private readonly ContainerReaper $reaper, + ContainerDefinition $definition, + private readonly CommandHandler $commandHandler + ) { $this->definition = $definition; } public static function from(string $image, ?string $name = null): static { + $client = new DockerClient(); $definition = ContainerDefinition::create(image: $image, name: $name); - $commandHandler = new ContainerCommandHandler(client: new DockerClient()); + $reaper = new ContainerReaper(client: $client); + $commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new ShutdownHook()); - return new static(definition: $definition, commandHandler: $commandHandler); + return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); } public function withNetwork(string $name): static @@ -66,9 +71,7 @@ public function withEnvironmentVariable(string $key, string $value): static public function pullImage(): static { - $command = DockerPull::from(image: $this->definition->image->name); - $this->imagePullProcess = Process::fromShellCommandline(command: $command->toCommandLine()); - $this->imagePullProcess->start(); + $this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name)); return $this; } @@ -103,22 +106,8 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): return $this; } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted { - $existing = $this->commandHandler->findBy(definition: $this->definition); - - if (!is_null($existing)) { - return $existing; - } - - return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted); - } - public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted { - $this->imagePullProcess?->wait(); $this->waitBeforeStarted?->waitBefore(); $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); @@ -128,4 +117,20 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS return $containerStarted; } + + public function runIfNotExists( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): ContainerStarted { + $existing = $this->commandHandler->findBy(definition: $this->definition); + + if (!is_null($existing)) { + return new Reused(reaper: $this->reaper, containerStarted: $existing); + } + + return new Reused( + reaper: $this->reaper, + containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted) + ); + } } diff --git a/src/Internal/CommandHandler/ContainerCommandHandler.php b/src/Internal/CommandHandler/ContainerCommandHandler.php index 6b04273..b832d32 100644 --- a/src/Internal/CommandHandler/ContainerCommandHandler.php +++ b/src/Internal/CommandHandler/ContainerCommandHandler.php @@ -16,15 +16,16 @@ use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction; use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; final readonly class ContainerCommandHandler implements CommandHandler { private ContainerLookup $lookup; - public function __construct(private Client $client) + public function __construct(private Client $client, ShutdownHook $shutdownHook) { - $this->lookup = new ContainerLookup(client: $client); + $this->lookup = new ContainerLookup(client: $client, shutdownHook: $shutdownHook); } public function execute(Command $command): ExecutionCompleted diff --git a/src/Internal/Commands/DockerList.php b/src/Internal/Commands/DockerList.php index d3913cc..43b97d2 100644 --- a/src/Internal/Commands/DockerList.php +++ b/src/Internal/Commands/DockerList.php @@ -19,6 +19,6 @@ public static function from(Name $name): DockerList public function toCommandLine(): string { - return sprintf('docker ps --all --quiet --filter name=%s', $this->name->value); + return sprintf('docker ps --all --quiet --filter name=^%s$', $this->name->value); } } diff --git a/src/Internal/Commands/DockerReaper.php b/src/Internal/Commands/DockerReaper.php new file mode 100644 index 0000000..97bb4ba --- /dev/null +++ b/src/Internal/Commands/DockerReaper.php @@ -0,0 +1,49 @@ +/dev/null 2>&1; do sleep 2; done;', + 'docker rm -fv %s 2>/dev/null;', + 'docker network prune -f --filter label=%s 2>/dev/null' + ]), + $this->testRunnerHostname, + $this->containerName, + DockerRun::MANAGED_LABEL + ); + + return sprintf( + implode(' ', [ + 'docker run --rm -d --name %s --label %s', + '-v /var/run/docker.sock:/var/run/docker.sock', + 'docker:cli sh -c %s' + ]), + $this->reaperName, + DockerRun::MANAGED_LABEL, + escapeshellarg($script) + ); + } +} diff --git a/src/Internal/Containers/ContainerLookup.php b/src/Internal/Containers/ContainerLookup.php index e6978c1..5d1f6fb 100644 --- a/src/Internal/Containers/ContainerLookup.php +++ b/src/Internal/Containers/ContainerLookup.php @@ -14,7 +14,7 @@ final readonly class ContainerLookup { - public function __construct(private Client $client) + public function __construct(private Client $client, private ShutdownHook $shutdownHook) { } @@ -38,6 +38,7 @@ public function byId( id: $id, name: $definition->name, address: $inspection->toAddress(), + shutdownHook: $this->shutdownHook, commandHandler: $commandHandler, environmentVariables: $inspection->toEnvironmentVariables() ); diff --git a/src/Internal/Containers/ContainerReaper.php b/src/Internal/Containers/ContainerReaper.php new file mode 100644 index 0000000..24beccf --- /dev/null +++ b/src/Internal/Containers/ContainerReaper.php @@ -0,0 +1,41 @@ +client->execute(command: $reaperList)->getOutput())); + + if ($reaperExists) { + return; + } + + $this->client->execute( + command: DockerReaper::from( + reaperName: $reaperName, + containerName: $containerName, + testRunnerHostname: gethostname() + ) + ); + } +} diff --git a/src/Internal/Containers/Reused.php b/src/Internal/Containers/Reused.php new file mode 100644 index 0000000..3236d43 --- /dev/null +++ b/src/Internal/Containers/Reused.php @@ -0,0 +1,57 @@ +ensureRunningFor(containerName: $containerStarted->getName()); + } + + public function remove(): void + { + } + + public function stopOnShutdown(): void + { + } + + public function getId(): string + { + return $this->containerStarted->getId(); + } + + public function getName(): string + { + return $this->containerStarted->getName(); + } + + public function getAddress(): Address + { + return $this->containerStarted->getAddress(); + } + + public function getEnvironmentVariables(): EnvironmentVariables + { + return $this->containerStarted->getEnvironmentVariables(); + } + + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + { + return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); + } + + + public function executeAfterStarted(array $commands): ExecutionCompleted + { + return $this->containerStarted->executeAfterStarted(commands: $commands); + } +} diff --git a/src/Internal/Containers/ShutdownHook.php b/src/Internal/Containers/ShutdownHook.php new file mode 100644 index 0000000..165973b --- /dev/null +++ b/src/Internal/Containers/ShutdownHook.php @@ -0,0 +1,13 @@ +environmentVariables; } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + public function stopOnShutdown(): void { - $command = DockerStop::from(id: $this->id, timeoutInWholeSeconds: $timeoutInWholeSeconds); - - return $this->commandHandler->execute(command: $command); + $this->shutdownHook->register([$this, 'remove']); } public function remove(): void @@ -62,9 +61,11 @@ public function remove(): void $this->commandHandler->execute(command: DockerNetworkPrune::create()); } - public function stopOnShutdown(): void + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted { - register_shutdown_function([$this, 'remove']); + $command = DockerStop::from(id: $this->id, timeoutInWholeSeconds: $timeoutInWholeSeconds); + + return $this->commandHandler->execute(command: $command); } public function executeAfterStarted(array $commands): ExecutionCompleted diff --git a/src/Waits/ContainerWaitForDependency.php b/src/Waits/ContainerWaitForDependency.php index dca8209..0f13385 100644 --- a/src/Waits/ContainerWaitForDependency.php +++ b/src/Waits/ContainerWaitForDependency.php @@ -32,12 +32,15 @@ public function waitBefore(): void { $deadline = microtime(true) + $this->timeoutInSeconds; - while (!$this->condition->isReady()) { - if (microtime(true) >= $deadline) { - throw new ContainerWaitTimeout(timeoutInSeconds: $this->timeoutInSeconds); - } + $ready = $this->condition->isReady(); + while (!$ready && microtime(true) < $deadline) { usleep($this->pollIntervalInMicroseconds); + $ready = $this->condition->isReady(); + } + + if (!$ready) { + throw new ContainerWaitTimeout(timeoutInSeconds: $this->timeoutInSeconds); } } } diff --git a/tests/Unit/FlywayDockerContainerTest.php b/tests/Unit/FlywayDockerContainerTest.php index 8600493..35f0ff1 100644 --- a/tests/Unit/FlywayDockerContainerTest.php +++ b/tests/Unit/FlywayDockerContainerTest.php @@ -25,21 +25,21 @@ public function testMigrateRunsFlywayMigrateCommand(): void /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( image: 'flyway/flyway:12-alpine', - name: 'flyway-migrate', + name: 'flyway-alpha', client: $this->client ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-migrate') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-alpha') ); /** @When migrate is called */ $started = $container->migrate(); /** @Then the container should have executed the migrate command */ - self::assertSame(expected: 'flyway-migrate', actual: $started->getName()); + self::assertSame(expected: 'flyway-alpha', actual: $started->getName()); self::assertCommandLineContains(needle: 'migrate', commandLines: $this->client->getExecutedCommandLines()); } @@ -48,21 +48,21 @@ public function testRepairRunsFlywayRepairCommand(): void /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( image: 'flyway/flyway:12-alpine', - name: 'flyway-repair', + name: 'flyway-beta', client: $this->client ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-repair') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-beta') ); /** @When repair is called */ $started = $container->repair(); /** @Then the container should have executed the repair command */ - self::assertSame(expected: 'flyway-repair', actual: $started->getName()); + self::assertSame(expected: 'flyway-beta', actual: $started->getName()); self::assertCommandLineContains(needle: 'repair', commandLines: $this->client->getExecutedCommandLines()); } @@ -71,21 +71,21 @@ public function testValidateRunsFlywayValidateCommand(): void /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( image: 'flyway/flyway:12-alpine', - name: 'flyway-validate', + name: 'flyway-gamma', client: $this->client ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-validate') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-gamma') ); /** @When validate is called */ $started = $container->validate(); /** @Then the container should have executed the validate command */ - self::assertSame(expected: 'flyway-validate', actual: $started->getName()); + self::assertSame(expected: 'flyway-gamma', actual: $started->getName()); self::assertCommandLineContains(needle: 'validate', commandLines: $this->client->getExecutedCommandLines()); } @@ -105,7 +105,9 @@ public function testCleanAndMigrateRunsBothCommands(): void ); /** @When cleanAndMigrate is called */ + $start = microtime(true); $started = $container->cleanAndMigrate(); + $elapsed = microtime(true) - $start; /** @Then the container should have executed clean followed by migrate */ self::assertSame(expected: 'flyway-clean-migrate', actual: $started->getName()); @@ -113,6 +115,10 @@ public function testCleanAndMigrateRunsBothCommands(): void needle: 'clean migrate', commandLines: $this->client->getExecutedCommandLines() ); + + /** @And the wait time should be exactly 10 seconds */ + self::assertGreaterThanOrEqual(minimum: 9.5, actual: $elapsed); + self::assertLessThanOrEqual(maximum: 10.5, actual: $elapsed); } public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void @@ -218,6 +224,15 @@ public function testWithSourceConfiguresJdbcUrlAndCredentials(): void ); self::assertCommandLineContains(needle: "FLYWAY_USER='admin'", commandLines: $commandLines); self::assertCommandLineContains(needle: "FLYWAY_PASSWORD='secret'", commandLines: $commandLines); + + /** @And a MySQL readiness check should have been executed before Flyway started */ + $mysqladminPingCount = count( + array_filter( + $commandLines, + static fn(string $cmd): bool => str_contains($cmd, 'mysqladmin ping') + ) + ); + self::assertSame(expected: 2, actual: $mysqladminPingCount); } public function testWithSchemaOverridesAutoDetectedSchema(): void @@ -444,6 +459,12 @@ public function testPullImageStartsBackgroundPull(): void /** @Then the container should start successfully after the pull completes */ self::assertSame(expected: 'flyway-pull', actual: $started->getName()); + + /** @And the docker pull command should have been executed */ + self::assertCommandLineContains( + needle: 'docker pull flyway/flyway:12-alpine', + commandLines: $this->client->getExecutedCommandLines() + ); } protected function createRunningMySQLContainer(string $hostname, string $database): MySQLContainerStarted diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index e52e01e..1e90d31 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -10,6 +10,7 @@ use Test\Unit\Mocks\InspectResponseFixture; use Test\Unit\Mocks\TestableGenericDockerContainer; use TinyBlocks\DockerContainer\GenericDockerContainer; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerContainerNotFound; @@ -983,6 +984,10 @@ public function testRunContainerWithPullImage(): void /** @Then the container should be running */ self::assertSame(expected: 'pull-test', actual: $started->getName()); + + /** @And the docker pull command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertStringContainsString(needle: 'docker pull alpine:latest', haystack: $commandLines[0]); } public function testRemoveExecutesDockerRmAndNetworkPrune(): void @@ -1053,11 +1058,16 @@ public function testRemoveCanBeCalledMultipleTimes(): void public function testStopOnShutdownRegistersRemove(): void { - /** @Given a running container */ + /** @Given a ShutdownHook that tracks registration */ + $shutdownHook = $this->createMock(ShutdownHook::class); + $shutdownHook->expects(self::once())->method('register'); + + /** @And a running container using the tracked hook */ $container = TestableGenericDockerContainer::createWith( image: 'alpine:latest', name: 'shutdown-test', - client: $this->client + client: $this->client, + shutdownHook: $shutdownHook ); /** @And the Docker daemon returns valid responses */ @@ -1072,7 +1082,71 @@ public function testStopOnShutdownRegistersRemove(): void /** @When stopOnShutdown is called */ $started->stopOnShutdown(); - /** @Then the shutdown function should be registered without errors */ + /** @Then the shutdown hook should have registered the remove callback */ self::assertSame(expected: 'shutdown-test', actual: $started->getName()); } + + public function testRemoveOnReusedContainerIsNoOp(): void + { + /** @Given a container returned by runIfNotExists (a Reused instance) */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'reused-remove', + client: $this->client + ); + + /** @And the Docker list returns an existing container */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'reused-remove') + ); + + /** @When runIfNotExists returns a reused container */ + $started = $container->runIfNotExists(); + + /** @And remove is called on the reused container */ + $started->remove(); + + /** @Then the container should still be accessible (remove is a no-op for reused containers) */ + self::assertSame(expected: 'reused-remove', actual: $started->getName()); + } + + public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): void + { + /** @Given a container that already exists */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'reaper-skip', + client: $this->client + ); + + /** @And the Docker list returns an existing container */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'reaper-skip') + ); + + /** @And the reaper container already exists */ + $this->client->withDockerListResponse(output: 'existing-reaper-id'); + + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); + + /** @Then the container should be returned */ + self::assertSame(expected: 'reaper-skip', actual: $started->getName()); + + /** @And no reaper creation command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + + foreach ($commandLines as $commandLine) { + self::assertStringNotContainsString( + needle: 'docker run --rm -d --name tiny-blocks-reaper', + haystack: $commandLine + ); + } + } } diff --git a/tests/Unit/Internal/Containers/ContainerReaperTest.php b/tests/Unit/Internal/Containers/ContainerReaperTest.php new file mode 100644 index 0000000..09c15c4 --- /dev/null +++ b/tests/Unit/Internal/Containers/ContainerReaperTest.php @@ -0,0 +1,30 @@ +ensureRunningFor(containerName: 'test-container'); + + /** @Then no Docker commands should have been executed */ + self::assertEmpty($client->getExecutedCommandLines()); + } +} + diff --git a/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php b/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php new file mode 100644 index 0000000..9b0800b --- /dev/null +++ b/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php @@ -0,0 +1,11 @@ +register(callback: $callback); + + /** @Then the callback should have been captured by the shutdown function */ + global $registeredShutdownCallbacks; + self::assertCount(expectedCount: 1, haystack: $registeredShutdownCallbacks); + + /** @And the registered callback should be executable */ + ($registeredShutdownCallbacks[0])(); + self::assertTrue($callbackExecuted); + } +} + diff --git a/tests/Unit/Mocks/ClientMock.php b/tests/Unit/Mocks/ClientMock.php index 81166a8..75bdf9c 100644 --- a/tests/Unit/Mocks/ClientMock.php +++ b/tests/Unit/Mocks/ClientMock.php @@ -13,6 +13,7 @@ use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute; use TinyBlocks\DockerContainer\Internal\Commands\DockerInspect; use TinyBlocks\DockerContainer\Internal\Commands\DockerList; +use TinyBlocks\DockerContainer\Internal\Commands\DockerPull; use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; use TinyBlocks\DockerContainer\Internal\Commands\DockerStop; @@ -102,6 +103,7 @@ public function execute(Command $command): ExecutionCompleted !empty($inspectData) ], $command instanceof DockerCopy => ['', true], + $command instanceof DockerPull => ['', true], $command instanceof DockerStop => array_shift($this->stopResponses) ?? ['', true], default => ['', false] }; diff --git a/tests/Unit/Mocks/TestableGenericDockerContainer.php b/tests/Unit/Mocks/TestableGenericDockerContainer.php index 740ecd0..c733bc9 100644 --- a/tests/Unit/Mocks/TestableGenericDockerContainer.php +++ b/tests/Unit/Mocks/TestableGenericDockerContainer.php @@ -7,15 +7,25 @@ use TinyBlocks\DockerContainer\GenericDockerContainer; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; final class TestableGenericDockerContainer extends GenericDockerContainer { - public static function createWith(string $image, ?string $name, Client $client): static - { + public static function createWith( + string $image, + ?string $name, + Client $client, + ?ShutdownHook $shutdownHook = null + ): static { $definition = ContainerDefinition::create(image: $image, name: $name); - $commandHandler = new ContainerCommandHandler(client: $client); + $reaper = new ContainerReaper(client: $client); + $commandHandler = new ContainerCommandHandler( + client: $client, + shutdownHook: $shutdownHook ?? new ShutdownHook() + ); - return new static(definition: $definition, commandHandler: $commandHandler); + return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); } } diff --git a/tests/Unit/Mocks/TestableMySQLDockerContainer.php b/tests/Unit/Mocks/TestableMySQLDockerContainer.php index 9baa650..e2bc29d 100644 --- a/tests/Unit/Mocks/TestableMySQLDockerContainer.php +++ b/tests/Unit/Mocks/TestableMySQLDockerContainer.php @@ -5,13 +5,23 @@ namespace Test\Unit\Mocks; use TinyBlocks\DockerContainer\Internal\Client\Client; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\MySQLDockerContainer; final class TestableMySQLDockerContainer extends MySQLDockerContainer { - public static function createWith(string $image, ?string $name, Client $client): static - { - $container = TestableGenericDockerContainer::createWith(image: $image, name: $name, client: $client); + public static function createWith( + string $image, + ?string $name, + Client $client, + ?ShutdownHook $shutdownHook = null + ): static { + $container = TestableGenericDockerContainer::createWith( + image: $image, + name: $name, + client: $client, + shutdownHook: $shutdownHook + ); return new static(container: $container); } diff --git a/tests/Unit/MySQLDockerContainerTest.php b/tests/Unit/MySQLDockerContainerTest.php index a9ca89f..def8aa7 100644 --- a/tests/Unit/MySQLDockerContainerTest.php +++ b/tests/Unit/MySQLDockerContainerTest.php @@ -9,6 +9,7 @@ use Test\Unit\Mocks\InspectResponseFixture; use Test\Unit\Mocks\TestableMySQLDockerContainer; use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; +use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\MySQLDockerContainer; @@ -101,6 +102,29 @@ public function testRunMySQLContainerSuccessfully(): void /** @And the port should be exposed */ self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort()); + + /** @And the docker run command should reflect delegated configuration */ + $commandLines = $this->client->getExecutedCommandLines(); + $runCommand = $commandLines[1]; + + self::assertStringNotContainsString(needle: '--rm', haystack: $runCommand); + self::assertStringContainsString(needle: '--volume /var/lib/mysql:/var/lib/mysql', haystack: $runCommand); + self::assertStringContainsString(needle: '--publish 3306:3306', haystack: $runCommand); + self::assertStringContainsString(needle: "MYSQL_USER='app_user'", haystack: $runCommand); + self::assertStringContainsString(needle: "MYSQL_PASSWORD='secret'", haystack: $runCommand); + self::assertStringContainsString(needle: "MYSQL_DATABASE='test_adm'", haystack: $runCommand); + self::assertStringContainsString(needle: "MYSQL_ROOT_PASSWORD='root'", haystack: $runCommand); + + /** @And the database setup should include CREATE DATABASE, GRANT, and FLUSH */ + $setupCommand = $commandLines[4]; + + self::assertStringContainsString(needle: 'CREATE DATABASE IF NOT EXISTS test_adm', haystack: $setupCommand); + self::assertStringContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); + self::assertStringContainsString(needle: 'FLUSH PRIVILEGES', haystack: $setupCommand); + + /** @And the GRANT statements should include both default hosts */ + self::assertStringContainsString(needle: "'root'@'%'", haystack: $setupCommand); + self::assertStringContainsString(needle: "'root'@'172.%'", haystack: $setupCommand); } public function testRunIfNotExistsReturnsMySQLContainerStarted(): void @@ -298,6 +322,18 @@ public function testRunMySQLContainerWithCopyToContainer(): void /** @Then the container should be running with copy instructions executed */ self::assertSame(expected: 'copy-db', actual: $started->getName()); + + /** @And the docker cp command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + $hasCopyCommand = false; + + foreach ($commandLines as $commandLine) { + if (str_contains($commandLine, 'docker cp') && str_contains($commandLine, '/host/init')) { + $hasCopyCommand = true; + } + } + + self::assertTrue($hasCopyCommand); } public function testRunMySQLContainerWithWaitBeforeRun(): void @@ -406,6 +442,22 @@ public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void self::assertSame(expected: 'jdbc:mysql://test-db:3306/test_adm', actual: $jdbcUrl); } + public function testGetJdbcUrlUsesExposedPortWhenDifferentFromDefault(): void + { + /** @Given a running MySQL container with a non-default port */ + $started = $this->createRunningMySQLContainer( + hostname: 'custom-port-db', + database: 'test_adm', + port: 3307 + ); + + /** @When getting the JDBC URL */ + $jdbcUrl = $started->getJdbcUrl(options: []); + + /** @Then the URL should use the exposed port 3307 instead of the default 3306 */ + self::assertSame(expected: 'jdbc:mysql://custom-port-db:3307/test_adm', actual: $jdbcUrl); + } + public function testRunMySQLContainerWithoutDatabase(): void { /** @Given a MySQL container without a database configured */ @@ -463,6 +515,52 @@ public function testRunMySQLContainerWithoutGrantedHosts(): void /** @Then the container should start without errors */ self::assertSame(expected: 'no-grants', actual: $started->getName()); + + /** @And the setup should include CREATE DATABASE but no GRANT statements */ + $commandLines = $this->client->getExecutedCommandLines(); + $setupCommand = $commandLines[3]; + + self::assertStringContainsString(needle: 'CREATE DATABASE IF NOT EXISTS test_db', haystack: $setupCommand); + self::assertStringNotContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); + } + + public function testRunMySQLContainerWithGrantedHostsButNoDatabase(): void + { + /** @Given a MySQL container with granted hosts but no database */ + $container = TestableMySQLDockerContainer::createWith( + image: 'mysql:8.1', + name: 'grants-only', + client: $this->client + ) + ->withRootPassword(rootPassword: 'root') + ->withGrantedHosts(hosts: ['%']); + + /** @And the Docker daemon returns valid responses without MYSQL_DATABASE */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'grants-only', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check and setup succeed */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); + + /** @When the MySQL container is started */ + $started = $container->run(); + + /** @Then the container should start successfully */ + self::assertSame(expected: 'grants-only', actual: $started->getName()); + + /** @And the setup should include GRANT and FLUSH but no CREATE DATABASE */ + $commandLines = $this->client->getExecutedCommandLines(); + $setupCommand = $commandLines[3]; + + self::assertStringNotContainsString(needle: 'CREATE DATABASE', haystack: $setupCommand); + self::assertStringContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); + self::assertStringContainsString(needle: 'FLUSH PRIVILEGES', haystack: $setupCommand); } public function testMySQLContainerDelegatesStopCorrectly(): void @@ -675,6 +773,11 @@ public function testMySQLContainerWithEnvironmentVariableDirectly(): void key: 'CUSTOM_KEY' ) ); + + /** @And the docker run command should include the custom environment variable */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + + self::assertStringContainsString(needle: "CUSTOM_KEY='custom_value'", haystack: $runCommand); } public function testRunMySQLContainerWithPullImage(): void @@ -705,6 +808,18 @@ public function testRunMySQLContainerWithPullImage(): void /** @Then the container should be running */ self::assertSame(expected: 'pull-db', actual: $started->getName()); + + /** @And the docker pull command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + $hasPullCommand = false; + + foreach ($commandLines as $commandLine) { + if (str_contains($commandLine, 'docker pull mysql:8.1')) { + $hasPullCommand = true; + } + } + + self::assertTrue($hasPullCommand); } public function testFromCreatesMySQLContainerInstance(): void @@ -721,17 +836,38 @@ public function testFromCreatesMySQLContainerInstance(): void public function testStopOnShutdownDelegatesToUnderlyingContainer(): void { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'shutdown-db', - database: 'test_adm', - port: 3306 + /** @Given a ShutdownHook that tracks registration */ + $shutdownHook = $this->createMock(ShutdownHook::class); + $shutdownHook->expects(self::once())->method('register'); + + /** @And a running MySQL container using the tracked hook */ + $container = TestableMySQLDockerContainer::createWith( + image: 'mysql:8.1', + name: 'shutdown-db', + client: $this->client, + shutdownHook: $shutdownHook + ) + ->withDatabase(database: 'test_adm') + ->withRootPassword(rootPassword: 'root'); + + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'shutdown-db', + environment: ['MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root'], + exposedPorts: ['3306/tcp' => (object)[]] + ) ); + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); + + /** @And the container is started */ + $started = $container->run(); /** @When stopOnShutdown is called */ $started->stopOnShutdown(); - /** @Then the container should still be accessible (the shutdown handler is deferred) */ + /** @Then the shutdown hook should have registered the remove callback */ self::assertSame(expected: 'shutdown-db', actual: $started->getName()); }