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 8001992..e308a68 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -4,7 +4,12 @@ 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; +use Psr\Http\Client\ClientInterface; class DockerContainerClient { @@ -13,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() { } @@ -26,14 +43,65 @@ private function __construct() public static function getDockerClient(): DockerClient { if (self::$dockerClient === null) { - self::$dockerClient = DockerClient::create(); + $version = static::resolveVersion(); + + $baseHttpClient = self::createHttpClient(); + + $httpClient = new PluginClient( + $baseHttpClient, + [new HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] + ); + + self::$dockerClient = self::createDockerClient($httpClient); } 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. + * + * @param string $package Composer package name to resolve; override in tests + * to exercise the OutOfBoundsException fallback path. + */ + protected static function resolveVersion(string $package = 'testcontainers/testcontainers'): string + { + try { + $version = InstalledVersions::getPrettyVersion($package) ?? 'unknown'; + } catch (\OutOfBoundsException) { + $version = 'unknown'; + } + + 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. * * @param DockerClient $client The DockerClient instance to set. */ @@ -41,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 new file mode 100644 index 0000000..9ba3104 --- /dev/null +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -0,0 +1,297 @@ +resetState(); + } + + protected function tearDown(): void + { + $this->resetState(); + parent::tearDown(); + } + + 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. + if (!$httpClient instanceof PluginClient) { + throw new \UnexpectedValueException( + 'Expected PluginClient, got ' . get_debug_type($httpClient) + ); + } + + $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; + } + + // ------------------------------------------------------------------------- + // 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); + } + + // ------------------------------------------------------------------------- + // resolveVersion() — exercises production version-resolution logic + // ------------------------------------------------------------------------- + + 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, + 'resolveVersion() must strip the +no-version-set build-metadata suffix' + ); + } + + public function testOutOfBoundsExceptionFallsBackToUnknown(): void + { + // 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); + + /** @var string $result */ + $result = $method->invoke(null, 'testcontainers/this-package-does-not-exist'); + + $this->assertSame( + 'unknown', + $result, + 'resolveVersion() must return "unknown" when the package is not found' + ); + } + + // ------------------------------------------------------------------------- + // User-Agent HeaderDefaultsPlugin in the PluginClient stack + // ------------------------------------------------------------------------- + + public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void + { + // 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(); + + $this->assertInstanceOf( + PluginClient::class, + $capturedHttpClient, + 'The docker client factory must receive a PluginClient' + ); + + $headerPlugin = $this->findHeaderDefaultsPlugin($capturedHttpClient); + + $this->assertInstanceOf( + HeaderDefaultsPlugin::class, + $headerPlugin, + 'HeaderDefaultsPlugin must be present in the PluginClient plugin stack' + ); + + $headers = $this->getHeadersFromPlugin($headerPlugin); + + $this->assertArrayHasKey('User-Agent', $headers); + $this->assertMatchesRegularExpression( + '/^tc-php\/.+$/', + $headers['User-Agent'], + 'HeaderDefaultsPlugin must set User-Agent to tc-php/' + ); + } + + public function testUserAgentHeaderContainsUnknownWhenVersionResolutionFails(): void + { + // 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 { + if (!$httpClient instanceof PluginClient) { + throw new \UnexpectedValueException( + 'Expected PluginClient, got ' . get_debug_type($httpClient) + ); + } + + $capturedHttpClient = $httpClient; + + return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); + } + ); + + DockerContainerClientWithBrokenVersion::getDockerClient(); + + $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' + ); + } +}