From d328fec21c93f551c447d22d831a51d5f9c66f3d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 13 Apr 2026 13:53:26 -0300 Subject: [PATCH] feat: Refactor Docker container commands and add image pull functionality. --- .claude/CLAUDE.md | 43 +++ .claude/rules/code-style.md | 82 +++++ .claude/rules/documentation.md | 34 ++ .claude/rules/domain.md | 96 +++++ .claude/rules/testing.md | 98 +++++ .github/copilot-instructions.md | 11 + .github/workflows/ci.yml | 2 +- README.md | 231 +++++++----- src/DockerContainer.php | 13 +- src/GenericDockerContainer.php | 88 +++-- src/Internal/Client/DockerClient.php | 4 +- .../ContainerCommandHandler.php | 67 ++-- src/Internal/Commands/DockerCopy.php | 6 +- src/Internal/Commands/DockerNetworkCreate.php | 22 ++ src/Internal/Commands/DockerPull.php | 22 ++ src/Internal/Commands/DockerRun.php | 8 +- ...sultParser.php => ContainerInspection.php} | 25 +- src/Internal/Containers/ContainerLookup.php | 45 +++ .../Drivers/MySQL/MySQLCommands.php | 48 ++- .../Containers/Drivers/MySQL/MySQLStarted.php | 2 +- src/Internal/Containers/Started.php | 4 +- src/MySQLDockerContainer.php | 79 ++-- src/Waits/ContainerWaitForDependency.php | 4 +- tests/Integration/DockerContainerTest.php | 28 +- tests/Unit/GenericDockerContainerTest.php | 343 ++++++++++-------- .../Unit/Internal/Client/DockerClientTest.php | 16 +- tests/Unit/Mocks/ClientMock.php | 80 ++-- tests/Unit/Mocks/InspectResponseFixture.php | 28 +- tests/Unit/MySQLDockerContainerTest.php | 297 ++++++++------- .../Waits/ContainerWaitForDependencyTest.php | 8 +- tests/Unit/Waits/ContainerWaitForTimeTest.php | 18 +- 31 files changed, 1237 insertions(+), 615 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/rules/code-style.md create mode 100644 .claude/rules/documentation.md create mode 100644 .claude/rules/domain.md create mode 100644 .claude/rules/testing.md create mode 100644 .github/copilot-instructions.md create mode 100644 src/Internal/Commands/DockerNetworkCreate.php create mode 100644 src/Internal/Commands/DockerPull.php rename src/Internal/Containers/{Factories/InspectResultParser.php => ContainerInspection.php} (65%) create mode 100644 src/Internal/Containers/ContainerLookup.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..2b951ef --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,43 @@ +# Project + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Stack + +Refer to `composer.json` for the full dependency list, version constraints, and PHP version. + +## Project layout + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ └── Exceptions/ # Internal exception classes +└── Exceptions/ # Public exception classes (when part of the API) +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +└── Integration/ # Tests requiring real external resources (when applicable) +``` + +See `rules/domain.md` for folder conventions and naming rules. + +## Commands + +- Run tests: `make test`. +- Run lint: `make review`. +- Run `make help` to list all available commands. + +## Post-change validation + +After any code change, run `make review` and `make test`. +If either fails, iterate on the fix while respecting all project rules until both pass. +Never deliver code that breaks lint or tests. + +## Reference-first approach + +Always read all rule files and reference sources before generating any code or documentation. +Never generate from memory. Read the source and match the pattern exactly. diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..7e76f9a --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,82 @@ +--- +description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `rules/domain.md` for domain modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$details`, `$result` → `$outcome`. +9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection` + (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable + only for primitive configuration data, variadic pass-through, or interop at system boundaries. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. The same rule applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. No O(N²) or worse complexity exists. +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, + `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. + Prefer names that describe the domain operation. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings because + `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- Never add PHPDoc to concrete classes. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..353899c --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,34 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **License** and **Contributing** sections at the end. +9. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/domain.md b/.claude/rules/domain.md new file mode 100644 index 0000000..f3b0eea --- /dev/null +++ b/.claude/rules/domain.md @@ -0,0 +1,96 @@ +--- +description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. +paths: + - "src/**/*.php" +--- + +# Domain modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. +Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the +`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators +inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in +`Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **domain concept** the library represents. + A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection + library uses `Collectible`, `Order`. +2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, + `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, + `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, + `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, + `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation + paths exist. + +## Exceptions + +1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). +2. Are pure: no formatted `code`/`message` for HTTP responses. +3. Signal invariant violations only. +4. Name after the invariant violated, never after the technical type: + `PrecisionOutOfRange` — not `InvalidPrecisionException`. + `CurrencyMismatch` — not `BadCurrencyException`. + `ContainerWaitTimeout` — not `TimeoutException`. +5. Create the exception class directly with the invariant name and the appropriate native parent. The exception + is dedicated by definition when its name describes the specific invariant it guards. + +## Enums + +1. Are PHP backed enums. +2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Principles + +- **Immutability**: all models and value objects adopt immutability. Operations return new instances. +- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. +- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. + +## SOLID reference + +| Principle | Failure signal | +|---------------------------|---------------------------------------------| +| S — Single responsibility | Class does two unrelated things | +| O — Open/closed | Adding a feature requires editing internals | +| L — Liskov substitution | Subclass throws on parent method | +| I — Interface segregation | Interface has unused methods | +| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..af8edff --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,98 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. For exception tests, place `@Then` (expectException) before `@When`. +9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. +- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). + Only present when the library interacts with infrastructure. +- `tests/Models/`: domain-specific fixture classes reused across test files. +- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Mandatory pre-task step + +Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not +deviate from the patterns, folder structure, or naming conventions defined in them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 102bb0f..de58e7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: tests: name: Tests runs-on: ubuntu-latest - needs: auto-review + needs: build steps: - name: Checkout diff --git a/README.md b/README.md index c7cb273..8698d2c 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,33 @@ * [How to use](#how-to-use) * [Creating a container](#creating-a-container) * [Running a container](#running-a-container) - * [Running a container if it doesn't exist](#running-a-container-if-it-doesnt-exist) + * [Running if not exists](#running-if-not-exists) + * [Pulling an image](#pulling-an-image) * [Setting network](#setting-network) * [Setting port mappings](#setting-port-mappings) - * [Setting volumes mappings](#setting-volumes-mappings) + * [Setting volume mappings](#setting-volume-mappings) * [Setting environment variables](#setting-environment-variables) * [Disabling auto-remove](#disabling-auto-remove) * [Copying files to a container](#copying-files-to-a-container) - * [Waiting for a condition](#waiting-for-a-condition) + * [Stopping a container](#stopping-a-container) + * [Executing commands after startup](#executing-commands-after-startup) + * [Wait strategies](#wait-strategies) +* [MySQL container](#mysql-container) + * [Configuring MySQL options](#configuring-mysql-options) + * [Setting readiness timeout](#setting-readiness-timeout) + * [Retrieving connection data](#retrieving-connection-data) * [Usage examples](#usage-examples) + * [MySQL with Flyway migrations](#mysql-with-flyway-migrations) * [License](#license) * [Contributing](#contributing) -
- ## Overview -The `DockerContainer` library provides an interface and implementations to manage Docker containers programmatically. -It simplifies the creation, execution, and interaction with containers, such as adding network configurations, mapping -ports, setting environment variables, and executing commands inside containers. -Designed specifically to support **unit tests** and **integration tests**, the library enables developers to simulate -and manage containerized environments with minimal effort, ensuring a seamless testing workflow. +Manage Docker containers programmatically, simplifying the creation, running, and interaction with containers. -
+The library provides interfaces and implementations for adding network configurations, mapping ports, setting +environment variables, and executing commands inside containers. Designed to support **unit tests** and +**integration tests**, it enables developers to manage containerized environments with minimal effort. ## Installation @@ -37,60 +41,63 @@ and manage containerized environments with minimal effort, ensuring a seamless t composer require tiny-blocks/docker-container ``` -
- ## How to use ### Creating a container -Creates a container from a specified image and optionally a name. -The `from` method initializes a new container instance with an image and an optional name for identification. +Creates a container from a specified image and an optional name. ```php -$container = GenericDockerContainer::from(image: 'php:8.3-fpm', name: 'my-container'); +$container = GenericDockerContainer::from(image: 'php:8.5-fpm', name: 'my-container'); ``` ### Running a container -The `run` method starts a container. -Optionally, it allows you to execute commands within the container after it has started and define a condition to wait -for using a `ContainerWaitAfterStarted` instance. - -**Example with no commands or conditions:** +Starts a container. Optionally accepts commands to run on startup and a wait strategy applied after the container +starts. ```php $container->run(); ``` -**Example with commands only:** +With commands: ```php $container->run(commands: ['ls', '-la']); ``` -**Example with commands and a wait condition:** +With commands and a wait strategy: ```php $container->run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5)); ``` -### Running a container if it doesn't exist +### Running if not exists -The `runIfNotExists` method starts a container only if it doesn't already exist. +Starts a container only if a container with the same name is not already running. ```php $container->runIfNotExists(); ``` -**Example with commands and a wait condition:** +### Pulling an image + +Starts pulling the container image in the background. When `run()` or `runIfNotExists()` is called, it waits for +the pull to complete before starting the container. Calling this on multiple containers before running them enables +parallel image pulls. ```php -$container->runIfNotExists(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5)); +$alpine = GenericDockerContainer::from(image: 'alpine:latest')->pullImage(); +$nginx = GenericDockerContainer::from(image: 'nginx:latest')->pullImage(); + +$alpineStarted = $alpine->run(); +$nginxStarted = $nginx->run(); ``` ### Setting network -The `withNetwork` method connects the container to a specified Docker network by name. +Sets the Docker network the container should join. The network is created automatically when the container is +started via `run()` or `runIfNotExists()`, if it does not already exist. ```php $container->withNetwork(name: 'my-network'); @@ -105,7 +112,7 @@ $container->withPortMapping(portOnHost: 9000, portOnContainer: 9000); $container->withPortMapping(portOnHost: 8080, portOnContainer: 80); ``` -### Setting volumes mappings +### Setting volume mappings Maps a volume from the host to the container. @@ -118,7 +125,7 @@ $container->withVolumeMapping(pathOnHost: '/path/on/host', pathOnContainer: '/pa Sets environment variables inside the container. ```php -$container->withEnvironmentVariable(key: 'XPTO', value: '123'); +$container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); ``` ### Disabling auto-remove @@ -131,76 +138,140 @@ $container->withoutAutoRemove(); ### Copying files to a container -Copies files or directories from the host machine to the container. +Registers files or directories to be copied from the host into the container after it starts. ```php $container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container'); ``` -### Waiting for a condition +### Stopping a container + +Stops a running container. An optional timeout (in seconds) controls how long to wait before forcing the stop. +The default timeout is 300 seconds. + +```php +$started = $container->run(); +$result = $started->stop(); +``` + +With a custom timeout: + +```php +$result = $started->stop(timeoutInWholeSeconds: 60); +``` + +### Executing commands after startup + +Runs commands inside an already-started container. + +```php +$started = $container->run(); +$result = $started->executeAfterStarted(commands: ['php', '-v']); +``` + +The returned `ExecutionCompleted` provides the command output and success status: + +```php +$result->getOutput(); +$result->isSuccessful(); +``` + +### Wait strategies -The `withWaitBeforeRun` method allows the container to pause its execution until a specified condition is met before -starting. A timeout prevents the wait from blocking indefinitely. +#### Waiting for a fixed time + +Pauses execution for a specified number of seconds before or after starting a container. ```php -$container->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: MySQLReady::from(container: $container), - timeoutInSeconds: 30 +$container->withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 3)); +``` + +#### Waiting for a dependency + +Blocks until a readiness condition is satisfied, with a configurable timeout. This is useful when one container +depends on another being fully ready. + +```php +$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.1') + ->withRootPassword(rootPassword: 'root') + ->run(); + +$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.1.0') + ->withWaitBeforeRun( + wait: ContainerWaitForDependency::untilReady( + condition: MySQLReady::from(container: $mySQLStarted), + timeoutInSeconds: 30 + ) ) -); + ->run(); ``` -### Setting readiness timeout for MySQL +## MySQL container + +`MySQLDockerContainer` provides a specialized container for MySQL databases. It extends the generic container with +MySQL-specific configuration and automatic readiness detection. -The `withReadinessTimeout` method configures how long the MySQL container will wait for the database to become ready -before throwing a `ContainerWaitTimeout` exception. The default timeout is 30 seconds. +### Configuring MySQL options + +| Method | Parameter | Description | +|--------------------|-----------------|-----------------------------------------------------------------| +| `withTimezone` | `$timezone` | Sets the container timezone (e.g., `America/Sao_Paulo`). | +| `withUsername` | `$user` | Sets the MySQL user created on startup. | +| `withPassword` | `$password` | Sets the password for the MySQL user. | +| `withDatabase` | `$database` | Sets the default database created on startup. | +| `withRootPassword` | `$rootPassword` | Sets the root password for the MySQL instance. | +| `withGrantedHosts` | `$hosts` | Sets hosts granted root privileges (default: `['%', '172.%']`). | ```php -$container = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database') - ->withReadinessTimeout(timeoutInSeconds: 60) +$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database') + ->withTimezone(timezone: 'America/Sao_Paulo') + ->withUsername(user: 'app_user') + ->withPassword(password: 'secret') + ->withDatabase(database: 'my_database') + ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) + ->withRootPassword(rootPassword: 'root') + ->withGrantedHosts() ->run(); ``` -
+### Setting readiness timeout -## Usage examples +Configures how long the MySQL container waits for the database to become ready before throwing a +`ContainerWaitTimeout` exception. The default timeout is 30 seconds. -- 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. +```php +$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeoutInSeconds: 60) + ->run(); +``` +### Retrieving connection data -- 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. +After the MySQL container starts, connection details are available through the `MySQLContainerStarted` instance. -### MySQL and Generic Containers +```php +$address = $mySQLContainer->getAddress(); +$ip = $address->getIp(); +$port = $address->getPorts()->firstExposedPort(); +$hostname = $address->getHostname(); -Before configuring and starting the MySQL container, a PHP container is set up to execute the tests and manage the -integration process. +$environmentVariables = $mySQLContainer->getEnvironmentVariables(); +$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); +$username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); +$password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); -This container runs within a Docker network and uses a volume for the database migrations. -The following commands are used to prepare the environment: +$jdbcUrl = $mySQLContainer->getJdbcUrl(); +``` -1. **Create the Docker network**: - ```bash - docker network create tiny-blocks - ``` +## Usage examples -2. **Create the volume for migrations**: - ```bash - docker volume create test-adm-migrations - ``` +- When running the containers from the library on a host (your local machine), map the volume + `/var/run/docker.sock:/var/run/docker.sock` so the container has access to the Docker daemon on the host machine. +- In some cases, it may be necessary to add the `docker-cli` dependency to your PHP image to interact with Docker + from within the container. -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.5-alpine bash -c "composer tests" - ``` +### MySQL with Flyway migrations The MySQL container is configured and started: @@ -219,7 +290,7 @@ $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-dat ->runIfNotExists(); ``` -With the MySQL container started, it is possible to retrieve data, such as the address and JDBC connection URL: +With the MySQL container started, retrieve the connection data: ```php $environmentVariables = $mySQLContainer->getEnvironmentVariables(); @@ -229,18 +300,16 @@ $username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); ``` -The Flyway container is configured and only starts and executes migrations after the MySQL container is **ready**: +The Flyway container is configured and only starts after the MySQL container is **ready**: ```php -$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0') +$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.1.0') ->withNetwork(name: 'tiny-blocks') ->copyToContainer(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') ->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') ->withWaitBeforeRun( wait: ContainerWaitForDependency::untilReady( - condition: MySQLReady::from( - container: $mySQLContainer - ), + condition: MySQLReady::from(container: $mySQLContainer), timeoutInSeconds: 30 ) ) @@ -259,14 +328,10 @@ $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0') ); ``` -
- ## License Docker container is licensed under [MIT](LICENSE). -
- ## Contributing Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to diff --git a/src/DockerContainer.php b/src/DockerContainer.php index bfe28f5..7e2c8e7 100644 --- a/src/DockerContainer.php +++ b/src/DockerContainer.php @@ -48,6 +48,15 @@ public function runIfNotExists( ?ContainerWaitAfterStarted $waitAfterStarted = null ): ContainerStarted; + /** + * Starts pulling the container image in the background. When run() or runIfNotExists() + * is called, it waits for the pull to complete before starting the container. + * Calling this method on multiple containers before running them enables parallel image pulls. + * + * @return static The current container instance for method chaining. + */ + public function pullImage(): static; + /** * Registers a file or directory to be copied into the container after it starts. * @@ -58,7 +67,9 @@ public function runIfNotExists( public function copyToContainer(string $pathOnHost, string $pathOnContainer): static; /** - * Connects the container to a specific Docker network. + * Sets the Docker network the container should join. The network is created + * automatically when the container is started via run() or runIfNotExists(), + * if it does not already exist. * * @param string $name The name of the Docker network. * @return static The current container instance for method chaining. diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index 1ee1cf3..87d5433 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -4,10 +4,12 @@ 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\Definitions\ContainerDefinition; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; @@ -17,6 +19,8 @@ class GenericDockerContainer implements DockerContainer { protected ContainerDefinition $definition; + private ?Process $imagePullProcess = null; + private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; private CommandHandler $commandHandler; @@ -35,68 +39,59 @@ public static function from(string $image, ?string $name = null): static return new static(definition: $definition, commandHandler: $commandHandler); } - public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted + public function withNetwork(string $name): static { - $this->waitBeforeStarted?->waitBefore(); - - $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); - $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun); - - $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); + $this->definition = $this->definition->withNetwork(name: $name); - return $containerStarted; + return $this; } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted { - $existing = $this->commandHandler->findBy(definition: $this->definition); - - if ($existing !== null) { - return $existing; - } + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static + { + $this->waitBeforeStarted = $wait; - return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted); + return $this; } - public function copyToContainer(string $pathOnHost, string $pathOnContainer): static + public function withoutAutoRemove(): static { - $this->definition = $this->definition->withCopyInstruction( - pathOnHost: $pathOnHost, - pathOnContainer: $pathOnContainer - ); + $this->definition = $this->definition->withoutAutoRemove(); return $this; } - public function withNetwork(string $name): static + public function withEnvironmentVariable(string $key, string $value): static { - $this->definition = $this->definition->withNetwork(name: $name); + $this->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value); return $this; } - public function withPortMapping(int $portOnHost, int $portOnContainer): static + public function pullImage(): static { - $this->definition = $this->definition->withPortMapping( - portOnHost: $portOnHost, - portOnContainer: $portOnContainer - ); + $command = DockerPull::from(image: $this->definition->image->name); + $this->imagePullProcess = Process::fromShellCommandline(command: $command->toCommandLine()); + $this->imagePullProcess->start(); return $this; } - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static + public function copyToContainer(string $pathOnHost, string $pathOnContainer): static { - $this->waitBeforeStarted = $wait; + $this->definition = $this->definition->withCopyInstruction( + pathOnHost: $pathOnHost, + pathOnContainer: $pathOnContainer + ); return $this; } - public function withoutAutoRemove(): static + public function withPortMapping(int $portOnHost, int $portOnContainer): static { - $this->definition = $this->definition->withoutAutoRemove(); + $this->definition = $this->definition->withPortMapping( + portOnHost: $portOnHost, + portOnContainer: $portOnContainer + ); return $this; } @@ -111,10 +106,29 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): return $this; } - public function withEnvironmentVariable(string $key, string $value): static + 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->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value); + $this->imagePullProcess?->wait(); + $this->waitBeforeStarted?->waitBefore(); - return $this; + $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); + $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun); + + $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); + + return $containerStarted; } } diff --git a/src/Internal/Client/DockerClient.php b/src/Internal/Client/DockerClient.php index ced94a8..6aa7214 100644 --- a/src/Internal/Client/DockerClient.php +++ b/src/Internal/Client/DockerClient.php @@ -15,11 +15,11 @@ { public function execute(Command $command): ExecutionCompleted { - $process = Process::fromShellCommandline($command->toCommandLine()); + $process = Process::fromShellCommandline(command: $command->toCommandLine()); try { if ($command instanceof CommandWithTimeout) { - $process->setTimeout($command->getTimeoutInWholeSeconds()); + $process->setTimeout(timeout: $command->getTimeoutInWholeSeconds()); } $process->run(); diff --git a/src/Internal/CommandHandler/ContainerCommandHandler.php b/src/Internal/CommandHandler/ContainerCommandHandler.php index 7468b82..6b04273 100644 --- a/src/Internal/CommandHandler/ContainerCommandHandler.php +++ b/src/Internal/CommandHandler/ContainerCommandHandler.php @@ -9,46 +9,27 @@ use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Commands\DockerCopy; -use TinyBlocks\DockerContainer\Internal\Commands\DockerInspect; use TinyBlocks\DockerContainer\Internal\Commands\DockerList; +use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkCreate; use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerLookup; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction; -use TinyBlocks\DockerContainer\Internal\Containers\Factories\InspectResultParser; use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; -use TinyBlocks\DockerContainer\Internal\Containers\Started; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; -use TinyBlocks\DockerContainer\Internal\Exceptions\DockerContainerNotFound; final readonly class ContainerCommandHandler implements CommandHandler { - private InspectResultParser $parser; + private ContainerLookup $lookup; public function __construct(private Client $client) { - $this->parser = new InspectResultParser(); + $this->lookup = new ContainerLookup(client: $client); } - public function run(DockerRun $dockerRun): ContainerStarted + public function execute(Command $command): ExecutionCompleted { - $executionCompleted = $this->client->execute(command: $dockerRun); - - if (!$executionCompleted->isSuccessful()) { - throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted); - } - - $id = ContainerId::from(value: $executionCompleted->getOutput()); - $definition = $dockerRun->definition; - - $started = $this->inspect(id: $id, definition: $definition); - - $definition->copyInstructions->each( - actions: function (CopyInstruction $instruction) use ($id): void { - $this->client->execute(command: DockerCopy::from(instruction: $instruction, id: $id)); - } - ); - - return $started; + return $this->client->execute(command: $command); } public function findBy(ContainerDefinition $definition): ?ContainerStarted @@ -64,33 +45,31 @@ public function findBy(ContainerDefinition $definition): ?ContainerStarted $id = ContainerId::from(value: $output); - return $this->inspect(id: $id, definition: $definition); + return $this->lookup->byId(id: $id, definition: $definition, commandHandler: $this); } - public function execute(Command $command): ExecutionCompleted - { - return $this->client->execute(command: $command); - } - - private function inspect(ContainerId $id, ContainerDefinition $definition): ContainerStarted + public function run(DockerRun $dockerRun): ContainerStarted { - $dockerInspect = DockerInspect::from(id: $id); - $executionCompleted = $this->client->execute(command: $dockerInspect); + if (!is_null($dockerRun->definition->network)) { + $this->client->execute(command: DockerNetworkCreate::from(network: $dockerRun->definition->network)); + } - $payload = (array)json_decode($executionCompleted->getOutput(), true); + $executionCompleted = $this->client->execute(command: $dockerRun); - if (empty(array_filter($payload))) { - throw new DockerContainerNotFound(name: $definition->name); + if (!$executionCompleted->isSuccessful()) { + throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted); } - $data = $payload[0]; + $id = ContainerId::from(value: $executionCompleted->getOutput()); + + $started = $this->lookup->byId(id: $id, definition: $dockerRun->definition, commandHandler: $this); - return new Started( - id: $id, - name: $definition->name, - address: $this->parser->parseAddress(data: $data), - environmentVariables: $this->parser->parseEnvironmentVariables(data: $data), - commandHandler: $this + $dockerRun->definition->copyInstructions->each( + actions: function (CopyInstruction $instruction) use ($id): void { + $this->client->execute(command: DockerCopy::from(id: $id, instruction: $instruction)); + } ); + + return $started; } } diff --git a/src/Internal/Commands/DockerCopy.php b/src/Internal/Commands/DockerCopy.php index 1ecac8b..12d3587 100644 --- a/src/Internal/Commands/DockerCopy.php +++ b/src/Internal/Commands/DockerCopy.php @@ -9,13 +9,13 @@ final readonly class DockerCopy implements Command { - private function __construct(private CopyInstruction $instruction, private ContainerId $id) + private function __construct(private ContainerId $id, private CopyInstruction $instruction) { } - public static function from(CopyInstruction $instruction, ContainerId $id): DockerCopy + public static function from(ContainerId $id, CopyInstruction $instruction): DockerCopy { - return new DockerCopy(instruction: $instruction, id: $id); + return new DockerCopy(id: $id, instruction: $instruction); } public function toCommandLine(): string diff --git a/src/Internal/Commands/DockerNetworkCreate.php b/src/Internal/Commands/DockerNetworkCreate.php new file mode 100644 index 0000000..2137a50 --- /dev/null +++ b/src/Internal/Commands/DockerNetworkCreate.php @@ -0,0 +1,22 @@ +/dev/null || true', $this->network); + } +} diff --git a/src/Internal/Commands/DockerPull.php b/src/Internal/Commands/DockerPull.php new file mode 100644 index 0000000..89f87ee --- /dev/null +++ b/src/Internal/Commands/DockerPull.php @@ -0,0 +1,22 @@ +image); + } +} diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php index e885b50..960e6e2 100644 --- a/src/Internal/Commands/DockerRun.php +++ b/src/Internal/Commands/DockerRun.php @@ -12,13 +12,13 @@ final readonly class DockerRun implements Command { - private function __construct(public ContainerDefinition $definition, private Collection $commands) + private function __construct(private Collection $commands, public ContainerDefinition $definition) { } public static function from(ContainerDefinition $definition, array $commands = []): DockerRun { - return new DockerRun(definition: $definition, commands: Collection::createFrom(elements: $commands)); + return new DockerRun(commands: Collection::createFrom(elements: $commands), definition: $definition); } public function toCommandLine(): string @@ -37,7 +37,7 @@ public function toCommandLine(): string ) ); - if ($this->definition->network !== null) { + if (!is_null($this->definition->network)) { $parts = $parts->add(sprintf('--network=%s', $this->definition->network)); } @@ -55,7 +55,7 @@ public function toCommandLine(): string $parts = $parts->merge( other: $this->definition->environmentVariables->map( - transformations: static fn(EnvironmentVariable $env): string => $env->toArgument() + transformations: static fn(EnvironmentVariable $environment): string => $environment->toArgument() ) ); diff --git a/src/Internal/Containers/Factories/InspectResultParser.php b/src/Internal/Containers/ContainerInspection.php similarity index 65% rename from src/Internal/Containers/Factories/InspectResultParser.php rename to src/Internal/Containers/ContainerInspection.php index f0b3a22..96c075e 100644 --- a/src/Internal/Containers/Factories/InspectResultParser.php +++ b/src/Internal/Containers/ContainerInspection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Internal\Containers\Factories; +namespace TinyBlocks\DockerContainer\Internal\Containers; use TinyBlocks\Collection\Collection; use TinyBlocks\DockerContainer\Internal\Containers\Address\Address; @@ -11,15 +11,24 @@ use TinyBlocks\DockerContainer\Internal\Containers\Address\Ports; use TinyBlocks\DockerContainer\Internal\Containers\Environment\EnvironmentVariables; -final readonly class InspectResultParser +final readonly class ContainerInspection { private const int LIMIT = 2; private const string SEPARATOR = '='; - public function parseAddress(array $data): Address + private function __construct(private array $inspectResult) { - $networks = $data['NetworkSettings']['Networks'] ?? []; - $configuration = $data['Config'] ?? []; + } + + public static function from(array $inspectResult): ContainerInspection + { + return new ContainerInspection(inspectResult: $inspectResult); + } + + public function toAddress(): Address + { + $networks = $this->inspectResult['NetworkSettings']['Networks'] ?? []; + $configuration = $this->inspectResult['Config'] ?? []; $rawPorts = $configuration['ExposedPorts'] ?? []; $ip = IP::from(value: !empty($networks) ? ($networks[key($networks)]['IPAddress'] ?? '') : ''); @@ -35,12 +44,12 @@ public function parseAddress(array $data): Address return Address::from(ip: $ip, ports: Ports::from(ports: $exposedPorts), hostname: $hostname); } - public function parseEnvironmentVariables(array $data): EnvironmentVariables + public function toEnvironmentVariables(): EnvironmentVariables { - $envData = $data['Config']['Env'] ?? []; + $rawEnvironment = $this->inspectResult['Config']['Env'] ?? []; $variables = []; - foreach ($envData as $variable) { + foreach ($rawEnvironment as $variable) { [$key, $value] = explode(self::SEPARATOR, $variable, self::LIMIT); $variables[$key] = $value; } diff --git a/src/Internal/Containers/ContainerLookup.php b/src/Internal/Containers/ContainerLookup.php new file mode 100644 index 0000000..e6978c1 --- /dev/null +++ b/src/Internal/Containers/ContainerLookup.php @@ -0,0 +1,45 @@ +client->execute(command: $dockerInspect); + + $inspectPayload = (array)json_decode($executionCompleted->getOutput(), true); + + if (empty(array_filter($inspectPayload))) { + throw new DockerContainerNotFound(name: $definition->name); + } + + $inspection = ContainerInspection::from(inspectResult: $inspectPayload[0]); + + return new Started( + id: $id, + name: $definition->name, + address: $inspection->toAddress(), + commandHandler: $commandHandler, + environmentVariables: $inspection->toEnvironmentVariables() + ); + } +} diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php index 9ad6ca8..9c0fcc1 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php @@ -7,34 +7,30 @@ final readonly class MySQLCommands { private const string USER_ROOT = 'root'; + private const string GRANT_ALL = "GRANT ALL PRIVILEGES ON *.* TO '%s'@'%s' WITH GRANT OPTION;"; + private const string CREATE_USER = "CREATE USER IF NOT EXISTS '%s'@'%s' IDENTIFIED BY '%s';"; + private const string EXECUTE_SQL = 'mysql -u%s -p%s -e "%s"'; + private const string CREATE_DATABASE = 'CREATE DATABASE IF NOT EXISTS %s;'; + private const string FLUSH_PRIVILEGES = 'FLUSH PRIVILEGES;'; - public static function createDatabase(string $database, string $rootPassword): string + public static function setupDatabase(string $database, string $rootPassword, array $grantedHosts): string { - $query = sprintf( - <<container->run(commands: $commands); - - $condition = MySQLReady::from(container: $containerStarted); - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: $this->readinessTimeoutInSeconds - )->waitBefore(); - - $environmentVariables = $containerStarted->getEnvironmentVariables(); - $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); - $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); - - if (!empty($database)) { - $containerStarted->executeAfterStarted( - commands: [MySQLCommands::createDatabase(database: $database, rootPassword: $rootPassword)] - ); - } - - foreach ($this->grantedHosts as $host) { - $containerStarted->executeAfterStarted( - commands: [MySQLCommands::grantPrivilegesToRoot(host: $host, rootPassword: $rootPassword)] - ); - } - - return MySQLStarted::from(containerStarted: $containerStarted); - } - - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): MySQLContainerStarted { - $containerStarted = $this->container->runIfNotExists(commands: $commands); + public function pullImage(): static + { + $this->container->pullImage(); - return MySQLStarted::from(containerStarted: $containerStarted); + return $this; } public function copyToContainer(string $pathOnHost, string $pathOnContainer): static @@ -170,4 +137,42 @@ public function withReadinessTimeout(int $timeoutInSeconds): static return $this; } + + public function runIfNotExists( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): MySQLContainerStarted { + $containerStarted = $this->container->runIfNotExists(commands: $commands); + + return MySQLStarted::from(containerStarted: $containerStarted); + } + + public function run( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): MySQLContainerStarted { + $containerStarted = $this->container->run(commands: $commands); + + $condition = MySQLReady::from(container: $containerStarted); + ContainerWaitForDependency::untilReady( + condition: $condition, + timeoutInSeconds: $this->readinessTimeoutInSeconds + )->waitBefore(); + + $environmentVariables = $containerStarted->getEnvironmentVariables(); + $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); + $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); + + if (!empty($database) || !empty($this->grantedHosts)) { + $containerStarted->executeAfterStarted( + commands: [MySQLCommands::setupDatabase( + database: $database, + rootPassword: $rootPassword, + grantedHosts: $this->grantedHosts + )] + ); + } + + return MySQLStarted::from(containerStarted: $containerStarted); + } } diff --git a/src/Waits/ContainerWaitForDependency.php b/src/Waits/ContainerWaitForDependency.php index 3e74f86..dca8209 100644 --- a/src/Waits/ContainerWaitForDependency.php +++ b/src/Waits/ContainerWaitForDependency.php @@ -30,10 +30,10 @@ public static function untilReady( public function waitBefore(): void { - $deadline = microtime(as_float: true) + $this->timeoutInSeconds; + $deadline = microtime(true) + $this->timeoutInSeconds; while (!$this->condition->isReady()) { - if (microtime(as_float: true) >= $deadline) { + if (microtime(true) >= $deadline) { throw new ContainerWaitTimeout(timeoutInSeconds: $this->timeoutInSeconds); } diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index 63d2e93..af52187 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -40,9 +40,9 @@ public function testMultipleContainersAreRunSuccessfully(): void $address = $mySQLContainer->getAddress(); $port = $address->getPorts()->firstExposedPort(); - self::assertSame('test-database', $mySQLContainer->getName()); - self::assertSame(3306, $port); - self::assertSame(self::DATABASE, $database); + self::assertSame(expected: 'test-database', actual: $mySQLContainer->getName()); + self::assertSame(expected: 3306, actual: $port); + self::assertSame(expected: self::DATABASE, actual: $database); /** @Given a Flyway container is configured to perform database migrations */ $jdbcUrl = $mySQLContainer->getJdbcUrl(); @@ -73,12 +73,13 @@ public function testMultipleContainersAreRunSuccessfully(): void waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 7) ); + /** @And the Flyway container should be running */ self::assertNotEmpty($flywayContainer->getName()); /** @Then the Flyway container should execute the migrations successfully */ - $actual = MySQLRepository::connectFrom(container: $mySQLContainer)->allRecordsFrom(table: 'xpto'); + $records = MySQLRepository::connectFrom(container: $mySQLContainer)->allRecordsFrom(table: 'xpto'); - self::assertCount(10, $actual); + self::assertCount(expectedCount: 10, haystack: $records); } public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void @@ -93,22 +94,25 @@ public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void $firstRun = $container->runIfNotExists(); /** @Then the container should be successfully started */ - self::assertSame('123', $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); + self::assertSame(expected: '123', actual: $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); /** @And when the same container is started again */ $secondRun = GenericDockerContainer::from(image: 'php:fpm-alpine', name: 'test-container') ->runIfNotExists(); /** @Then the container should not be restarted */ - self::assertSame($firstRun->getId(), $secondRun->getId()); - self::assertSame($firstRun->getName(), $secondRun->getName()); - self::assertEquals($firstRun->getAddress(), $secondRun->getAddress()); - self::assertEquals($firstRun->getEnvironmentVariables(), $secondRun->getEnvironmentVariables()); + self::assertSame(expected: $firstRun->getId(), actual: $secondRun->getId()); + self::assertSame(expected: $firstRun->getName(), actual: $secondRun->getName()); + self::assertEquals(expected: $firstRun->getAddress(), actual: $secondRun->getAddress()); + self::assertEquals( + expected: $firstRun->getEnvironmentVariables(), + actual: $secondRun->getEnvironmentVariables() + ); /** @And when the container is stopped */ - $actual = $firstRun->stop(); + $stopped = $firstRun->stop(); /** @Then the stop operation should be successful */ - self::assertTrue($actual->isSuccessful()); + self::assertTrue($stopped->isSuccessful()); } } diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index 299d5b5..e4e8936 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -9,7 +9,6 @@ use Test\Unit\Mocks\ClientMock; use Test\Unit\Mocks\InspectResponseFixture; use Test\Unit\Mocks\TestableGenericDockerContainer; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; use TinyBlocks\DockerContainer\GenericDockerContainer; use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -37,11 +36,11 @@ public function testRunContainerSuccessfully(): void ); /** @And the Docker daemon returns a valid container ID and inspect response */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'test-alpine', - env: ['PATH=/usr/local/bin'] + environment: ['PATH=/usr/local/bin'] ) ); @@ -49,11 +48,10 @@ public function testRunContainerSuccessfully(): void $started = $container->run(); /** @Then the container should be running with the expected properties */ - self::assertInstanceOf(ContainerStarted::class, $started); - self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); - self::assertSame('test-alpine', $started->getName()); - self::assertSame('test-alpine', $started->getAddress()->getHostname()); - self::assertSame('172.22.0.2', $started->getAddress()->getIp()); + self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); + self::assertSame(expected: 'test-alpine', actual: $started->getName()); + self::assertSame(expected: 'test-alpine', actual: $started->getAddress()->getHostname()); + self::assertSame(expected: '172.22.0.2', actual: $started->getAddress()->getIp()); } public function testRunContainerWithFullConfiguration(): void @@ -71,12 +69,12 @@ public function testRunContainerWithFullConfiguration(): void ->withEnvironmentVariable(key: 'NGINX_PORT', value: '80'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'web-server', + environment: ['NGINX_HOST=localhost', 'NGINX_PORT=80'], networkName: 'my-network', - env: ['NGINX_HOST=localhost', 'NGINX_PORT=80'], exposedPorts: ['80/tcp' => (object)[]] ) ); @@ -85,12 +83,17 @@ public function testRunContainerWithFullConfiguration(): void $started = $container->run(); /** @Then the container should expose the configured environment variables */ - self::assertSame('localhost', $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_HOST')); - self::assertSame('80', $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_PORT')); + self::assertSame( + expected: 'localhost', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'NGINX_HOST' + ) + ); + self::assertSame(expected: '80', actual: $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_PORT')); /** @And the address should reflect the exposed port */ - self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort()); - self::assertSame([80], $started->getAddress()->getPorts()->exposedPorts()); + self::assertSame(expected: 80, actual: $started->getAddress()->getPorts()->firstExposedPort()); + self::assertSame(expected: [80], actual: $started->getAddress()->getPorts()->exposedPorts()); } public function testRunContainerWithMultiplePortMappings(): void @@ -105,9 +108,9 @@ public function testRunContainerWithMultiplePortMappings(): void ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'multi-port', exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]] ) @@ -117,8 +120,8 @@ public function testRunContainerWithMultiplePortMappings(): void $started = $container->run(); /** @Then both ports should be exposed */ - self::assertSame([80, 443], $started->getAddress()->getPorts()->exposedPorts()); - self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort()); + self::assertSame(expected: [80, 443], actual: $started->getAddress()->getPorts()->exposedPorts()); + self::assertSame(expected: 80, actual: $started->getAddress()->getPorts()->firstExposedPort()); } public function testRunContainerWithoutAutoRemove(): void @@ -131,14 +134,14 @@ public function testRunContainerWithoutAutoRemove(): void )->withoutAutoRemove(); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'persistent')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'persistent')); /** @When the container is started */ $started = $container->run(); /** @Then the container should be running */ - self::assertSame('persistent', $started->getName()); + self::assertSame(expected: 'persistent', actual: $started->getName()); } public function testRunContainerWithCopyToContainer(): void @@ -151,14 +154,14 @@ public function testRunContainerWithCopyToContainer(): void )->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'copy-test')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'copy-test')); /** @When the container is started (docker cp is automatically called) */ $started = $container->run(); /** @Then the container should be running */ - self::assertSame('copy-test', $started->getName()); + self::assertSame(expected: 'copy-test', actual: $started->getName()); } public function testRunContainerWithCommands(): void @@ -171,14 +174,14 @@ public function testRunContainerWithCommands(): void ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'cmd-test')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'cmd-test')); /** @When the container is started with commands */ $started = $container->run(commands: ['echo', 'hello']); /** @Then the container should be running */ - self::assertSame('cmd-test', $started->getName()); + self::assertSame(expected: 'cmd-test', actual: $started->getName()); } public function testRunContainerWithWaitBeforeRun(): void @@ -195,14 +198,14 @@ public function testRunContainerWithWaitBeforeRun(): void )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-test')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-test')); /** @When the container is started */ $started = $container->run(); /** @Then the container should be running (wait was called) */ - self::assertSame('wait-test', $started->getName()); + self::assertSame(expected: 'wait-test', actual: $started->getName()); } public function testRunIfNotExistsCreatesNewContainer(): void @@ -215,14 +218,14 @@ public function testRunIfNotExistsCreatesNewContainer(): void )->withEnvironmentVariable(key: 'APP_ENV', value: 'test'); /** @And the Docker list returns empty (container does not exist) */ - $this->client->withDockerListResponse(data: ''); + $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'new-container', - env: ['APP_ENV=test'] + environment: ['APP_ENV=test'] ) ); @@ -230,8 +233,8 @@ public function testRunIfNotExistsCreatesNewContainer(): void $started = $container->runIfNotExists(); /** @Then a new container should be created */ - self::assertSame('new-container', $started->getName()); - self::assertSame('test', $started->getEnvironmentVariables()->getValueBy(key: 'APP_ENV')); + self::assertSame(expected: 'new-container', actual: $started->getName()); + self::assertSame(expected: 'test', actual: $started->getEnvironmentVariables()->getValueBy(key: 'APP_ENV')); } public function testRunIfNotExistsReturnsExistingContainer(): void @@ -244,13 +247,13 @@ public function testRunIfNotExistsReturnsExistingContainer(): void ); /** @And the Docker list returns the existing container ID */ - $this->client->withDockerListResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); /** @And the Docker inspect returns the container details */ $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'existing', - env: ['EXISTING=true'] + environment: ['EXISTING=true'] ) ); @@ -258,9 +261,9 @@ public function testRunIfNotExistsReturnsExistingContainer(): void $started = $container->runIfNotExists(); /** @Then the existing container should be returned */ - self::assertSame('existing', $started->getName()); - self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); - self::assertSame('true', $started->getEnvironmentVariables()->getValueBy(key: 'EXISTING')); + self::assertSame(expected: 'existing', actual: $started->getName()); + self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); + self::assertSame(expected: 'true', actual: $started->getEnvironmentVariables()->getValueBy(key: 'EXISTING')); } public function testStopContainer(): void @@ -272,17 +275,19 @@ public function testStopContainer(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-test')); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-test')); $this->client->withDockerStopResponse(output: ''); + /** @And the container is started */ $started = $container->run(); /** @When the container is stopped */ - $result = $started->stop(); + $stopped = $started->stop(); /** @Then the stop should be successful */ - self::assertTrue($result->isSuccessful()); + self::assertTrue($stopped->isSuccessful()); } public function testExecuteAfterStarted(): void @@ -294,18 +299,20 @@ public function testExecuteAfterStarted(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-test')); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-test')); $this->client->withDockerExecuteResponse(output: 'command output'); + /** @And the container is started */ $started = $container->run(); /** @When commands are executed inside the running container */ - $result = $started->executeAfterStarted(commands: ['ls', '-la']); + $execution = $started->executeAfterStarted(commands: ['ls', '-la']); /** @Then the execution should be successful */ - self::assertTrue($result->isSuccessful()); - self::assertSame('command output', $result->getOutput()); + self::assertTrue($execution->isSuccessful()); + self::assertSame(expected: 'command output', actual: $execution->getOutput()); } public function testExceptionWhenRunFails(): void @@ -318,7 +325,7 @@ public function testExceptionWhenRunFails(): void ); /** @And the Docker daemon returns a failure */ - $this->client->withDockerRunResponse(data: 'Cannot connect to the Docker daemon.', isSuccessful: false); + $this->client->withDockerRunResponse(output: 'Cannot connect to the Docker daemon.', isSuccessful: false); /** @Then a DockerCommandExecutionFailed exception should be thrown */ $this->expectException(DockerCommandExecutionFailed::class); @@ -337,8 +344,9 @@ public function testExceptionWhenContainerInspectReturnsEmpty(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: []); + /** @And the Docker daemon returns a valid ID but empty inspect */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: []); /** @Then a DockerContainerNotFound exception should be thrown */ $this->expectException(DockerContainerNotFound::class); @@ -357,9 +365,10 @@ public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + /** @And the Docker daemon returns a response with empty address data */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: '', ipAddress: '' ) @@ -369,8 +378,8 @@ public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void $started = $container->run(); /** @Then the address should fall back to defaults */ - self::assertSame('127.0.0.1', $started->getAddress()->getIp()); - self::assertSame('localhost', $started->getAddress()->getHostname()); + self::assertSame(expected: '127.0.0.1', actual: $started->getAddress()->getIp()); + self::assertSame(expected: 'localhost', actual: $started->getAddress()->getHostname()); } public function testContainerWithNoExposedPortsReturnsNull(): void @@ -382,8 +391,9 @@ public function testContainerWithNoExposedPortsReturnsNull(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'no-ports')); + /** @And the Docker daemon returns valid responses without exposed ports */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-ports')); /** @When the container is started */ $started = $container->run(); @@ -402,21 +412,23 @@ public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'env-test', - env: ['KNOWN=value'] + environment: ['KNOWN=value'] ) ); + /** @And the container is started */ $started = $container->run(); /** @When querying for a missing environment variable */ - $actual = $started->getEnvironmentVariables()->getValueBy(key: 'MISSING'); + $missingValue = $started->getEnvironmentVariables()->getValueBy(key: 'MISSING'); /** @Then it should return an empty string */ - self::assertSame('', $actual); + self::assertSame(expected: '', actual: $missingValue); } public function testRunContainerWithAutoGeneratedName(): void @@ -429,8 +441,12 @@ public function testRunContainerWithAutoGeneratedName(): void ); /** @And the Docker daemon returns valid responses (with any hostname from KSUID) */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'auto-generated')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'auto-generated' + ) + ); /** @When the container is started */ $started = $container->run(); @@ -449,17 +465,17 @@ public function testRunContainerWithWaitAfterStarted(): void ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-after')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-after')); /** @When the container is started with a wait-after condition */ - $start = microtime(as_float: true); + $start = microtime(true); $started = $container->run(waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 1)); - $elapsed = microtime(as_float: true) - $start; + $elapsed = microtime(true) - $start; /** @Then the container should have waited after starting */ - self::assertSame('wait-after', $started->getName()); - self::assertGreaterThanOrEqual(0.9, $elapsed); + self::assertSame(expected: 'wait-after', actual: $started->getName()); + self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); } public function testStopContainerWithCustomTimeout(): void @@ -471,17 +487,21 @@ public function testStopContainerWithCustomTimeout(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-timeout')); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'stop-timeout') + ); $this->client->withDockerStopResponse(output: ''); + /** @And the container is started */ $started = $container->run(); /** @When the container is stopped with a custom timeout */ - $result = $started->stop(timeoutInWholeSeconds: 10); + $stopped = $started->stop(timeoutInWholeSeconds: 10); /** @Then the stop should be successful */ - self::assertTrue($result->isSuccessful()); + self::assertTrue($stopped->isSuccessful()); } public function testExecuteAfterStartedReturnsFailure(): void @@ -493,18 +513,20 @@ public function testExecuteAfterStartedReturnsFailure(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-fail')); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-fail')); $this->client->withDockerExecuteResponse(output: 'command not found', isSuccessful: false); + /** @And the container is started */ $started = $container->run(); /** @When an invalid command is executed */ - $result = $started->executeAfterStarted(commands: ['invalid-command']); + $execution = $started->executeAfterStarted(commands: ['invalid-command']); /** @Then the result should indicate failure */ - self::assertFalse($result->isSuccessful()); - self::assertSame('command not found', $result->getOutput()); + self::assertFalse($execution->isSuccessful()); + self::assertSame(expected: 'command not found', actual: $execution->getOutput()); } public function testRunIfNotExistsWithWaitBeforeRun(): void @@ -521,17 +543,17 @@ public function testRunIfNotExistsWithWaitBeforeRun(): void )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); /** @And the Docker list returns empty */ - $this->client->withDockerListResponse(data: ''); + $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'wait-new')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-new')); /** @When runIfNotExists is called */ $started = $container->runIfNotExists(); /** @Then the wait-before-run should have been evaluated and the container created */ - self::assertSame('wait-new', $started->getName()); + self::assertSame(expected: 'wait-new', actual: $started->getName()); } public function testExceptionWhenWaitBeforeRunTimesOut(): void @@ -572,14 +594,14 @@ public function testRunContainerWithMultipleVolumeMappings(): void ->withVolumeMapping(pathOnHost: '/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-vol')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-vol')); /** @When the container is started */ $started = $container->run(); /** @Then the container should be running */ - self::assertSame('multi-vol', $started->getName()); + self::assertSame(expected: 'multi-vol', actual: $started->getName()); } public function testRunContainerWithMultipleEnvironmentVariables(): void @@ -595,11 +617,11 @@ public function testRunContainerWithMultipleEnvironmentVariables(): void ->withEnvironmentVariable(key: 'DB_NAME', value: 'mydb'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'multi-env', - env: ['DB_HOST=localhost', 'DB_PORT=5432', 'DB_NAME=mydb'] + environment: ['DB_HOST=localhost', 'DB_PORT=5432', 'DB_NAME=mydb'] ) ); @@ -607,9 +629,12 @@ public function testRunContainerWithMultipleEnvironmentVariables(): void $started = $container->run(); /** @Then all environment variables should be accessible */ - self::assertSame('localhost', $started->getEnvironmentVariables()->getValueBy(key: 'DB_HOST')); - self::assertSame('5432', $started->getEnvironmentVariables()->getValueBy(key: 'DB_PORT')); - self::assertSame('mydb', $started->getEnvironmentVariables()->getValueBy(key: 'DB_NAME')); + self::assertSame( + expected: 'localhost', + actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_HOST') + ); + self::assertSame(expected: '5432', actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_PORT')); + self::assertSame(expected: 'mydb', actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_NAME')); } public function testRunContainerWithMultipleCopyInstructions(): void @@ -624,14 +649,14 @@ public function testRunContainerWithMultipleCopyInstructions(): void ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-copy')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-copy')); /** @When the container is started */ $started = $container->run(); /** @Then the container should be running (both docker cp calls were made) */ - self::assertSame('multi-copy', $started->getName()); + self::assertSame(expected: 'multi-copy', actual: $started->getName()); } public function testExceptionWhenImageNameIsEmpty(): void @@ -654,7 +679,7 @@ public function testExceptionWhenDockerReturnsEmptyContainerId(): void ); /** @And the Docker daemon returns an empty container ID */ - $this->client->withDockerRunResponse(data: ' '); + $this->client->withDockerRunResponse(output: ' '); /** @Then an InvalidArgumentException should be thrown */ $this->expectException(InvalidArgumentException::class); @@ -674,7 +699,7 @@ public function testExceptionWhenDockerReturnsTooShortContainerId(): void ); /** @And the Docker daemon returns a too-short container ID */ - $this->client->withDockerRunResponse(data: 'abc123'); + $this->client->withDockerRunResponse(output: 'abc123'); /** @Then an InvalidArgumentException should be thrown */ $this->expectException(InvalidArgumentException::class); @@ -694,15 +719,15 @@ public function testRunCommandLineIncludesPortMapping(): void )->withPortMapping(portOnHost: 8080, portOnContainer: 80); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'port-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'port-cmd')); /** @When the container is started */ $container->run(); /** @Then the executed docker run command should contain the port mapping argument */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('--publish 8080:80', $runCommand); + self::assertStringContainsString(needle: '--publish 8080:80', haystack: $runCommand); } public function testRunCommandLineIncludesMultiplePortMappings(): void @@ -717,16 +742,20 @@ public function testRunCommandLineIncludesMultiplePortMappings(): void ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'multi-port-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'multi-port-cmd' + ) + ); /** @When the container is started */ $container->run(); /** @Then the docker run command should contain both port mapping arguments */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('--publish 8080:80', $runCommand); - self::assertStringContainsString('--publish 8443:443', $runCommand); + self::assertStringContainsString(needle: '--publish 8080:80', haystack: $runCommand); + self::assertStringContainsString(needle: '--publish 8443:443', haystack: $runCommand); } public function testRunCommandLineIncludesVolumeMapping(): void @@ -739,15 +768,15 @@ public function testRunCommandLineIncludesVolumeMapping(): void )->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/app/data'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'vol-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'vol-cmd')); /** @When the container is started */ $container->run(); /** @Then the docker run command should contain the volume mapping argument */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('--volume /host/data:/app/data', $runCommand); + self::assertStringContainsString(needle: '--volume /host/data:/app/data', haystack: $runCommand); } public function testRunCommandLineIncludesEnvironmentVariable(): void @@ -760,15 +789,15 @@ public function testRunCommandLineIncludesEnvironmentVariable(): void )->withEnvironmentVariable(key: 'APP_ENV', value: 'production'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'env-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'env-cmd')); /** @When the container is started */ $container->run(); /** @Then the docker run command should contain the environment variable argument */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString("--env APP_ENV='production'", $runCommand); + self::assertStringContainsString(needle: "--env APP_ENV='production'", haystack: $runCommand); } public function testRunCommandLineIncludesNetwork(): void @@ -781,15 +810,19 @@ public function testRunCommandLineIncludesNetwork(): void )->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'net-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'net-cmd')); /** @When the container is started */ $container->run(); - /** @Then the docker run command should contain the network argument */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('--network=my-network', $runCommand); + /** @Then the first command should be the network creation */ + $networkCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString(needle: 'docker network create my-network', haystack: $networkCommand); + + /** @And the docker run command should contain the network argument */ + $runCommand = $this->client->getExecutedCommandLines()[1]; + self::assertStringContainsString(needle: '--network=my-network', haystack: $runCommand); } public function testRunCommandLineIncludesAutoRemoveByDefault(): void @@ -802,15 +835,15 @@ public function testRunCommandLineIncludesAutoRemoveByDefault(): void ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'rm-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'rm-cmd')); /** @When the container is started */ $container->run(); /** @Then the docker run command should contain --rm */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('--rm', $runCommand); + self::assertStringContainsString(needle: '--rm', haystack: $runCommand); } public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void @@ -823,15 +856,15 @@ public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void )->withoutAutoRemove(); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'no-rm-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-rm-cmd')); /** @When the container is started */ $container->run(); /** @Then the docker run command should NOT contain --rm */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringNotContainsString('--rm', $runCommand); + self::assertStringNotContainsString(needle: '--rm', haystack: $runCommand); } public function testRunCommandLineIncludesCommands(): void @@ -844,15 +877,15 @@ public function testRunCommandLineIncludesCommands(): void ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'args-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'args-cmd')); /** @When the container is started with commands */ $container->run(commands: ['-connectRetries=15', 'clean', 'migrate']); /** @Then the docker run command should end with the commands */ $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString('-connectRetries=15 clean migrate', $runCommand); + self::assertStringContainsString(needle: '-connectRetries=15 clean migrate', haystack: $runCommand); } public function testCopyToContainerExecutesDockerCpCommand(): void @@ -865,17 +898,17 @@ public function testCopyToContainerExecutesDockerCpCommand(): void )->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'cp-cmd')); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'cp-cmd')); /** @When the container is started */ $container->run(); /** @Then the second executed command should be a docker cp with the correct arguments */ - $cpCommand = $this->client->getExecutedCommandLines()[2]; - self::assertStringStartsWith('docker cp', $cpCommand); - self::assertStringContainsString('/host/sql', $cpCommand); - self::assertStringContainsString('/app/sql', $cpCommand); + $copyCommand = $this->client->getExecutedCommandLines()[2]; + self::assertStringStartsWith(prefix: 'docker cp', string: $copyCommand); + self::assertStringContainsString(needle: '/host/sql', haystack: $copyCommand); + self::assertStringContainsString(needle: '/app/sql', haystack: $copyCommand); } public function testStopExecutesDockerStopCommand(): void @@ -887,10 +920,12 @@ public function testStopExecutesDockerStopCommand(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'stop-cmd')); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-cmd')); $this->client->withDockerStopResponse(output: ''); + /** @And the container is started */ $started = $container->run(); /** @When the container is stopped */ @@ -898,8 +933,8 @@ public function testStopExecutesDockerStopCommand(): void /** @Then a docker stop command should have been executed with the container ID */ $stopCommand = $this->client->getExecutedCommandLines()[2]; - self::assertStringStartsWith('docker stop', $stopCommand); - self::assertStringContainsString(InspectResponseFixture::shortContainerId(), $stopCommand); + self::assertStringStartsWith(prefix: 'docker stop', string: $stopCommand); + self::assertStringContainsString(needle: InspectResponseFixture::shortContainerId(), haystack: $stopCommand); } public function testExecuteAfterStartedRunsDockerExecCommand(): void @@ -911,10 +946,12 @@ public function testExecuteAfterStartedRunsDockerExecCommand(): void client: $this->client ); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(data: InspectResponseFixture::build(hostname: 'exec-cmd')); - $this->client->withDockerExecuteResponse(output: '', isSuccessful: true); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-cmd')); + $this->client->withDockerExecuteResponse(output: ''); + /** @And the container is started */ $started = $container->run(); /** @When executing commands inside the container */ @@ -922,6 +959,26 @@ public function testExecuteAfterStartedRunsDockerExecCommand(): void /** @Then a docker exec command should have been executed with the container name and commands */ $execCommand = $this->client->getExecutedCommandLines()[2]; - self::assertSame('docker exec exec-cmd ls -la /tmp', $execCommand); + self::assertSame(expected: 'docker exec exec-cmd ls -la /tmp', actual: $execCommand); + } + + public function testRunContainerWithPullImage(): void + { + /** @Given a container with image pulling enabled */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'pull-test', + client: $this->client + )->pullImage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'pull-test')); + + /** @When the container is started (waiting for the image pull to complete first) */ + $started = $container->run(); + + /** @Then the container should be running */ + self::assertSame(expected: 'pull-test', actual: $started->getName()); } } diff --git a/tests/Unit/Internal/Client/DockerClientTest.php b/tests/Unit/Internal/Client/DockerClientTest.php index 20159ee..e898c10 100644 --- a/tests/Unit/Internal/Client/DockerClientTest.php +++ b/tests/Unit/Internal/Client/DockerClientTest.php @@ -25,11 +25,11 @@ public function testExecuteCommandSuccessfully(): void $command = new CommandMock(command: 'echo Hello'); /** @When the command is executed */ - $actual = $this->client->execute(command: $command); + $execution = $this->client->execute(command: $command); /** @Then the output should contain the expected result */ - self::assertTrue($actual->isSuccessful()); - self::assertStringContainsString('Hello', $actual->getOutput()); + self::assertTrue($execution->isSuccessful()); + self::assertStringContainsString(needle: 'Hello', haystack: $execution->getOutput()); } public function testExecuteCommandWithValidTimeout(): void @@ -38,10 +38,10 @@ public function testExecuteCommandWithValidTimeout(): void $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: 10); /** @When the command is executed */ - $actual = $this->client->execute(command: $command); + $execution = $this->client->execute(command: $command); /** @Then the execution should succeed */ - self::assertTrue($actual->isSuccessful()); + self::assertTrue($execution->isSuccessful()); } public function testExceptionFromProcessWhenTimeoutIsInvalid(): void @@ -63,10 +63,10 @@ public function testExecuteCommandReturnsErrorOutput(): void $command = new CommandMock(command: 'cat /nonexistent/file/path'); /** @When the command is executed */ - $actual = $this->client->execute(command: $command); + $execution = $this->client->execute(command: $command); /** @Then the execution should indicate failure */ - self::assertFalse($actual->isSuccessful()); - self::assertNotEmpty($actual->getOutput()); + self::assertFalse($execution->isSuccessful()); + self::assertNotEmpty($execution->getOutput()); } } diff --git a/tests/Unit/Mocks/ClientMock.php b/tests/Unit/Mocks/ClientMock.php index 8d7a0fe..5a7e559 100644 --- a/tests/Unit/Mocks/ClientMock.php +++ b/tests/Unit/Mocks/ClientMock.php @@ -17,40 +17,34 @@ final class ClientMock implements Client { - /** @var array */ private array $runResponses = []; - /** @var array */ private array $listResponses = []; - /** @var array> */ private array $inspectResponses = []; - /** @var array */ private array $executeResponses = []; - /** @var array */ private array $stopResponses = []; - /** @var array */ private array $executedCommandLines = []; private bool $runIsSuccessful = true; - public function withDockerRunResponse(string $data, bool $isSuccessful = true): void + public function withDockerRunResponse(string $output, bool $isSuccessful = true): void { - $this->runResponses[] = $data; + $this->runResponses[] = $output; $this->runIsSuccessful = $isSuccessful; } - public function withDockerListResponse(string $data): void + public function withDockerListResponse(string $output): void { - $this->listResponses[] = $data; + $this->listResponses[] = $output; } - public function withDockerInspectResponse(array $data): void + public function withDockerInspectResponse(array $inspectResult): void { - $this->inspectResponses[] = $data; + $this->inspectResponses[] = $inspectResult; } public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void @@ -77,48 +71,36 @@ public function execute(Command $command): ExecutionCompleted { $this->executedCommandLines[] = $command->toCommandLine(); - [$output, $isSuccessful] = match (true) { - $command instanceof DockerRun => [array_shift($this->runResponses) ?? '', $this->runIsSuccessful], - $command instanceof DockerList => $this->resolveListResponse(), - $command instanceof DockerInspect => $this->resolveInspectResponse(), - $command instanceof DockerCopy => ['', true], - $command instanceof DockerExecute => $this->resolveExecuteResponse(), - $command instanceof DockerStop => $this->resolveStopResponse(), - default => ['', false] - }; - - return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); - } - - private function resolveListResponse(): array - { - $data = array_shift($this->listResponses) ?? ''; + if ($command instanceof DockerExecute) { + $response = array_shift($this->executeResponses); - return [$data, !empty($data)]; - } + if ($response instanceof Throwable) { + throw $response; + } - private function resolveInspectResponse(): array - { - $data = array_shift($this->inspectResponses); + [$output, $isSuccessful] = $response ?? ['', true]; - return [json_encode([$data]), !empty($data)]; - } - - private function resolveExecuteResponse(): array - { - $response = array_shift($this->executeResponses); - - if ($response instanceof Throwable) { - throw $response; + return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); } - return $response ?? ['', true]; - } - - private function resolveStopResponse(): array - { - $response = array_shift($this->stopResponses); + [$output, $isSuccessful] = match (true) { + $command instanceof DockerRun => [ + array_shift($this->runResponses) ?? '', + $this->runIsSuccessful + ], + $command instanceof DockerList => [ + ($listOutput = array_shift($this->listResponses) ?? ''), + !empty($listOutput) + ], + $command instanceof DockerInspect => [ + json_encode([($inspectData = array_shift($this->inspectResponses))]), + !empty($inspectData) + ], + $command instanceof DockerCopy => ['', true], + $command instanceof DockerStop => array_shift($this->stopResponses) ?? ['', true], + default => ['', false] + }; - return $response ?? ['', true]; + return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); } } diff --git a/tests/Unit/Mocks/InspectResponseFixture.php b/tests/Unit/Mocks/InspectResponseFixture.php index 790e7c9..58f1803 100644 --- a/tests/Unit/Mocks/InspectResponseFixture.php +++ b/tests/Unit/Mocks/InspectResponseFixture.php @@ -6,16 +6,22 @@ final readonly class InspectResponseFixture { - /** - * @param array $env - * @param array $exposedPorts - */ + public static function containerId(): string + { + return '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'; + } + + public static function shortContainerId(): string + { + return '6acae5967be0'; + } + public static function build( string $id = '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8', string $hostname = 'alpine', string $ipAddress = '172.22.0.2', + array $environment = [], string $networkName = 'bridge', - array $env = [], array $exposedPorts = [] ): array { return [ @@ -24,7 +30,7 @@ public static function build( 'Config' => [ 'Hostname' => $hostname, 'ExposedPorts' => $exposedPorts, - 'Env' => $env + 'Env' => $environment ], 'NetworkSettings' => [ 'Networks' => [ @@ -35,14 +41,4 @@ public static function build( ] ]; } - - public static function containerId(): string - { - return '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'; - } - - public static function shortContainerId(): string - { - return '6acae5967be0'; - } } diff --git a/tests/Unit/MySQLDockerContainerTest.php b/tests/Unit/MySQLDockerContainerTest.php index 033adce..c768847 100644 --- a/tests/Unit/MySQLDockerContainerTest.php +++ b/tests/Unit/MySQLDockerContainerTest.php @@ -38,23 +38,23 @@ public function testRunMySQLContainerSuccessfully(): void ->withDatabase(database: 'test_adm') ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) ->withRootPassword(rootPassword: 'root') - ->withGrantedHosts(hosts: ['%', '172.%']) + ->withGrantedHosts() ->withoutAutoRemove() ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'test-db', - networkName: 'my-net', - env: [ + environment: [ 'TZ=America/Sao_Paulo', 'MYSQL_USER=app_user', 'MYSQL_PASSWORD=secret', 'MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root' ], + networkName: 'my-net', exposedPorts: ['3306/tcp' => (object)[]] ) ); @@ -62,29 +62,44 @@ public function testRunMySQLContainerSuccessfully(): void /** @And the MySQL readiness check succeeds */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - /** @And the CREATE DATABASE command succeeds */ - $this->client->withDockerExecuteResponse(output: ''); - - /** @And the GRANT PRIVILEGES commands succeed (one per host) */ - $this->client->withDockerExecuteResponse(output: ''); + /** @And the database setup command succeeds */ $this->client->withDockerExecuteResponse(output: ''); /** @When the MySQL container is started */ $started = $container->run(); /** @Then it should return a MySQLContainerStarted instance */ - self::assertInstanceOf(MySQLContainerStarted::class, $started); - self::assertSame('test-db', $started->getName()); - self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); + self::assertSame(expected: 'test-db', actual: $started->getName()); + self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); /** @And the environment variables should be accessible */ - self::assertSame('test_adm', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE')); - self::assertSame('app_user', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_USER')); - self::assertSame('secret', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_PASSWORD')); - self::assertSame('root', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_ROOT_PASSWORD')); + self::assertSame( + expected: 'test_adm', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'MYSQL_DATABASE' + ) + ); + self::assertSame( + expected: 'app_user', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'MYSQL_USER' + ) + ); + self::assertSame( + expected: 'secret', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'MYSQL_PASSWORD' + ) + ); + self::assertSame( + expected: 'root', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'MYSQL_ROOT_PASSWORD' + ) + ); /** @And the port should be exposed */ - self::assertSame(3306, $started->getAddress()->getPorts()->firstExposedPort()); + self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort()); } public function testRunIfNotExistsReturnsMySQLContainerStarted(): void @@ -99,11 +114,11 @@ public function testRunIfNotExistsReturnsMySQLContainerStarted(): void ->withRootPassword(rootPassword: 'root'); /** @And the container already exists */ - $this->client->withDockerListResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'existing-db', - env: ['MYSQL_DATABASE=my_db', 'MYSQL_ROOT_PASSWORD=root'], + environment: ['MYSQL_DATABASE=my_db', 'MYSQL_ROOT_PASSWORD=root'], exposedPorts: ['3306/tcp' => (object)[]] ) ); @@ -112,8 +127,7 @@ public function testRunIfNotExistsReturnsMySQLContainerStarted(): void $started = $container->runIfNotExists(); /** @Then it should return a MySQLContainerStarted wrapping the existing container */ - self::assertInstanceOf(MySQLContainerStarted::class, $started); - self::assertSame('existing-db', $started->getName()); + self::assertSame(expected: 'existing-db', actual: $started->getName()); } public function testRunIfNotExistsCreatesNewMySQLContainer(): void @@ -128,14 +142,14 @@ public function testRunIfNotExistsCreatesNewMySQLContainer(): void ->withRootPassword(rootPassword: 'root'); /** @And the Docker list returns empty (container does not exist) */ - $this->client->withDockerListResponse(data: ''); + $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'new-db', - env: ['MYSQL_DATABASE=new_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=new_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); @@ -147,8 +161,7 @@ public function testRunIfNotExistsCreatesNewMySQLContainer(): void $started = $container->runIfNotExists(); /** @Then a new container should be created */ - self::assertInstanceOf(MySQLContainerStarted::class, $started); - self::assertSame('new-db', $started->getName()); + self::assertSame(expected: 'new-db', actual: $started->getName()); } public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): void @@ -164,11 +177,11 @@ public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): vo ->withReadinessTimeout(timeoutInSeconds: 10); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'retry-db', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); @@ -184,7 +197,7 @@ public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): vo $started = $container->run(); /** @Then the container should start after retries */ - self::assertSame('retry-db', $started->getName()); + self::assertSame(expected: 'retry-db', actual: $started->getName()); } public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): void @@ -200,11 +213,11 @@ public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): ->withReadinessTimeout(timeoutInSeconds: 10); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'exception-db', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); @@ -221,7 +234,7 @@ public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): $started = $container->run(); /** @Then the container should start after the exception was caught and retried */ - self::assertSame('exception-db', $started->getName()); + self::assertSame(expected: 'exception-db', actual: $started->getName()); } public function testRunMySQLContainerWithSingleGrantedHost(): void @@ -237,24 +250,23 @@ public function testRunMySQLContainerWithSingleGrantedHost(): void ->withGrantedHosts(hosts: ['%']); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'single-grant', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And readiness, CREATE DATABASE, and one GRANT PRIVILEGES succeed */ + /** @And readiness and database setup succeed */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); $this->client->withDockerExecuteResponse(output: ''); - $this->client->withDockerExecuteResponse(output: ''); /** @When the container is started */ $started = $container->run(); /** @Then the container should start successfully */ - self::assertSame('single-grant', $started->getName()); + self::assertSame(expected: 'single-grant', actual: $started->getName()); } public function testRunMySQLContainerWithCopyToContainer(): void @@ -269,11 +281,11 @@ public function testRunMySQLContainerWithCopyToContainer(): void ->copyToContainer(pathOnHost: '/host/init', pathOnContainer: '/docker-entrypoint-initdb.d'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'copy-db', - env: ['MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); @@ -284,7 +296,7 @@ public function testRunMySQLContainerWithCopyToContainer(): void $started = $container->run(); /** @Then the container should be running with copy instructions executed */ - self::assertSame('copy-db', $started->getName()); + self::assertSame(expected: 'copy-db', actual: $started->getName()); } public function testRunMySQLContainerWithWaitBeforeRun(): void @@ -293,6 +305,7 @@ public function testRunMySQLContainerWithWaitBeforeRun(): void $condition = $this->createMock(ContainerReady::class); $condition->expects(self::once())->method('isReady')->willReturn(true); + /** @And the container is configured */ $container = TestableMySQLDockerContainer::createWith( image: 'mysql:8.1', name: 'wait-db', @@ -304,11 +317,11 @@ public function testRunMySQLContainerWithWaitBeforeRun(): void ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'wait-db', - env: ['MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); @@ -319,7 +332,7 @@ public function testRunMySQLContainerWithWaitBeforeRun(): void $started = $container->run(); /** @Then the wait-before-run condition should have been evaluated */ - self::assertSame('wait-db', $started->getName()); + self::assertSame(expected: 'wait-db', actual: $started->getName()); } public function testGetJdbcUrlWithDefaultOptions(): void @@ -332,12 +345,12 @@ public function testGetJdbcUrlWithDefaultOptions(): void ); /** @When getting the JDBC URL with default options */ - $actual = $started->getJdbcUrl(); + $jdbcUrl = $started->getJdbcUrl(); /** @Then the URL should include default JDBC options */ self::assertSame( - 'jdbc:mysql://test-db:3306/test_adm?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true', - $actual + expected: 'jdbc:mysql://test-db:3306/test_adm?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true', + actual: $jdbcUrl ); } @@ -351,10 +364,13 @@ public function testGetJdbcUrlWithCustomOptions(): void ); /** @When getting the JDBC URL with custom options */ - $actual = $started->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']); + $jdbcUrl = $started->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']); /** @Then the URL should include the custom options */ - self::assertSame('jdbc:mysql://test-db:3306/test_adm?connectTimeout=5000&useSSL=true', $actual); + self::assertSame( + expected: 'jdbc:mysql://test-db:3306/test_adm?connectTimeout=5000&useSSL=true', + actual: $jdbcUrl + ); } public function testGetJdbcUrlWithoutOptions(): void @@ -367,10 +383,10 @@ public function testGetJdbcUrlWithoutOptions(): void ); /** @When getting the JDBC URL with empty options */ - $actual = $started->getJdbcUrl(options: []); + $jdbcUrl = $started->getJdbcUrl(options: []); /** @Then the URL should not include any query string */ - self::assertSame('jdbc:mysql://test-db:3306/test_adm', $actual); + self::assertSame(expected: 'jdbc:mysql://test-db:3306/test_adm', actual: $jdbcUrl); } public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void @@ -383,10 +399,10 @@ public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void ); /** @When getting the JDBC URL */ - $actual = $started->getJdbcUrl(options: []); + $jdbcUrl = $started->getJdbcUrl(options: []); /** @Then the URL should use the default MySQL port 3306 */ - self::assertSame('jdbc:mysql://test-db:3306/test_adm', $actual); + self::assertSame(expected: 'jdbc:mysql://test-db:3306/test_adm', actual: $jdbcUrl); } public function testRunMySQLContainerWithoutDatabase(): void @@ -399,11 +415,11 @@ public function testRunMySQLContainerWithoutDatabase(): void )->withRootPassword(rootPassword: 'root'); /** @And the Docker daemon returns valid responses with no MYSQL_DATABASE */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'no-db', - env: ['MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); @@ -414,7 +430,7 @@ public function testRunMySQLContainerWithoutDatabase(): void $started = $container->run(); /** @Then the container should start without errors */ - self::assertSame('no-db', $started->getName()); + self::assertSame(expected: 'no-db', actual: $started->getName()); } public function testRunMySQLContainerWithoutGrantedHosts(): void @@ -429,11 +445,11 @@ public function testRunMySQLContainerWithoutGrantedHosts(): void ->withRootPassword(rootPassword: 'root'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'no-grants', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); @@ -445,7 +461,7 @@ public function testRunMySQLContainerWithoutGrantedHosts(): void $started = $container->run(); /** @Then the container should start without errors */ - self::assertSame('no-grants', $started->getName()); + self::assertSame(expected: 'no-grants', actual: $started->getName()); } public function testMySQLContainerDelegatesStopCorrectly(): void @@ -461,10 +477,10 @@ public function testMySQLContainerDelegatesStopCorrectly(): void $this->client->withDockerStopResponse(output: ''); /** @When the container is stopped */ - $result = $started->stop(); + $stopped = $started->stop(); /** @Then the stop should be successful */ - self::assertTrue($result->isSuccessful()); + self::assertTrue($stopped->isSuccessful()); } public function testMySQLContainerDelegatesStopWithCustomTimeout(): void @@ -480,10 +496,10 @@ public function testMySQLContainerDelegatesStopWithCustomTimeout(): void $this->client->withDockerStopResponse(output: ''); /** @When the container is stopped with a custom timeout */ - $result = $started->stop(timeoutInWholeSeconds: 10); + $stopped = $started->stop(timeoutInWholeSeconds: 10); /** @Then the stop should be successful */ - self::assertTrue($result->isSuccessful()); + self::assertTrue($stopped->isSuccessful()); } public function testMySQLContainerDelegatesExecuteAfterStarted(): void @@ -499,11 +515,11 @@ public function testMySQLContainerDelegatesExecuteAfterStarted(): void $this->client->withDockerExecuteResponse(output: 'SHOW DATABASES output'); /** @When commands are executed inside the container */ - $result = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES']); + $execution = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES']); /** @Then the execution should return the output */ - self::assertTrue($result->isSuccessful()); - self::assertSame('SHOW DATABASES output', $result->getOutput()); + self::assertTrue($execution->isSuccessful()); + self::assertSame(expected: 'SHOW DATABASES output', actual: $execution->getOutput()); } public function testMySQLContainerDelegatesGetAddress(): void @@ -519,10 +535,10 @@ public function testMySQLContainerDelegatesGetAddress(): void $address = $started->getAddress(); /** @Then the address should delegate correctly */ - self::assertSame('address-db', $address->getHostname()); - self::assertSame('172.22.0.2', $address->getIp()); - self::assertSame(3306, $address->getPorts()->firstExposedPort()); - self::assertSame([3306], $address->getPorts()->exposedPorts()); + self::assertSame(expected: 'address-db', actual: $address->getHostname()); + self::assertSame(expected: '172.22.0.2', actual: $address->getIp()); + self::assertSame(expected: 3306, actual: $address->getPorts()->firstExposedPort()); + self::assertSame(expected: [3306], actual: $address->getPorts()->exposedPorts()); } public function testExceptionWhenMySQLNeverBecomesReady(): void @@ -538,16 +554,16 @@ public function testExceptionWhenMySQLNeverBecomesReady(): void ->withReadinessTimeout(timeoutInSeconds: 1); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'stuck-db', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); /** @And the MySQL readiness check always fails */ - for ($i = 0; $i < 100; $i++) { + for ($index = 0; $index < 100; $index++) { $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); } @@ -571,16 +587,16 @@ public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): vo ->withReadinessTimeout(timeoutInSeconds: 1); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'crash-db', - env: ['MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); /** @And the MySQL readiness check always throws exceptions */ - for ($i = 0; $i < 100; $i++) { + for ($index = 0; $index < 100; $index++) { $this->client->withDockerExecuteException( exception: new DockerCommandExecutionFailed(reason: 'container crashed', command: 'docker exec') ); @@ -606,11 +622,11 @@ public function testCustomReadinessTimeoutIsUsed(): void ->withReadinessTimeout(timeoutInSeconds: 60); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( + inspectResult: InspectResponseFixture::build( hostname: 'timeout-db', - env: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); @@ -622,69 +638,104 @@ public function testCustomReadinessTimeoutIsUsed(): void $started = $container->run(); /** @Then the container should start successfully */ - self::assertSame('timeout-db', $started->getName()); + self::assertSame(expected: 'timeout-db', actual: $started->getName()); } - private function createRunningMySQLContainer( - string $hostname, - string $database, - ?int $port - ): MySQLContainerStarted { + public function testMySQLContainerWithEnvironmentVariableDirectly(): void + { + /** @Given a MySQL container with a custom environment variable */ $container = TestableMySQLDockerContainer::createWith( image: 'mysql:8.1', - name: $hostname, + name: 'env-db', client: $this->client ) - ->withDatabase(database: $database) - ->withRootPassword(rootPassword: 'root'); - - $exposedPorts = $port !== null ? [sprintf('%d/tcp', $port) => (object)[]] : []; + ->withRootPassword(rootPassword: 'root') + ->withEnvironmentVariable(key: 'CUSTOM_KEY', value: 'custom_value'); - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + /** @And the Docker daemon returns valid responses including the custom env var */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( - hostname: $hostname, - env: [ - sprintf('MYSQL_DATABASE=%s', $database), - 'MYSQL_ROOT_PASSWORD=root' - ], - exposedPorts: $exposedPorts + inspectResult: InspectResponseFixture::build( + hostname: 'env-db', + environment: ['MYSQL_ROOT_PASSWORD=root', 'CUSTOM_KEY=custom_value'] ) ); + /** @And the MySQL readiness check succeeds */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); - return $container->run(); + /** @When the MySQL container is started */ + $started = $container->run(); + + /** @Then the custom environment variable should be accessible */ + self::assertSame( + expected: 'custom_value', + actual: $started->getEnvironmentVariables()->getValueBy( + key: 'CUSTOM_KEY' + ) + ); } - public function testMySQLContainerWithEnvironmentVariableDirectly(): void + public function testRunMySQLContainerWithPullImage(): void { - /** @Given a MySQL container with a custom environment variable */ + /** @Given a MySQL container with image pulling enabled */ $container = TestableMySQLDockerContainer::createWith( image: 'mysql:8.1', - name: 'env-db', + name: 'pull-db', client: $this->client ) ->withRootPassword(rootPassword: 'root') - ->withEnvironmentVariable(key: 'CUSTOM_KEY', value: 'custom_value'); + ->pullImage(); - /** @And the Docker daemon returns valid responses including the custom env var */ - $this->client->withDockerRunResponse(data: InspectResponseFixture::containerId()); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - data: InspectResponseFixture::build( - hostname: 'env-db', - env: ['MYSQL_ROOT_PASSWORD=root', 'CUSTOM_KEY=custom_value'] + inspectResult: InspectResponseFixture::build( + hostname: 'pull-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); /** @And the MySQL readiness check succeeds */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - /** @When the MySQL container is started */ + /** @When the container is started (waiting for the image pull to complete first) */ $started = $container->run(); - /** @Then the custom environment variable should be accessible */ - self::assertSame('custom_value', $started->getEnvironmentVariables()->getValueBy(key: 'CUSTOM_KEY')); + /** @Then the container should be running */ + self::assertSame(expected: 'pull-db', actual: $started->getName()); + } + + protected function createRunningMySQLContainer( + string $hostname, + string $database, + ?int $port + ): MySQLContainerStarted { + $container = TestableMySQLDockerContainer::createWith( + image: 'mysql:8.1', + name: $hostname, + client: $this->client + ) + ->withDatabase(database: $database) + ->withRootPassword(rootPassword: 'root'); + + $exposedPorts = !is_null($port) ? [sprintf('%d/tcp', $port) => (object)[]] : []; + + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: $hostname, + environment: [ + sprintf('MYSQL_DATABASE=%s', $database), + 'MYSQL_ROOT_PASSWORD=root' + ], + exposedPorts: $exposedPorts + ) + ); + + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); + + return $container->run(); } } diff --git a/tests/Unit/Waits/ContainerWaitForDependencyTest.php b/tests/Unit/Waits/ContainerWaitForDependencyTest.php index cc04ebf..8fa57de 100644 --- a/tests/Unit/Waits/ContainerWaitForDependencyTest.php +++ b/tests/Unit/Waits/ContainerWaitForDependencyTest.php @@ -75,17 +75,17 @@ public function testCustomPollIntervalIsRespected(): void }); /** @When waiting with a very fast poll interval */ - $start = microtime(as_float: true); + $start = microtime(true); $wait = ContainerWaitForDependency::untilReady( condition: $condition, timeoutInSeconds: 5, pollIntervalInMicroseconds: 10_000 ); $wait->waitBefore(); - $elapsed = microtime(as_float: true) - $start; + $elapsed = microtime(true) - $start; /** @Then the wait should complete quickly (well under 1 second) */ - self::assertLessThan(1.0, $elapsed); - self::assertSame(3, $callCount); + self::assertLessThan(maximum: 1.0, actual: $elapsed); + self::assertSame(expected: 3, actual: $callCount); } } diff --git a/tests/Unit/Waits/ContainerWaitForTimeTest.php b/tests/Unit/Waits/ContainerWaitForTimeTest.php index 52d9d4f..a5f1727 100644 --- a/tests/Unit/Waits/ContainerWaitForTimeTest.php +++ b/tests/Unit/Waits/ContainerWaitForTimeTest.php @@ -16,12 +16,12 @@ public function testWaitBeforePausesForSpecifiedDuration(): void $wait = ContainerWaitForTime::forSeconds(seconds: 1); /** @When waiting before */ - $start = microtime(as_float: true); + $start = microtime(true); $wait->waitBefore(); - $elapsed = microtime(as_float: true) - $start; + $elapsed = microtime(true) - $start; /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(0.9, $elapsed); + self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); } public function testWaitAfterPausesForSpecifiedDuration(): void @@ -33,12 +33,12 @@ public function testWaitAfterPausesForSpecifiedDuration(): void $containerStarted = $this->createMock(ContainerStarted::class); /** @When waiting after */ - $start = microtime(as_float: true); + $start = microtime(true); $wait->waitAfter(containerStarted: $containerStarted); - $elapsed = microtime(as_float: true) - $start; + $elapsed = microtime(true) - $start; /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(0.9, $elapsed); + self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); } public function testWaitForZeroSecondsReturnsImmediately(): void @@ -47,11 +47,11 @@ public function testWaitForZeroSecondsReturnsImmediately(): void $wait = ContainerWaitForTime::forSeconds(seconds: 0); /** @When waiting before */ - $start = microtime(as_float: true); + $start = microtime(true); $wait->waitBefore(); - $elapsed = microtime(as_float: true) - $start; + $elapsed = microtime(true) - $start; /** @Then the wait should complete almost instantly */ - self::assertLessThan(0.1, $elapsed); + self::assertLessThan(maximum: 0.1, actual: $elapsed); } }