From 8528b0699451c6217ade9e782717da37331e693c Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 14:40:03 +0000 Subject: [PATCH 1/6] feat: set tc-php/ User-Agent header for Docker API requests --- src/ContainerClient/DockerContainerClient.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index 8001992..ff6a5bf 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -4,7 +4,11 @@ namespace Testcontainers\ContainerClient; +use Composer\InstalledVersions; use Docker\Docker as DockerClient; +use Docker\DockerClientFactory; +use Http\Client\Common\Plugin\HeaderDefaultsPlugin; +use Http\Client\Common\PluginClient; class DockerContainerClient { @@ -26,7 +30,16 @@ private function __construct() public static function getDockerClient(): DockerClient { if (self::$dockerClient === null) { - self::$dockerClient = DockerClient::create(); + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + + $baseHttpClient = DockerClientFactory::createFromEnv(); + + $httpClient = new PluginClient( + $baseHttpClient, + [new HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] + ); + + self::$dockerClient = DockerClient::create($httpClient); } return self::$dockerClient; From c00e6f801320b8cb29c56fcd9a1f9811cb72fa38 Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 15:30:17 +0000 Subject: [PATCH 2/6] fix: handle OutOfBoundsException, declare php-http/client-common, strip build metadata from version --- composer.json | 3 ++- src/ContainerClient/DockerContainerClient.php | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 2bc1b7d..19ed465 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "require": { "ext-curl": "*", "php": ">= 8.1", - "beluga-php/docker-php": "^1.45" + "beluga-php/docker-php": "^1.45", + "php-http/client-common": "^2.7" }, "require-dev": { "ext-pdo": "*", diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index ff6a5bf..3afa0c3 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -30,7 +30,13 @@ private function __construct() public static function getDockerClient(): DockerClient { if (self::$dockerClient === null) { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + try { + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + } catch (\OutOfBoundsException) { + $version = 'unknown'; + } + + $version = str_replace('+no-version-set', '', $version); $baseHttpClient = DockerClientFactory::createFromEnv(); @@ -47,6 +53,7 @@ public static function getDockerClient(): DockerClient /** * Injects a DockerClient instance for testing or special use cases. + * Note: clients injected via this method will not have the tc-php User-Agent header applied automatically. * * @param DockerClient $client The DockerClient instance to set. */ From 679cb52411bae1f8cc8cab157b11765ef26a17e9 Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 16:25:08 +0000 Subject: [PATCH 3/6] test: add unit tests for DockerContainerClient User-Agent header --- .../DockerContainerClientTest.php | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/Unit/ContainerClient/DockerContainerClientTest.php diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php new file mode 100644 index 0000000..92aea71 --- /dev/null +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -0,0 +1,162 @@ +resetSingleton(); + } + + protected function tearDown(): void + { + $this->resetSingleton(); + parent::tearDown(); + } + + private function resetSingleton(): void + { + $reflection = new \ReflectionClass(DockerContainerClient::class); + $property = $reflection->getProperty('dockerClient'); + $property->setAccessible(true); + $property->setValue(null, null); + } + + // ------------------------------------------------------------------------- + // Singleton behaviour + // ------------------------------------------------------------------------- + + public function testSetDockerClientOverridesSingleton(): void + { + $mock = $this->getMockBuilder(DockerClient::class) + ->disableOriginalConstructor() + ->getMock(); + + DockerContainerClient::setDockerClient($mock); + + $this->assertSame($mock, DockerContainerClient::getDockerClient()); + } + + public function testSingletonReturnsSameInstance(): void + { + $mock = $this->getMockBuilder(DockerClient::class) + ->disableOriginalConstructor() + ->getMock(); + + DockerContainerClient::setDockerClient($mock); + + $first = DockerContainerClient::getDockerClient(); + $second = DockerContainerClient::getDockerClient(); + + $this->assertSame($first, $second); + } + + // ------------------------------------------------------------------------- + // User-Agent version string logic + // ------------------------------------------------------------------------- + + public function testUserAgentVersionHasExpectedFormat(): void + { + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + $version = str_replace('+no-version-set', '', $version); + + $userAgent = 'tc-php/' . $version; + + $this->assertMatchesRegularExpression( + '/^tc-php\/.+$/', + $userAgent, + 'User-Agent must match tc-php/ format' + ); + } + + public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void + { + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + $version = str_replace('+no-version-set', '', $version); + + $this->assertStringNotContainsString( + '+no-version-set', + $version, + 'Version string must not contain +no-version-set build-metadata suffix' + ); + } + + public function testOutOfBoundsExceptionFallsBackToUnknown(): void + { + // Exercises the catch(\OutOfBoundsException) branch in getDockerClient(). + // InstalledVersions::getPrettyVersion() throws OutOfBoundsException for + // unknown packages; the fallback must yield 'unknown'. + $version = 'sentinel'; + try { + $version = InstalledVersions::getPrettyVersion('testcontainers/this-package-does-not-exist') ?? 'unknown'; + } catch (\OutOfBoundsException) { + $version = 'unknown'; + } + + $this->assertSame('unknown', $version); + } + + // ------------------------------------------------------------------------- + // User-Agent HeaderDefaultsPlugin in the PluginClient stack + // ------------------------------------------------------------------------- + + public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void + { + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + $version = str_replace('+no-version-set', '', $version); + $expectedUserAgent = 'tc-php/' . $version; + + // Build the identical PluginClient wrapper that DockerContainerClient builds + // (using a mock PSR-18 client as the inner client so no Docker socket is needed). + $innerClient = $this->createMock(\Psr\Http\Client\ClientInterface::class); + $headerPlugin = new HeaderDefaultsPlugin(['User-Agent' => $expectedUserAgent]); + $pluginClient = new PluginClient($innerClient, [$headerPlugin]); + + // Retrieve the private $plugins array via reflection. + $pluginsProperty = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); + $pluginsProperty->setAccessible(true); + /** @var \Http\Client\Common\Plugin[] $plugins */ + $plugins = $pluginsProperty->getValue($pluginClient); + + $found = null; + foreach ($plugins as $plugin) { + if ($plugin instanceof HeaderDefaultsPlugin) { + $found = $plugin; + break; + } + } + + $this->assertInstanceOf( + HeaderDefaultsPlugin::class, + $found, + 'HeaderDefaultsPlugin must be present in the PluginClient plugin stack' + ); + + // Confirm the correct header value is stored inside the plugin. + $headersProperty = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); + $headersProperty->setAccessible(true); + /** @var array $headers */ + $headers = $headersProperty->getValue($found); + + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertSame( + $expectedUserAgent, + $headers['User-Agent'], + 'HeaderDefaultsPlugin must be configured with User-Agent: tc-php/' + ); + } +} From 1c35d1a6f460e2766f459fa6535162a5ae2720ae Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 16:36:07 +0000 Subject: [PATCH 4/6] test: fix structurally unsound unit tests to exercise production code paths --- src/ContainerClient/DockerContainerClient.php | 26 ++- .../DockerContainerClientTest.php | 169 +++++++++++++----- 2 files changed, 145 insertions(+), 50 deletions(-) diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index 3afa0c3..38c3eb9 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -30,13 +30,7 @@ private function __construct() public static function getDockerClient(): DockerClient { if (self::$dockerClient === null) { - try { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; - } catch (\OutOfBoundsException) { - $version = 'unknown'; - } - - $version = str_replace('+no-version-set', '', $version); + $version = static::resolveVersion(); $baseHttpClient = DockerClientFactory::createFromEnv(); @@ -51,6 +45,24 @@ public static function getDockerClient(): DockerClient return self::$dockerClient; } + /** + * Resolves the package version string used in the User-Agent header. + * + * Returns the pretty version of the installed package, with the + * '+no-version-set' build-metadata suffix stripped. Falls back to + * 'unknown' if the package is not found in the Composer runtime data. + */ + protected static function resolveVersion(): string + { + try { + $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + } catch (\OutOfBoundsException) { + $version = 'unknown'; + } + + return str_replace('+no-version-set', '', $version); + } + /** * Injects a DockerClient instance for testing or special use cases. * Note: clients injected via this method will not have the tc-php User-Agent header applied automatically. diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php index 92aea71..348f909 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -11,6 +11,51 @@ use PHPUnit\Framework\TestCase; use Testcontainers\ContainerClient\DockerContainerClient; +/** + * Test subclass that forces resolveVersion() to return 'unknown' by simulating + * the OutOfBoundsException fallback path, and captures the PluginClient built + * during getDockerClient() so tests can inspect it without a Docker socket. + */ +class DockerContainerClientWithUnknownVersion extends DockerContainerClient +{ + /** @var PluginClient|null The PluginClient captured during the last getDockerClient() call */ + public static ?PluginClient $capturedHttpClient = null; + + protected static function resolveVersion(): string + { + // Exercise the catch branch: getPrettyVersion throws OutOfBoundsException + // for any package that is not installed. + try { + InstalledVersions::getPrettyVersion('testcontainers/this-package-does-not-exist'); + } catch (\OutOfBoundsException) { + return 'unknown'; + } + + return 'unknown'; + } + + public static function getDockerClient(): DockerClient + { + $version = static::resolveVersion(); + + $baseHttpClient = \Docker\DockerClientFactory::createFromEnv(); + + $httpClient = new PluginClient( + $baseHttpClient, + [new \Http\Client\Common\Plugin\HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] + ); + + self::$capturedHttpClient = $httpClient; + + // Build a DockerClient instance without invoking the constructor (no socket needed). + /** @var DockerClient $stub */ + $stub = (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); + DockerContainerClient::setDockerClient($stub); + + return $stub; + } +} + /** * @covers \Testcontainers\ContainerClient\DockerContainerClient */ @@ -20,11 +65,13 @@ protected function setUp(): void { parent::setUp(); $this->resetSingleton(); + DockerContainerClientWithUnknownVersion::$capturedHttpClient = null; } protected function tearDown(): void { $this->resetSingleton(); + DockerContainerClientWithUnknownVersion::$capturedHttpClient = null; parent::tearDown(); } @@ -66,48 +113,70 @@ public function testSingletonReturnsSameInstance(): void } // ------------------------------------------------------------------------- - // User-Agent version string logic + // resolveVersion() — exercises production version-resolution logic // ------------------------------------------------------------------------- - public function testUserAgentVersionHasExpectedFormat(): void + public function testResolveVersionHasExpectedFormat(): void { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; - $version = str_replace('+no-version-set', '', $version); + // Call the real protected static method via reflection. + $method = (new \ReflectionClass(DockerContainerClient::class)) + ->getMethod('resolveVersion'); + $method->setAccessible(true); - $userAgent = 'tc-php/' . $version; + /** @var string $version */ + $version = $method->invoke(null); $this->assertMatchesRegularExpression( - '/^tc-php\/.+$/', - $userAgent, - 'User-Agent must match tc-php/ format' + '/^(tc-php\/.+|unknown|\S+)$/', + $version, + 'resolveVersion() must return a non-empty string' ); - } - - public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void - { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; - $version = str_replace('+no-version-set', '', $version); - $this->assertStringNotContainsString( '+no-version-set', $version, - 'Version string must not contain +no-version-set build-metadata suffix' + 'resolveVersion() must strip the +no-version-set build-metadata suffix' ); } public function testOutOfBoundsExceptionFallsBackToUnknown(): void { - // Exercises the catch(\OutOfBoundsException) branch in getDockerClient(). - // InstalledVersions::getPrettyVersion() throws OutOfBoundsException for - // unknown packages; the fallback must yield 'unknown'. - $version = 'sentinel'; - try { - $version = InstalledVersions::getPrettyVersion('testcontainers/this-package-does-not-exist') ?? 'unknown'; - } catch (\OutOfBoundsException) { - $version = 'unknown'; + // DockerContainerClientWithUnknownVersion::resolveVersion() exercises the + // catch(\OutOfBoundsException) branch in the production resolveVersion() body + // by calling getPrettyVersion() with a non-existent package name. + // Calling getDockerClient() on the subclass triggers the real resolveVersion() + // override and injects the result into the singleton via setDockerClient(). + DockerContainerClientWithUnknownVersion::getDockerClient(); + + $capturedClient = DockerContainerClientWithUnknownVersion::$capturedHttpClient; + $this->assertInstanceOf(PluginClient::class, $capturedClient); + + // Walk the plugin stack to find the HeaderDefaultsPlugin. + $pluginsProperty = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); + $pluginsProperty->setAccessible(true); + /** @var \Http\Client\Common\Plugin[] $plugins */ + $plugins = $pluginsProperty->getValue($capturedClient); + + $headerPlugin = null; + foreach ($plugins as $plugin) { + if ($plugin instanceof HeaderDefaultsPlugin) { + $headerPlugin = $plugin; + break; + } } - $this->assertSame('unknown', $version); + $this->assertInstanceOf(HeaderDefaultsPlugin::class, $headerPlugin); + + $headersProperty = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); + $headersProperty->setAccessible(true); + /** @var array $headers */ + $headers = $headersProperty->getValue($headerPlugin); + + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertSame( + 'tc-php/unknown', + $headers['User-Agent'], + 'When version resolution falls back to unknown, User-Agent must be tc-php/unknown' + ); } // ------------------------------------------------------------------------- @@ -116,47 +185,61 @@ public function testOutOfBoundsExceptionFallsBackToUnknown(): void public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; - $version = str_replace('+no-version-set', '', $version); - $expectedUserAgent = 'tc-php/' . $version; + // Use the capturing subclass to exercise the production PluginClient-building + // code path (resolveVersion + PluginClient construction) without needing a + // live Docker socket. + DockerContainerClientWithUnknownVersion::getDockerClient(); - // Build the identical PluginClient wrapper that DockerContainerClient builds - // (using a mock PSR-18 client as the inner client so no Docker socket is needed). - $innerClient = $this->createMock(\Psr\Http\Client\ClientInterface::class); - $headerPlugin = new HeaderDefaultsPlugin(['User-Agent' => $expectedUserAgent]); - $pluginClient = new PluginClient($innerClient, [$headerPlugin]); + $capturedClient = DockerContainerClientWithUnknownVersion::$capturedHttpClient; + $this->assertInstanceOf(PluginClient::class, $capturedClient); - // Retrieve the private $plugins array via reflection. + // Walk the plugin stack. $pluginsProperty = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); $pluginsProperty->setAccessible(true); /** @var \Http\Client\Common\Plugin[] $plugins */ - $plugins = $pluginsProperty->getValue($pluginClient); + $plugins = $pluginsProperty->getValue($capturedClient); - $found = null; + $headerPlugin = null; foreach ($plugins as $plugin) { if ($plugin instanceof HeaderDefaultsPlugin) { - $found = $plugin; + $headerPlugin = $plugin; break; } } $this->assertInstanceOf( HeaderDefaultsPlugin::class, - $found, + $headerPlugin, 'HeaderDefaultsPlugin must be present in the PluginClient plugin stack' ); - // Confirm the correct header value is stored inside the plugin. $headersProperty = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); $headersProperty->setAccessible(true); /** @var array $headers */ - $headers = $headersProperty->getValue($found); + $headers = $headersProperty->getValue($headerPlugin); $this->assertArrayHasKey('User-Agent', $headers); - $this->assertSame( - $expectedUserAgent, + $this->assertMatchesRegularExpression( + '/^tc-php\/.+$/', $headers['User-Agent'], - 'HeaderDefaultsPlugin must be configured with User-Agent: tc-php/' + 'HeaderDefaultsPlugin must set User-Agent to tc-php/' + ); + } + + public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void + { + // Call the real protected static resolveVersion() and assert the suffix is stripped. + $method = (new \ReflectionClass(DockerContainerClient::class)) + ->getMethod('resolveVersion'); + $method->setAccessible(true); + + /** @var string $version */ + $version = $method->invoke(null); + + $this->assertStringNotContainsString( + '+no-version-set', + $version, + 'resolveVersion() must strip the +no-version-set build-metadata suffix' ); } } From f69bfffc51a9899d09bb6cb9d92bbbe7e3f95698 Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 16:42:55 +0000 Subject: [PATCH 5/6] test: refactor DockerContainerClient for testability; fix false-positive tests --- src/ContainerClient/DockerContainerClient.php | 67 +++- .../DockerContainerClientTest.php | 291 +++++++++++------- 2 files changed, 238 insertions(+), 120 deletions(-) diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index 38c3eb9..e308a68 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -9,6 +9,7 @@ use Docker\DockerClientFactory; use Http\Client\Common\Plugin\HeaderDefaultsPlugin; use Http\Client\Common\PluginClient; +use Psr\Http\Client\ClientInterface; class DockerContainerClient { @@ -17,6 +18,18 @@ class DockerContainerClient */ private static ?DockerClient $dockerClient = null; + /** + * @var (callable(): ClientInterface)|null Factory for the base HTTP client. + * When null, DockerClientFactory::createFromEnv() is used. + */ + private static $httpClientFactory = null; + + /** + * @var (callable(ClientInterface): DockerClient)|null Factory for the Docker client. + * When null, DockerClient::create() is used. + */ + private static $dockerClientFactory = null; + private function __construct() { } @@ -32,14 +45,14 @@ public static function getDockerClient(): DockerClient if (self::$dockerClient === null) { $version = static::resolveVersion(); - $baseHttpClient = DockerClientFactory::createFromEnv(); + $baseHttpClient = self::createHttpClient(); $httpClient = new PluginClient( $baseHttpClient, [new HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] ); - self::$dockerClient = DockerClient::create($httpClient); + self::$dockerClient = self::createDockerClient($httpClient); } return self::$dockerClient; @@ -51,11 +64,14 @@ public static function getDockerClient(): DockerClient * Returns the pretty version of the installed package, with the * '+no-version-set' build-metadata suffix stripped. Falls back to * 'unknown' if the package is not found in the Composer runtime data. + * + * @param string $package Composer package name to resolve; override in tests + * to exercise the OutOfBoundsException fallback path. */ - protected static function resolveVersion(): string + protected static function resolveVersion(string $package = 'testcontainers/testcontainers'): string { try { - $version = InstalledVersions::getPrettyVersion('testcontainers/testcontainers') ?? 'unknown'; + $version = InstalledVersions::getPrettyVersion($package) ?? 'unknown'; } catch (\OutOfBoundsException) { $version = 'unknown'; } @@ -63,6 +79,26 @@ protected static function resolveVersion(): string return str_replace('+no-version-set', '', $version); } + /** + * Returns the base HTTP client, using the injected factory if set. + */ + private static function createHttpClient(): ClientInterface + { + return self::$httpClientFactory !== null + ? (self::$httpClientFactory)() + : DockerClientFactory::createFromEnv(); + } + + /** + * Builds the DockerClient from the given HTTP client, using the injected factory if set. + */ + private static function createDockerClient(ClientInterface $httpClient): DockerClient + { + return self::$dockerClientFactory !== null + ? (self::$dockerClientFactory)($httpClient) + : DockerClient::create($httpClient); + } + /** * Injects a DockerClient instance for testing or special use cases. * Note: clients injected via this method will not have the tc-php User-Agent header applied automatically. @@ -73,4 +109,27 @@ public static function setDockerClient(DockerClient $client): void { self::$dockerClient = $client; } + + /** + * Resets the injectable factories to their defaults. + * For use in tests only — do not call in production code. + * + * @param (callable(): ClientInterface)|null $httpClientFactory + * @param (callable(ClientInterface): DockerClient)|null $dockerClientFactory + */ + public static function setFactories(?callable $httpClientFactory, ?callable $dockerClientFactory): void + { + self::$httpClientFactory = $httpClientFactory; + self::$dockerClientFactory = $dockerClientFactory; + } + + /** + * Resets the injectable factories to their defaults (both null). + * For use in tests only — do not call in production code. + */ + public static function resetFactories(): void + { + self::$httpClientFactory = null; + self::$dockerClientFactory = null; + } } diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php index 348f909..cc08702 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -4,55 +4,30 @@ namespace Testcontainers\Tests\Unit\ContainerClient; -use Composer\InstalledVersions; use Docker\Docker as DockerClient; use Http\Client\Common\Plugin\HeaderDefaultsPlugin; use Http\Client\Common\PluginClient; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; use Testcontainers\ContainerClient\DockerContainerClient; /** - * Test subclass that forces resolveVersion() to return 'unknown' by simulating - * the OutOfBoundsException fallback path, and captures the PluginClient built - * during getDockerClient() so tests can inspect it without a Docker socket. + * Test subclass used by testUserAgentHeaderContainsUnknownWhenVersionResolutionFails. + * + * Overrides resolveVersion() to pass a non-existent package name to the parent + * implementation, exercising the OutOfBoundsException catch branch in the + * production resolveVersion() body. getDockerClient() is inherited unmodified + * from DockerContainerClient — the only change is the version source. */ -class DockerContainerClientWithUnknownVersion extends DockerContainerClient +class DockerContainerClientWithBrokenVersion extends DockerContainerClient { - /** @var PluginClient|null The PluginClient captured during the last getDockerClient() call */ - public static ?PluginClient $capturedHttpClient = null; - - protected static function resolveVersion(): string + protected static function resolveVersion(string $package = 'testcontainers/testcontainers'): string { - // Exercise the catch branch: getPrettyVersion throws OutOfBoundsException - // for any package that is not installed. - try { - InstalledVersions::getPrettyVersion('testcontainers/this-package-does-not-exist'); - } catch (\OutOfBoundsException) { - return 'unknown'; - } - - return 'unknown'; - } - - public static function getDockerClient(): DockerClient - { - $version = static::resolveVersion(); - - $baseHttpClient = \Docker\DockerClientFactory::createFromEnv(); - - $httpClient = new PluginClient( - $baseHttpClient, - [new \Http\Client\Common\Plugin\HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] - ); - - self::$capturedHttpClient = $httpClient; - - // Build a DockerClient instance without invoking the constructor (no socket needed). - /** @var DockerClient $stub */ - $stub = (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); - DockerContainerClient::setDockerClient($stub); - - return $stub; + // Delegates to the real production resolveVersion() with an unknown package. + // The production catch(\OutOfBoundsException) block must catch the exception + // and return 'unknown'. If that catch block is removed, an unhandled + // OutOfBoundsException propagates and the test fails. + return parent::resolveVersion('testcontainers/this-package-does-not-exist'); } } @@ -64,23 +39,99 @@ class DockerContainerClientTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->resetSingleton(); - DockerContainerClientWithUnknownVersion::$capturedHttpClient = null; + $this->resetState(); } protected function tearDown(): void { - $this->resetSingleton(); - DockerContainerClientWithUnknownVersion::$capturedHttpClient = null; + $this->resetState(); parent::tearDown(); } - private function resetSingleton(): void + private function resetState(): void { $reflection = new \ReflectionClass(DockerContainerClient::class); $property = $reflection->getProperty('dockerClient'); $property->setAccessible(true); $property->setValue(null, null); + + DockerContainerClient::resetFactories(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Injects factory stubs so getDockerClient() runs its full production body + * (version resolution → PluginClient wrapping → docker-client factory) without + * opening a Docker socket. The injected $dockerClientFactory receives the real + * PluginClient that production code constructs, providing a handle to inspect it. + * + * @param PluginClient|null $capturedHttpClient Out-param set to the PluginClient passed to the docker factory. + */ + private function injectNoSocketFactories(?PluginClient &$capturedHttpClient = null): void + { + $mockPsrClient = $this->createMock(ClientInterface::class); + + DockerContainerClient::setFactories( + static function () use ($mockPsrClient): ClientInterface { + return $mockPsrClient; + }, + static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { + // $httpClient here is the PluginClient wrapping the UA plugin — capture it. + assert($httpClient instanceof PluginClient); + $capturedHttpClient = $httpClient; + + return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); + } + ); + } + + /** + * Returns the private $plugins array from a PluginClient via reflection. + * + * @return \Http\Client\Common\Plugin[] + */ + private function getPlugins(PluginClient $client): array + { + $prop = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); + $prop->setAccessible(true); + + /** @var \Http\Client\Common\Plugin[] $plugins */ + $plugins = $prop->getValue($client); + + return $plugins; + } + + /** + * Finds the first HeaderDefaultsPlugin in a PluginClient's plugin stack, or null. + */ + private function findHeaderDefaultsPlugin(PluginClient $client): ?HeaderDefaultsPlugin + { + foreach ($this->getPlugins($client) as $plugin) { + if ($plugin instanceof HeaderDefaultsPlugin) { + return $plugin; + } + } + + return null; + } + + /** + * Returns the $headers array stored inside a HeaderDefaultsPlugin via reflection. + * + * @return array + */ + private function getHeadersFromPlugin(HeaderDefaultsPlugin $plugin): array + { + $prop = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); + $prop->setAccessible(true); + + /** @var array $headers */ + $headers = $prop->getValue($plugin); + + return $headers; } // ------------------------------------------------------------------------- @@ -116,9 +167,8 @@ public function testSingletonReturnsSameInstance(): void // resolveVersion() — exercises production version-resolution logic // ------------------------------------------------------------------------- - public function testResolveVersionHasExpectedFormat(): void + public function testResolveVersionReturnsNonEmptyStringWithoutBuildMetadata(): void { - // Call the real protected static method via reflection. $method = (new \ReflectionClass(DockerContainerClient::class)) ->getMethod('resolveVersion'); $method->setAccessible(true); @@ -126,11 +176,23 @@ public function testResolveVersionHasExpectedFormat(): void /** @var string $version */ $version = $method->invoke(null); - $this->assertMatchesRegularExpression( - '/^(tc-php\/.+|unknown|\S+)$/', + $this->assertNotEmpty($version, 'resolveVersion() must return a non-empty string'); + $this->assertStringNotContainsString( + '+no-version-set', $version, - 'resolveVersion() must return a non-empty string' + 'resolveVersion() must strip the +no-version-set build-metadata suffix' ); + } + + public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void + { + $method = (new \ReflectionClass(DockerContainerClient::class)) + ->getMethod('resolveVersion'); + $method->setAccessible(true); + + /** @var string $version */ + $version = $method->invoke(null); + $this->assertStringNotContainsString( '+no-version-set', $version, @@ -140,42 +202,22 @@ public function testResolveVersionHasExpectedFormat(): void public function testOutOfBoundsExceptionFallsBackToUnknown(): void { - // DockerContainerClientWithUnknownVersion::resolveVersion() exercises the - // catch(\OutOfBoundsException) branch in the production resolveVersion() body - // by calling getPrettyVersion() with a non-existent package name. - // Calling getDockerClient() on the subclass triggers the real resolveVersion() - // override and injects the result into the singleton via setDockerClient(). - DockerContainerClientWithUnknownVersion::getDockerClient(); - - $capturedClient = DockerContainerClientWithUnknownVersion::$capturedHttpClient; - $this->assertInstanceOf(PluginClient::class, $capturedClient); - - // Walk the plugin stack to find the HeaderDefaultsPlugin. - $pluginsProperty = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); - $pluginsProperty->setAccessible(true); - /** @var \Http\Client\Common\Plugin[] $plugins */ - $plugins = $pluginsProperty->getValue($capturedClient); - - $headerPlugin = null; - foreach ($plugins as $plugin) { - if ($plugin instanceof HeaderDefaultsPlugin) { - $headerPlugin = $plugin; - break; - } - } - - $this->assertInstanceOf(HeaderDefaultsPlugin::class, $headerPlugin); + // Call the real production resolveVersion() with a package name that is not + // installed. InstalledVersions::getPrettyVersion() throws OutOfBoundsException; + // the production catch block converts that to 'unknown'. + // If the catch block is removed from production code, this call throws an + // unhandled OutOfBoundsException and the test fails — it is not self-testing. + $method = (new \ReflectionClass(DockerContainerClient::class)) + ->getMethod('resolveVersion'); + $method->setAccessible(true); - $headersProperty = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); - $headersProperty->setAccessible(true); - /** @var array $headers */ - $headers = $headersProperty->getValue($headerPlugin); + /** @var string $result */ + $result = $method->invoke(null, 'testcontainers/this-package-does-not-exist'); - $this->assertArrayHasKey('User-Agent', $headers); $this->assertSame( - 'tc-php/unknown', - $headers['User-Agent'], - 'When version resolution falls back to unknown, User-Agent must be tc-php/unknown' + 'unknown', + $result, + 'resolveVersion() must return "unknown" when the package is not found' ); } @@ -185,27 +227,26 @@ public function testOutOfBoundsExceptionFallsBackToUnknown(): void public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void { - // Use the capturing subclass to exercise the production PluginClient-building - // code path (resolveVersion + PluginClient construction) without needing a - // live Docker socket. - DockerContainerClientWithUnknownVersion::getDockerClient(); + // Inject no-socket factories so the real production getDockerClient() body runs: + // 1. static::resolveVersion() — real production code, no change + // 2. new PluginClient(..., [new HeaderDefaultsPlugin([...])]) — real production code + // 3. self::createDockerClient($httpClient) — calls our injected factory, which + // captures the PluginClient and returns a no-constructor stub + // + // Removing HeaderDefaultsPlugin from getDockerClient() causes this test to fail + // because $capturedHttpClient would then have no HeaderDefaultsPlugin in its stack. + $capturedHttpClient = null; + $this->injectNoSocketFactories($capturedHttpClient); + + DockerContainerClient::getDockerClient(); - $capturedClient = DockerContainerClientWithUnknownVersion::$capturedHttpClient; - $this->assertInstanceOf(PluginClient::class, $capturedClient); - - // Walk the plugin stack. - $pluginsProperty = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); - $pluginsProperty->setAccessible(true); - /** @var \Http\Client\Common\Plugin[] $plugins */ - $plugins = $pluginsProperty->getValue($capturedClient); + $this->assertInstanceOf( + PluginClient::class, + $capturedHttpClient, + 'The docker client factory must receive a PluginClient' + ); - $headerPlugin = null; - foreach ($plugins as $plugin) { - if ($plugin instanceof HeaderDefaultsPlugin) { - $headerPlugin = $plugin; - break; - } - } + $headerPlugin = $this->findHeaderDefaultsPlugin($capturedHttpClient); $this->assertInstanceOf( HeaderDefaultsPlugin::class, @@ -213,10 +254,7 @@ public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void 'HeaderDefaultsPlugin must be present in the PluginClient plugin stack' ); - $headersProperty = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); - $headersProperty->setAccessible(true); - /** @var array $headers */ - $headers = $headersProperty->getValue($headerPlugin); + $headers = $this->getHeadersFromPlugin($headerPlugin); $this->assertArrayHasKey('User-Agent', $headers); $this->assertMatchesRegularExpression( @@ -226,20 +264,41 @@ public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void ); } - public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void + public function testUserAgentHeaderContainsUnknownWhenVersionResolutionFails(): void { - // Call the real protected static resolveVersion() and assert the suffix is stripped. - $method = (new \ReflectionClass(DockerContainerClient::class)) - ->getMethod('resolveVersion'); - $method->setAccessible(true); + // Injects no-socket factories (same pattern as above), then calls + // DockerContainerClientWithBrokenVersion::getDockerClient() which inherits the + // production getDockerClient() body unchanged but overrides resolveVersion() to + // pass a non-existent package to parent::resolveVersion(), triggering the real + // OutOfBoundsException catch branch. The captured PluginClient must carry + // User-Agent: tc-php/unknown. + $capturedHttpClient = null; + $mockPsrClient = $this->createMock(ClientInterface::class); + + DockerContainerClient::setFactories( + static function () use ($mockPsrClient): ClientInterface { + return $mockPsrClient; + }, + static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { + assert($httpClient instanceof PluginClient); + $capturedHttpClient = $httpClient; + + return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); + } + ); - /** @var string $version */ - $version = $method->invoke(null); + DockerContainerClientWithBrokenVersion::getDockerClient(); - $this->assertStringNotContainsString( - '+no-version-set', - $version, - 'resolveVersion() must strip the +no-version-set build-metadata suffix' + $this->assertInstanceOf(PluginClient::class, $capturedHttpClient); + + $headerPlugin = $this->findHeaderDefaultsPlugin($capturedHttpClient); + $this->assertInstanceOf(HeaderDefaultsPlugin::class, $headerPlugin); + + $headers = $this->getHeadersFromPlugin($headerPlugin); + $this->assertSame( + 'tc-php/unknown', + $headers['User-Agent'], + 'When version resolution falls back to unknown, User-Agent must be tc-php/unknown' ); } } From d2f26910b6def2b7305a4f51c38e0ff42df6aee9 Mon Sep 17 00:00:00 2001 From: Testcontainers Agent Date: Thu, 25 Jun 2026 16:46:25 +0000 Subject: [PATCH 6/6] test: replace assert() with explicit guards; remove duplicate test --- .../DockerContainerClientTest.php | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php index cc08702..9ba3104 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -80,7 +80,12 @@ static function () use ($mockPsrClient): ClientInterface { }, static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { // $httpClient here is the PluginClient wrapping the UA plugin — capture it. - assert($httpClient instanceof PluginClient); + if (!$httpClient instanceof PluginClient) { + throw new \UnexpectedValueException( + 'Expected PluginClient, got ' . get_debug_type($httpClient) + ); + } + $capturedHttpClient = $httpClient; return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); @@ -167,23 +172,6 @@ public function testSingletonReturnsSameInstance(): void // resolveVersion() — exercises production version-resolution logic // ------------------------------------------------------------------------- - public function testResolveVersionReturnsNonEmptyStringWithoutBuildMetadata(): void - { - $method = (new \ReflectionClass(DockerContainerClient::class)) - ->getMethod('resolveVersion'); - $method->setAccessible(true); - - /** @var string $version */ - $version = $method->invoke(null); - - $this->assertNotEmpty($version, 'resolveVersion() must return a non-empty string'); - $this->assertStringNotContainsString( - '+no-version-set', - $version, - 'resolveVersion() must strip the +no-version-set build-metadata suffix' - ); - } - public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void { $method = (new \ReflectionClass(DockerContainerClient::class)) @@ -280,7 +268,12 @@ static function () use ($mockPsrClient): ClientInterface { return $mockPsrClient; }, static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { - assert($httpClient instanceof PluginClient); + if (!$httpClient instanceof PluginClient) { + throw new \UnexpectedValueException( + 'Expected PluginClient, got ' . get_debug_type($httpClient) + ); + } + $capturedHttpClient = $httpClient; return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor();