diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc94060..6eb5e22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,13 @@ jobs: run: docker network create tiny-blocks - name: Create Docker volume for migrations - run: docker volume create migrations + run: docker volume create test-adm-migrations - name: Run tests run: | docker run --network=tiny-blocks \ -v ${PWD}:/app \ - -v ${PWD}/tests/Integration/Database/Migrations:/migrations \ + -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /app \ gustavofreze/php:${{ env.PHP_VERSION }} bash -c "composer tests" diff --git a/Makefile b/Makefile index 3f4732c..fcb2e47 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -DOCKER_RUN = docker run -u root --rm -it --network=tiny-blocks --name test-lib -v ${PWD}:/app -v ${PWD}/tests/Integration/Database/Migrations:/migrations -v /var/run/docker.sock:/var/run/docker.sock -w /app gustavofreze/php:8.3 +DOCKER_RUN = docker run -u root --rm -it --network=tiny-blocks --name test-lib \ + -v ${PWD}:/app \ + -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -w /app gustavofreze/php:8.3 .PHONY: configure test unit-test test-no-coverage create-volume create-network review show-reports clean @@ -18,7 +22,7 @@ create-network: @docker network create tiny-blocks create-volume: - @docker volume create migrations + @docker volume create test-adm-migrations review: @${DOCKER_RUN} composer review diff --git a/README.md b/README.md index 2f090cc..0328e59 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,42 @@ $container->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condi ## Usage examples +- When running the containers from the library on a host (your local machine), you need to map the volume + `/var/run/docker.sock:/var/run/docker.sock`. + This ensures that the container has access to the Docker daemon on the host machine, allowing Docker commands to be + executed within the container. + + +- In some cases, it may be necessary to add the `docker-cli` dependency to your PHP image. + This enables the container to interact with Docker from within the container environment. + ### MySQL and Generic Containers +Before configuring and starting the MySQL container, a PHP container is set up to execute the tests and manage the +integration process. + +This container runs within a Docker network and uses a volume for the database migrations. +The following commands are used to prepare the environment: + +1. **Create the Docker network**: + ```bash + docker network create tiny-blocks + ``` + +2. **Create the volume for migrations**: + ```bash + docker volume create test-adm-migrations + ``` + +3. **Run the PHP container**: + ```bash + docker run -u root --rm -it --network=tiny-blocks --name test-lib \ + -v ${PWD}:/app \ + -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -w /app gustavofreze/php:8.3 bash -c "composer tests" + ``` + The MySQL container is configured and started: ```php @@ -179,6 +213,7 @@ $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-dat ->withDatabase(database: 'test_adm') ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) ->withRootPassword(rootPassword: 'root') + ->withGrantedHosts() ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql') ->withoutAutoRemove() ->runIfNotExists(); @@ -187,7 +222,8 @@ $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-dat With the MySQL container started, it is possible to retrieve data, such as the address and JDBC connection URL: ```php -$jdbcUrl = $mySQLContainer->getJdbcUrl(options: 'useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&useSSL=false'); +$environmentVariables = $mySQLContainer->getEnvironmentVariables(); +$jdbcUrl = $mySQLContainer->getJdbcUrl(); $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); $username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); @@ -198,8 +234,8 @@ The Flyway container is configured and only starts and executes migrations after ```php $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0') ->withNetwork(name: 'tiny-blocks') - ->copyToContainer(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql') - ->withVolumeMapping(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql') + ->copyToContainer(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') + ->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') ->withWaitBeforeRun( wait: ContainerWaitForDependency::untilReady( condition: MySQLReady::from( @@ -216,7 +252,10 @@ $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0') ->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/sql') ->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: 'false') ->withEnvironmentVariable(key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', value: 'true') - ->run(commands: ['-connectRetries=15', 'clean', 'migrate']); + ->run( + commands: ['-connectRetries=15', 'clean', 'migrate'], + waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5) + ); ```
diff --git a/composer.json b/composer.json index 9383c30..799f42a 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,10 @@ "minimum-stability": "stable", "keywords": [ "psr", + "tests", "docker", "tiny-blocks", + "test-containers", "docker-container" ], "authors": [ @@ -23,10 +25,7 @@ "source": "https://github.com/tiny-blocks/docker-container" }, "config": { - "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - } + "sort-packages": true }, "autoload": { "psr-4": { diff --git a/src/Contracts/MySQL/MySQLContainerStarted.php b/src/Contracts/MySQL/MySQLContainerStarted.php index 84acc10..285482f 100644 --- a/src/Contracts/MySQL/MySQLContainerStarted.php +++ b/src/Contracts/MySQL/MySQLContainerStarted.php @@ -11,15 +11,25 @@ */ interface MySQLContainerStarted extends ContainerStarted { + /** + * Default JDBC options for connecting to the MySQL container. + */ + public const array DEFAULT_JDBC_OPTIONS = [ + 'useSSL' => 'false', + 'useUnicode' => 'yes', + 'characterEncoding' => 'UTF-8', + 'allowPublicKeyRetrieval' => 'true' + ]; + /** * Generates and returns a JDBC URL for connecting to the MySQL container. * * The URL is built using the container's hostname, port, and database name, * with optional query parameters for additional configurations. * - * @param string|null $options A query string to append to the JDBC URL. - * Example: "useSSL=false&serverTimezone=UTC". + * @param array $options An array of key-value pairs to append to the JDBC URL. + * Defaults to {@see DEFAULT_JDBC_OPTIONS}. * @return string The generated JDBC URL. */ - public function getJdbcUrl(?string $options = null): string; + public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string; } diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index 95d0be8..3bb73bb 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -16,7 +16,7 @@ use TinyBlocks\DockerContainer\Internal\Commands\Options\PortOption; use TinyBlocks\DockerContainer\Internal\Commands\Options\SimpleCommandOption; use TinyBlocks\DockerContainer\Internal\Commands\Options\VolumeOption; -use TinyBlocks\DockerContainer\Internal\ContainerHandler; +use TinyBlocks\DockerContainer\Internal\ContainerCommandHandler; use TinyBlocks\DockerContainer\Internal\Containers\Models\Container; use TinyBlocks\DockerContainer\Internal\Containers\Started; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; @@ -34,7 +34,7 @@ class GenericDockerContainer implements DockerContainer private bool $autoRemove = true; - private ContainerHandler $containerHandler; + private ContainerCommandHandler $commandHandler; private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; @@ -46,7 +46,7 @@ private function __construct(private readonly Container $container) $this->volumes = CommandOptions::createFromEmpty(); $this->environmentVariables = CommandOptions::createFromEmpty(); - $this->containerHandler = new ContainerHandler(client: new DockerClient()); + $this->commandHandler = new ContainerCommandHandler(client: new DockerClient()); } public static function from(string $image, ?string $name = null): static @@ -71,17 +71,17 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS environmentVariables: $this->environmentVariables ); - $container = $this->containerHandler->run(command: $dockerRun); + $container = $this->commandHandler->run(dockerRun: $dockerRun); $this->items->each( actions: function (VolumeOption $volume) use ($container) { $item = ItemToCopyOption::from(id: $container->id, volume: $volume); $dockerCopy = DockerCopy::from(item: $item); - $this->containerHandler->execute(command: $dockerCopy); + $this->commandHandler->execute(command: $dockerCopy); } ); - $containerStarted = new Started(container: $container, containerHandler: $this->containerHandler); + $containerStarted = new Started(container: $container, commandHandler: $this->commandHandler); $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); return $containerStarted; @@ -92,10 +92,10 @@ public function runIfNotExists( ?ContainerWaitAfterStarted $waitAfterStarted = null ): ContainerStarted { $dockerList = DockerList::from(container: $this->container); - $container = $this->containerHandler->findBy(command: $dockerList); + $container = $this->commandHandler->findBy(dockerList: $dockerList); if ($container->hasId()) { - return new Started(container: $container, containerHandler: $this->containerHandler); + return new Started(container: $container, commandHandler: $this->commandHandler); } return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted); diff --git a/src/Internal/Client/DockerClient.php b/src/Internal/Client/DockerClient.php index 208ed88..61299e5 100644 --- a/src/Internal/Client/DockerClient.php +++ b/src/Internal/Client/DockerClient.php @@ -26,7 +26,7 @@ public function execute(Command $command): ExecutionCompleted return Execution::from(process: $process); } catch (Throwable $exception) { - throw new DockerCommandExecutionFailed(process: $process, exception: $exception); + throw DockerCommandExecutionFailed::fromProcess(process: $process, exception: $exception); } } } diff --git a/src/Internal/Client/Execution.php b/src/Internal/Client/Execution.php index e7d61a8..d62b025 100644 --- a/src/Internal/Client/Execution.php +++ b/src/Internal/Client/Execution.php @@ -15,7 +15,9 @@ private function __construct(private string $output, private bool $successful) public static function from(Process $process): Execution { - return new Execution(output: $process->getOutput(), successful: $process->isSuccessful()); + $output = $process->isSuccessful() ? $process->getOutput() : $process->getErrorOutput(); + + return new Execution(output: $output, successful: $process->isSuccessful()); } public function getOutput(): string diff --git a/src/Internal/CommandHandler.php b/src/Internal/CommandHandler.php new file mode 100644 index 0000000..75b8249 --- /dev/null +++ b/src/Internal/CommandHandler.php @@ -0,0 +1,45 @@ +containerFactory = new ContainerFactory(client: $client); } - public function run(DockerRun $command): Container + public function run(DockerRun $dockerRun): Container { - $executionCompleted = $this->client->execute(command: $command); + $executionCompleted = $this->client->execute(command: $dockerRun); + + if (!$executionCompleted->isSuccessful()) { + throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted); + } + $id = ContainerId::from(value: $executionCompleted->getOutput()); - return $this->containerFactory->buildFrom(id: $id, container: $command->container); + return $this->containerFactory->buildFrom(id: $id, container: $dockerRun->container); } - public function findBy(DockerList $command): Container + public function findBy(DockerList $dockerList): Container { - $container = $command->container; - $executionCompleted = $this->client->execute(command: $command); + $container = $dockerList->container; + $executionCompleted = $this->client->execute(command: $dockerList); $output = $executionCompleted->getOutput(); diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php index b1bd049..8c18de9 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php @@ -16,11 +16,11 @@ public static function from(ContainerStarted $containerStarted): MySQLStarted { return new MySQLStarted( container: $containerStarted->container, - containerHandler: $containerStarted->containerHandler + commandHandler: $containerStarted->commandHandler ); } - public function getJdbcUrl(?string $options = null): string + public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string { $address = $this->getAddress(); $port = $address->getPorts()->firstExposedPort() ?? self::DEFAULT_MYSQL_PORT; @@ -29,6 +29,11 @@ public function getJdbcUrl(?string $options = null): string $baseUrl = sprintf('jdbc:mysql://%s:%d/%s', $hostname, $port, $database); - return $options ? sprintf('%s?%s', $baseUrl, ltrim($options, '?')) : $baseUrl; + if (!empty($options)) { + $queryString = http_build_query($options); + return sprintf('%s?%s', $baseUrl, $queryString); + } + + return $baseUrl; } } diff --git a/src/Internal/Containers/Started.php b/src/Internal/Containers/Started.php index ba5eb3a..09e9c57 100644 --- a/src/Internal/Containers/Started.php +++ b/src/Internal/Containers/Started.php @@ -8,14 +8,14 @@ use TinyBlocks\DockerContainer\Contracts\ContainerStarted; use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables; use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\Internal\CommandHandler; use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute; use TinyBlocks\DockerContainer\Internal\Commands\DockerStop; -use TinyBlocks\DockerContainer\Internal\ContainerHandler; use TinyBlocks\DockerContainer\Internal\Containers\Models\Container; readonly class Started implements ContainerStarted { - public function __construct(public Container $container, public ContainerHandler $containerHandler) + public function __construct(public Container $container, public CommandHandler $commandHandler) { } @@ -43,13 +43,13 @@ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE { $command = DockerStop::from(id: $this->container->id, timeoutInWholeSeconds: $timeoutInWholeSeconds); - return $this->containerHandler->execute(command: $command); + return $this->commandHandler->execute(command: $command); } public function executeAfterStarted(array $commands): ExecutionCompleted { $command = DockerExecute::from(name: $this->container->name, commandOptions: $commands); - return $this->containerHandler->execute(command: $command); + return $this->commandHandler->execute(command: $command); } } diff --git a/src/Internal/Exceptions/DockerCommandExecutionFailed.php b/src/Internal/Exceptions/DockerCommandExecutionFailed.php index dd17ccb..e980c32 100644 --- a/src/Internal/Exceptions/DockerCommandExecutionFailed.php +++ b/src/Internal/Exceptions/DockerCommandExecutionFailed.php @@ -7,14 +7,27 @@ use RuntimeException; use Symfony\Component\Process\Process; use Throwable; +use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\Internal\Commands\Command; final class DockerCommandExecutionFailed extends RuntimeException { - public function __construct(Process $process, Throwable $exception) + public function __construct(string $reason, string $command) { - $reason = $process->isStarted() ? $process->getErrorOutput() : $exception->getMessage(); $template = 'Failed to execute command <%s> in Docker container. Reason: %s'; - parent::__construct(message: sprintf($template, $process->getCommandLine(), $reason)); + parent::__construct(message: sprintf($template, $command, $reason)); + } + + public static function fromProcess(Process $process, Throwable $exception): DockerCommandExecutionFailed + { + $reason = $process->isStarted() ? $process->getErrorOutput() : $exception->getMessage(); + + return new DockerCommandExecutionFailed(reason: $reason, command: $process->getCommandLine()); + } + + public static function fromCommand(Command $command, ExecutionCompleted $execution): DockerCommandExecutionFailed + { + return new DockerCommandExecutionFailed(reason: $execution->getOutput(), command: $command->toCommandLine()); } } diff --git a/src/MySQLDockerContainer.php b/src/MySQLDockerContainer.php index 7cb6d9d..69110ce 100644 --- a/src/MySQLDockerContainer.php +++ b/src/MySQLDockerContainer.php @@ -20,19 +20,21 @@ public function run( ?ContainerWaitAfterStarted $waitAfterStarted = null ): MySQLContainerStarted { $containerStarted = parent::run(commands: $commands); - $environmentVariables = $containerStarted->getEnvironmentVariables(); + $condition = MySQLReady::from(container: $containerStarted); + $waitForDependency = ContainerWaitForDependency::untilReady(condition: $condition); + $waitForDependency->waitBefore(); + + $environmentVariables = $containerStarted->getEnvironmentVariables(); $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); - if (!empty($this->grantedHosts)) { - $condition = MySQLReady::from(container: $containerStarted); - $waitForDependency = ContainerWaitForDependency::untilReady(condition: $condition); - $waitForDependency->waitBefore(); - + if (!empty($database)) { $command = MySQLCommands::createDatabase(database: $database, rootPassword: $rootPassword); $containerStarted->executeAfterStarted(commands: [$command]); + } + if (!empty($this->grantedHosts)) { foreach ($this->grantedHosts as $host) { $command = MySQLCommands::grantPrivilegesToRoot(host: $host, rootPassword: $rootPassword); $containerStarted->executeAfterStarted(commands: [$command]); diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index 752ff27..c1d4ba3 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -45,14 +45,12 @@ public function testMultipleContainersAreRunSuccessfully(): void self::assertSame(self::DATABASE, $database); /** @Given a Flyway container is configured to perform database migrations */ - $jdbcUrl = $mySQLContainer->getJdbcUrl( - options: 'useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&useSSL=false' - ); + $jdbcUrl = $mySQLContainer->getJdbcUrl(); $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0') ->withNetwork(name: 'tiny-blocks') - ->copyToContainer(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql') - ->withVolumeMapping(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql') + ->copyToContainer(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') + ->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') ->withWaitBeforeRun( wait: ContainerWaitForDependency::untilReady( condition: MySQLReady::from( diff --git a/tests/Unit/ClientMock.php b/tests/Unit/ClientMock.php index fdecfd6..23b402b 100644 --- a/tests/Unit/ClientMock.php +++ b/tests/Unit/ClientMock.php @@ -13,38 +13,47 @@ final class ClientMock implements Client { - private array $dockerRunResponses = []; + private array $runResponses = []; - private array $dockerListResponses = []; + private array $listResponses = []; - private array $dockerInspectResponses = []; + private array $inspectResponses = []; - public function withDockerRunResponse(string $data): void + private bool $runIsSuccessful; + + private bool $listIsSuccessful; + + private bool $inspectIsSuccessful; + + public function withDockerRunResponse(string $data, bool $isSuccessful = true): void { - $this->dockerRunResponses[] = $data; + $this->runResponses[] = $data; + $this->runIsSuccessful = $isSuccessful; } public function withDockerListResponse(string $data): void { - $this->dockerListResponses[] = $data; + $this->listResponses[] = $data; + $this->listIsSuccessful = !empty($data); } public function withDockerInspectResponse(array $data): void { - $this->dockerInspectResponses[] = $data; + $this->inspectResponses[] = $data; + $this->inspectIsSuccessful = !empty($data); } public function execute(Command $command): ExecutionCompleted { - $output = match (get_class($command)) { - DockerRun::class => array_shift($this->dockerRunResponses), - DockerList::class => array_shift($this->dockerListResponses), - DockerInspect::class => json_encode([array_shift($this->dockerInspectResponses)]), - default => '' + [$output, $isSuccessful] = match (get_class($command)) { + DockerRun::class => [array_shift($this->runResponses), $this->runIsSuccessful], + DockerList::class => [array_shift($this->listResponses), $this->listIsSuccessful], + DockerInspect::class => [json_encode([array_shift($this->inspectResponses)]), $this->inspectIsSuccessful], + default => ['', false] }; - return new readonly class($output) implements ExecutionCompleted { - public function __construct(private string $output) + return new readonly class($output, $isSuccessful) implements ExecutionCompleted { + public function __construct(private string $output, private bool $isSuccessful) { } @@ -55,7 +64,7 @@ public function getOutput(): string public function isSuccessful(): bool { - return !empty($this->output); + return $this->isSuccessful; } }; } diff --git a/tests/Unit/CommandHandlerMock.php b/tests/Unit/CommandHandlerMock.php new file mode 100644 index 0000000..4d05d0b --- /dev/null +++ b/tests/Unit/CommandHandlerMock.php @@ -0,0 +1,30 @@ +client = new ClientMock(); - $this->handler = new ContainerHandler(client: $this->client); + $this->commandHandler = new ContainerCommandHandler(client: $this->client); } public function testShouldRunContainerSuccessfully(): void @@ -66,7 +67,7 @@ public function testShouldRunContainerSuccessfully(): void ]); /** @When running the container */ - $container = $this->handler->run(command: $command); + $container = $this->commandHandler->run(dockerRun: $command); /** @Then the container should be created with the correct details */ self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD')); @@ -106,7 +107,7 @@ public function testShouldFindContainerSuccessfully(): void ]); /** @When finding the container */ - $container = $this->handler->findBy(command: $command); + $container = $this->commandHandler->findBy(dockerList: $command); /** @Then the container should be returned with the correct details */ self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD')); @@ -117,25 +118,6 @@ public function testShouldFindContainerSuccessfully(): void self::assertSame('alpine:latest', $container->image->name); } - public function testShouldReturnEmptyContainerWhenNotFound(): void - { - /** @Given a DockerList command */ - $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest')); - - /** @And the DockerList command was executed and returned the container ID */ - $this->client->withDockerListResponse(data: ''); - - /** @When finding the container */ - $container = $this->handler->findBy(command: $command); - - /** @Then the container should be returned with the correct details */ - self::assertNull($container->id); - self::assertSame('alpine', $container->name->value); - self::assertSame('localhost', $container->address->getHostname()); - self::assertSame('127.0.0.1', $container->address->getIp()); - self::assertSame('alpine:latest', $container->image->name); - } - public function testShouldExecuteCommandSuccessfully(): void { /** @Given a DockerList command */ @@ -145,7 +127,7 @@ public function testShouldExecuteCommandSuccessfully(): void $this->client->withDockerListResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'); /** @When executing the DockerList command */ - $executionCompleted = $this->handler->execute(command: $command); + $executionCompleted = $this->commandHandler->execute(command: $command); /** @Then the execution should be successful and return the correct output */ self::assertTrue($executionCompleted->isSuccessful()); @@ -180,6 +162,34 @@ public function testExceptionWhenDockerContainerNotFound(): void $this->expectExceptionMessage('Docker container with name was not found.'); /** @When running the container */ - $this->handler->run(command: $command); + $this->commandHandler->run(dockerRun: $command); + } + + public function testExceptionWhenDockerConnectionFailure(): void + { + /** @Given a DockerRun command */ + $command = DockerRun::from( + commands: [], + container: Container::create(name: 'alpine', image: 'alpine:latest'), + network: NetworkOption::from(name: 'bridge'), + detached: SimpleCommandOption::DETACH, + autoRemove: SimpleCommandOption::REMOVE, + environmentVariables: CommandOptions::createFromOptions( + commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root') + ) + ); + + /** @And the DockerRun command was executed and returned the container ID */ + $this->client->withDockerRunResponse(data: 'Cannot connect to the Docker daemon.', isSuccessful: false); + + /** @Then an exception indicating cannot connect to the Docker daemon */ + $template = 'Failed to execute command <%s> in Docker container. Reason: %s'; + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessage( + sprintf($template, $command->toCommandLine(), 'Cannot connect to the Docker daemon.') + ); + + /** @When running the container */ + $this->commandHandler->run(dockerRun: $command); } } diff --git a/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php b/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php new file mode 100644 index 0000000..5598ce5 --- /dev/null +++ b/tests/Unit/Internal/Containers/Drivers/MySQL/MySQLStartedTest.php @@ -0,0 +1,91 @@ +commandHandler = new CommandHandlerMock(); + } + + public function testJdbcUrlWithDefaultOptions(): void + { + /** @Given a container with default configuration */ + $container = Container::from( + id: ContainerId::from(value: 'abc123abc123'), + name: Name::from(value: 'mysql'), + image: Image::from(image: 'mysql:latest'), + address: Address::create(), + environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db']) + ); + + /** @And a MySQLStarted instance is created with the container */ + $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler); + + /** @When calling getJdbcUrl without any additional options */ + $actual = $mysqlStarted->getJdbcUrl(); + + /** @Then the returned JDBC URL should include default options */ + self::assertSame( + 'jdbc:mysql://localhost:3306/test_db?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true', + $actual + ); + } + + public function testJdbcUrlWithCustomOptions(): void + { + /** @Given a container with default configuration */ + $container = Container::from( + id: ContainerId::from(value: 'abc123abc123'), + name: Name::from(value: 'mysql'), + image: Image::from(image: 'mysql:latest'), + address: Address::create(), + environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db']) + ); + + /** @And a MySQLStarted instance is created with the container */ + $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler); + + /** @When calling getJdbcUrl with custom options */ + $actual = $mysqlStarted->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']); + + /** @Then the returned JDBC URL should include the custom options */ + self::assertSame('jdbc:mysql://localhost:3306/test_db?connectTimeout=5000&useSSL=true', $actual); + } + + public function testJdbcUrlWithoutOptions(): void + { + /** @Given a container with default configuration */ + $container = Container::from( + id: ContainerId::from(value: 'abc123abc123'), + name: Name::from(value: 'mysql'), + image: Image::from(image: 'mysql:latest'), + address: Address::create(), + environmentVariables: EnvironmentVariables::createFrom(elements: ['MYSQL_DATABASE' => 'test_db']) + ); + + /** @And a MySQLStarted instance is created with the container */ + $mysqlStarted = new MySQLStarted(container: $container, commandHandler: $this->commandHandler); + + /** @When calling getJdbcUrl with an empty options array */ + $actual = $mysqlStarted->getJdbcUrl(options: []); + + /** @Then the returned JDBC URL should not include any query string */ + self::assertSame('jdbc:mysql://localhost:3306/test_db', $actual); + } +} diff --git a/tests/Unit/Waits/ContainerWaitForDependencyTest.php b/tests/Unit/Waits/ContainerWaitForDependencyTest.php new file mode 100644 index 0000000..851bf35 --- /dev/null +++ b/tests/Unit/Waits/ContainerWaitForDependencyTest.php @@ -0,0 +1,47 @@ +createMock(ContainerReady::class); + + /** @And the condition does not initially indicate the dependency is ready */ + $condition->expects(self::exactly(2)) + ->method('isReady') + ->willReturnOnConsecutiveCalls(false, true); + + /** @When I wait until the condition is satisfied */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition); + $wait->waitBefore(); + + /** @Then the condition should eventually return true, indicating the dependency is ready */ + self::assertTrue(true); + } + + public function testWaitBeforeWhenConditionIsReady(): void + { + /** @Given I have a condition */ + $condition = $this->createMock(ContainerReady::class); + + /** @And the condition initially indicates the dependency is ready */ + $condition->expects(self::once()) + ->method('isReady') + ->willReturn(true); + + /** @When I wait until the condition is satisfied */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition); + $wait->waitBefore(); + + /** @Then the condition should return true immediately, indicating the dependency is ready */ + self::assertTrue(true); + } +}