From 1ed7fb52db171030eb29af85cb36deb09b7e30cc Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 11 Mar 2026 22:36:58 +0000 Subject: [PATCH 1/4] Expand Behat behaviour test suite with zip inspection and per-service coverage - Add zip-inspection step definitions to DefaultContext (contain/not-contain file and content checks, AfterScenario cleanup) - Add container_for_mariadb_* and container_for_postgres_* wrapper divs to generator template to support validation error selectors - Expand generator.feature from 6 to 20 scenarios covering zip structure, PHP version in Dockerfile, MariaDB/PostgreSQL validation, per-service docker-compose content (including credentials), optional services absent by default, and all services enabled simultaneously Co-Authored-By: Claude Sonnet 4.6 --- features/generator.feature | 163 ++++++++++++++++++++++++++++++++- templates/generator.html.twig | 4 +- tests/Behat/DefaultContext.php | 66 +++++++++++-- 3 files changed, 220 insertions(+), 13 deletions(-) diff --git a/features/generator.feature b/features/generator.feature index f43f237f..517900b4 100644 --- a/features/generator.feature +++ b/features/generator.feature @@ -20,7 +20,38 @@ Feature: When I press "Generate project archive" Then the response code should be 200 And I should receive a zip file named "phpdocker.zip" -# And show last response + + Scenario: Default zip contains all expected files + Given I am on "/" + When I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip should contain the file "docker-compose.yml" + And the zip should contain the file "phpdocker/php-fpm/Dockerfile" + And the zip should contain the file "phpdocker/php-fpm/php-ini-overrides.ini" + And the zip should contain the file "phpdocker/nginx/nginx.conf" + And the zip should contain the file "phpdocker/README.md" + And the zip should contain the file "phpdocker/README.html" + + Scenario: Webserver and php-fpm are always present in docker-compose.yml + Given I am on "/" + When I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "webserver:" + And the zip file "docker-compose.yml" should contain "php-fpm:" + + Scenario: PHP 8.2 is reflected in Dockerfile + Given I am on "/" + When I select "8.2" from "project_phpOptions_version" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "phpdocker/php-fpm/Dockerfile" should contain "phpdockerio/php:8.2-fpm" + + Scenario: PHP 8.5 is reflected in Dockerfile + Given I am on "/" + When I select "8.5" from "project_phpOptions_version" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "phpdocker/php-fpm/Dockerfile" should contain "phpdockerio/php:8.5-fpm" Scenario: Check MySQL validation works Given I am on "/" @@ -31,6 +62,23 @@ Feature: And the "#container_for_mysql_username" element should contain "This value should not be blank." And the "#container_for_mysql_password" element should contain "This value should not be blank." + Scenario: Check MariaDB validation works + Given I am on "/" + When I check "MariaDB" + And I press "Generate project archive" + Then the "#container_for_mariadb_rootPassword" element should contain "This value should not be blank." + And the "#container_for_mariadb_databaseName" element should contain "This value should not be blank." + And the "#container_for_mariadb_username" element should contain "This value should not be blank." + And the "#container_for_mariadb_password" element should contain "This value should not be blank." + + Scenario: Check PostgreSQL validation works + Given I am on "/" + When I check "Postgres" + And I press "Generate project archive" + Then the "#container_for_postgres_rootUser" element should contain "This value should not be blank." + And the "#container_for_postgres_rootPassword" element should contain "This value should not be blank." + And the "#container_for_postgres_databaseName" element should contain "This value should not be blank." + Scenario: MySQL config works correctly Given I am on "/" When I check "MySQL" @@ -41,3 +89,116 @@ Feature: When I press "Generate project archive" Then the response code should be 200 And I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "mysql:" + And the zip file "docker-compose.yml" should contain "MYSQL_ROOT_PASSWORD=root pass" + And the zip file "docker-compose.yml" should contain "MYSQL_DATABASE=db name" + And the zip file "docker-compose.yml" should contain "MYSQL_USER=user" + And the zip file "docker-compose.yml" should contain "MYSQL_PASSWORD=pass" + + Scenario: MariaDB config works correctly + Given I am on "/" + When I check "MariaDB" + And I fill in "project_mariadbOptions_rootPassword" with "root pass" + And I fill in "project_mariadbOptions_databaseName" with "db name" + And I fill in "project_mariadbOptions_username" with "user" + And I fill in "project_mariadbOptions_password" with "pass" + When I press "Generate project archive" + Then the response code should be 200 + And I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "mariadb:" + And the zip file "docker-compose.yml" should contain "MYSQL_ROOT_PASSWORD=root pass" + And the zip file "docker-compose.yml" should contain "MYSQL_DATABASE=db name" + And the zip file "docker-compose.yml" should contain "MYSQL_USER=user" + And the zip file "docker-compose.yml" should contain "MYSQL_PASSWORD=pass" + + Scenario: PostgreSQL config works correctly + Given I am on "/" + When I check "Postgres" + And I fill in "project_postgresOptions_rootUser" with "root user" + And I fill in "project_postgresOptions_rootPassword" with "root pass" + And I fill in "project_postgresOptions_databaseName" with "db name" + When I press "Generate project archive" + Then the response code should be 200 + And I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "postgres:" + And the zip file "docker-compose.yml" should contain "POSTGRES_USER=root user" + And the zip file "docker-compose.yml" should contain "POSTGRES_PASSWORD=root pass" + And the zip file "docker-compose.yml" should contain "POSTGRES_DB=db name" + + Scenario: Redis is included when enabled + Given I am on "/" + When I check "Redis" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "redis:" + + Scenario: Memcached is included when enabled + Given I am on "/" + When I check "Memcached" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "memcached:" + + Scenario: Mailhog is included when enabled + Given I am on "/" + When I check "Mailhog" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "mailhog:" + + Scenario: Clickhouse is included when enabled + Given I am on "/" + When I check "Clickhouse" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "clickhouse:" + + Scenario: Optional services are absent by default + Given I am on "/" + When I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should not contain "redis:" + And the zip file "docker-compose.yml" should not contain "memcached:" + And the zip file "docker-compose.yml" should not contain "mailhog:" + And the zip file "docker-compose.yml" should not contain "clickhouse:" + And the zip file "docker-compose.yml" should not contain "mysql:" + And the zip file "docker-compose.yml" should not contain "mariadb:" + And the zip file "docker-compose.yml" should not contain "postgres:" + And the zip file "docker-compose.yml" should not contain "elasticsearch:" + + Scenario: All optional services enabled simultaneously + Given I am on "/" + When I check "Redis" + And I check "Memcached" + And I check "Mailhog" + And I check "Clickhouse" + And I check "MySQL" + And I fill in "project_mysqlOptions_rootPassword" with "root pass" + And I fill in "project_mysqlOptions_databaseName" with "db name" + And I fill in "project_mysqlOptions_username" with "user" + And I fill in "project_mysqlOptions_password" with "pass" + And I check "MariaDB" + And I fill in "project_mariadbOptions_rootPassword" with "root pass" + And I fill in "project_mariadbOptions_databaseName" with "db name" + And I fill in "project_mariadbOptions_username" with "user" + And I fill in "project_mariadbOptions_password" with "pass" + And I check "Postgres" + And I fill in "project_postgresOptions_rootUser" with "root user" + And I fill in "project_postgresOptions_rootPassword" with "root pass" + And I fill in "project_postgresOptions_databaseName" with "db name" + And I press "Generate project archive" + Then I should receive a zip file named "phpdocker.zip" + And the zip file "docker-compose.yml" should contain "redis:" + And the zip file "docker-compose.yml" should contain "memcached:" + And the zip file "docker-compose.yml" should contain "mailhog:" + And the zip file "docker-compose.yml" should contain "clickhouse:" + And the zip file "docker-compose.yml" should contain "mysql:" + And the zip file "docker-compose.yml" should contain "MYSQL_ROOT_PASSWORD=root pass" + And the zip file "docker-compose.yml" should contain "MYSQL_DATABASE=db name" + And the zip file "docker-compose.yml" should contain "MYSQL_USER=user" + And the zip file "docker-compose.yml" should contain "MYSQL_PASSWORD=pass" + And the zip file "docker-compose.yml" should contain "mariadb:" + And the zip file "docker-compose.yml" should contain "postgres:" + And the zip file "docker-compose.yml" should contain "POSTGRES_USER=root user" + And the zip file "docker-compose.yml" should contain "POSTGRES_PASSWORD=root pass" + And the zip file "docker-compose.yml" should contain "POSTGRES_DB=db name" diff --git a/templates/generator.html.twig b/templates/generator.html.twig index 8fdae4ff..b7f327b9 100644 --- a/templates/generator.html.twig +++ b/templates/generator.html.twig @@ -97,7 +97,7 @@
{% for field in ['version', 'rootPassword', 'databaseName', 'username', 'password'] %} {% if (attribute(form.mariadbOptions, field) is defined) %} - {{ form_row(attribute(form.mariadbOptions, field)) }} +
{{ form_row(attribute(form.mariadbOptions, field)) }}
{% endif %} {% endfor %}
@@ -108,7 +108,7 @@
{% for field in ['version', 'rootUser', 'rootPassword', 'databaseName'] %} {% if (attribute(form.postgresOptions, field) is defined) %} - {{ form_row(attribute(form.postgresOptions, field)) }} +
{{ form_row(attribute(form.postgresOptions, field)) }}
{% endif %} {% endfor %}
diff --git a/tests/Behat/DefaultContext.php b/tests/Behat/DefaultContext.php index 02700f34..ab151f3a 100644 --- a/tests/Behat/DefaultContext.php +++ b/tests/Behat/DefaultContext.php @@ -10,6 +10,9 @@ final class DefaultContext extends MinkContext { + private ?ZipArchive $lastZip = null; + private ?string $lastZipTmpFile = null; + /** * @Then /^the response code should be (\d+)$/ */ @@ -56,20 +59,63 @@ public function iShouldReceiveAZipFileNamed(string $zipFilename) Assertion::eqArraySubset($headers, $expectedZipHeaders); - Assertion::true($this->isZipFile($response)); + $tmpFile = sprintf('%s', tempnam('/tmp', 'zip_test_')); + file_put_contents(filename: $tmpFile, data: $response); + + $zip = new ZipArchive(); + $result = $zip->open($tmpFile); + + Assertion::true($result); + + $this->lastZip = $zip; + $this->lastZipTmpFile = $tmpFile; } - private function isZipFile(string $data): bool + /** @AfterScenario */ + public function cleanUpZip(): void { - $fn = sprintf('%s', tempnam('/tmp', 'zip_test_')); - file_put_contents(filename: $fn, data: $data); - - try { - $zipFile = new ZipArchive(); + if ($this->lastZip !== null) { + $this->lastZip->close(); + $this->lastZip = null; + } - return $zipFile->open($fn); - } finally { - @unlink($fn); + if ($this->lastZipTmpFile !== null) { + @unlink($this->lastZipTmpFile); + $this->lastZipTmpFile = null; } } + + /** + * @Then /^the zip should contain the file "([^"]*)"$/ + */ + public function theZipShouldContainTheFile(string $path): void + { + Assertion::notNull($this->lastZip, 'No zip file available. Did you call "I should receive a zip file named" first?'); + Assertion::true( + $this->lastZip->locateName($path) !== false, + sprintf('File "%s" not found in zip archive', $path), + ); + } + + /** + * @Then /^the zip file "([^"]*)" should contain "([^"]*)"$/ + */ + public function theZipFileShouldContain(string $path, string $content): void + { + Assertion::notNull($this->lastZip, 'No zip file available. Did you call "I should receive a zip file named" first?'); + $fileContent = $this->lastZip->getFromName($path); + Assertion::string($fileContent, sprintf('File "%s" not found in zip archive', $path)); + Assertion::contains($fileContent, $content, sprintf('File "%s" does not contain "%s"', $path, $content)); + } + + /** + * @Then /^the zip file "([^"]*)" should not contain "([^"]*)"$/ + */ + public function theZipFileShouldNotContain(string $path, string $content): void + { + Assertion::notNull($this->lastZip, 'No zip file available. Did you call "I should receive a zip file named" first?'); + $fileContent = $this->lastZip->getFromName($path); + Assertion::string($fileContent, sprintf('File "%s" not found in zip archive', $path)); + Assertion::notContains($fileContent, $content, sprintf('File "%s" should not contain "%s"', $path, $content)); + } } From 61a4a074c6ff063e549f717c5c793e422bedf814 Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 11 Mar 2026 22:51:13 +0000 Subject: [PATCH 2/4] Add a whole bunch of new unit test cases --- .../Unit/Assert/PostgresTypeValidatorTest.php | 46 +++++++ .../AvailableExtensionsFactoryTest.php | 54 +++++++++ .../BaseAvailableExtensionsTest.php | 79 ++++++++++++ .../Project/ServiceOptions/BaseTest.php | 45 +++++++ .../ServiceOptions/ElasticsearchTest.php | 49 ++++++++ .../Project/ServiceOptions/MariaDBTest.php | 56 +++++++++ .../Project/ServiceOptions/MySQLTest.php | 67 +++++++++++ .../Project/ServiceOptions/PhpTest.php | 75 ++++++++++++ .../Project/ServiceOptions/PostgresTest.php | 54 +++++++++ tests/Unit/PHPDocker/Zip/ArchiverTest.php | 113 ++++++++++++++++++ 10 files changed, 638 insertions(+) create mode 100644 tests/Unit/Assert/PostgresTypeValidatorTest.php create mode 100644 tests/Unit/PHPDocker/PhpExtension/AvailableExtensionsFactoryTest.php create mode 100644 tests/Unit/PHPDocker/PhpExtension/BaseAvailableExtensionsTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/BaseTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/ElasticsearchTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/MariaDBTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/MySQLTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/PhpTest.php create mode 100644 tests/Unit/PHPDocker/Project/ServiceOptions/PostgresTest.php create mode 100644 tests/Unit/PHPDocker/Zip/ArchiverTest.php diff --git a/tests/Unit/Assert/PostgresTypeValidatorTest.php b/tests/Unit/Assert/PostgresTypeValidatorTest.php new file mode 100644 index 00000000..29decb04 --- /dev/null +++ b/tests/Unit/Assert/PostgresTypeValidatorTest.php @@ -0,0 +1,46 @@ + + */ +class PostgresTypeValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): ConstraintValidatorInterface + { + return new PostgresTypeValidator(); + } + + #[Test] + public function validPostgresVersionProducesNoViolations(): void + { + $this->validator->validate('15', new PostgresType()); + $this->assertNoViolation(); + } + + #[Test] + public function invalidVersionStringProducesOneViolation(): void + { + $constraint = new PostgresType(); + $this->validator->validate('99', $constraint); + + $this->buildViolation($constraint->message) + ->setParameter('{{ value }}', '"99"') + ->assertRaised(); + } + + #[Test] + public function nullValueProducesNoViolations(): void + { + $this->validator->validate(null, new PostgresType()); + $this->assertNoViolation(); + } +} diff --git a/tests/Unit/PHPDocker/PhpExtension/AvailableExtensionsFactoryTest.php b/tests/Unit/PHPDocker/PhpExtension/AvailableExtensionsFactoryTest.php new file mode 100644 index 00000000..398ee8d2 --- /dev/null +++ b/tests/Unit/PHPDocker/PhpExtension/AvailableExtensionsFactoryTest.php @@ -0,0 +1,54 @@ +expectException(InvalidArgumentException::class); + AvailableExtensionsFactory::create('7.4'); + } + + #[Test] + public function createThrowsForEmptyVersion(): void + { + $this->expectException(InvalidArgumentException::class); + AvailableExtensionsFactory::create(''); + } +} diff --git a/tests/Unit/PHPDocker/PhpExtension/BaseAvailableExtensionsTest.php b/tests/Unit/PHPDocker/PhpExtension/BaseAvailableExtensionsTest.php new file mode 100644 index 00000000..e1bbc89a --- /dev/null +++ b/tests/Unit/PHPDocker/PhpExtension/BaseAvailableExtensionsTest.php @@ -0,0 +1,79 @@ +extensions = new Php84AvailableExtensions(); + } + + #[Test] + public function getAllReturnsNonEmptyArray(): void + { + $all = $this->extensions->getAll(); + self::assertNotEmpty($all); + } + + #[Test] + public function getAllIsIdempotent(): void + { + $first = $this->extensions->getAll(); + $second = $this->extensions->getAll(); + self::assertSame($first, $second); + } + + #[Test] + public function isAvailableReturnsTrueForKnownExtension(): void + { + self::assertTrue($this->extensions->isAvailable('Xdebug')); + } + + #[Test] + public function isAvailableReturnsFalseForUnknownExtension(): void + { + self::assertFalse($this->extensions->isAvailable('nonexistent-ext')); + } + + #[Test] + public function getPhpExtensionReturnsCorrectExtension(): void + { + $ext = $this->extensions->getPhpExtension('Xdebug'); + self::assertInstanceOf(PhpExtension::class, $ext); + self::assertSame('Xdebug', $ext->getName()); + } + + #[Test] + public function getPhpExtensionThrowsForUnknownExtension(): void + { + $this->expectException(NotFoundException::class); + $this->extensions->getPhpExtension('nonexistent-ext'); + } + + #[Test] + public function getOptionalReturnsArrayOfPhpExtensionObjects(): void + { + $optional = $this->extensions->getOptional(); + self::assertNotEmpty($optional); + foreach ($optional as $ext) { + self::assertInstanceOf(PhpExtension::class, $ext); + } + } + + #[Test] + public function extensionPackagesAreMappedCorrectly(): void + { + $ext = $this->extensions->getPhpExtension('Xdebug'); + self::assertContains('php8.4-xdebug', $ext->getPackages()); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/BaseTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/BaseTest.php new file mode 100644 index 00000000..8b227ce1 --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/BaseTest.php @@ -0,0 +1,45 @@ +service = new class extends Base {}; + } + + #[Test] + public function isEnabledReturnsFalseByDefault(): void + { + self::assertFalse($this->service->isEnabled()); + } + + #[Test] + public function setEnabledTrueReturnsTrue(): void + { + $this->service->setEnabled(true); + self::assertTrue($this->service->isEnabled()); + } + + #[Test] + public function setEnabledFalseReturnsFalse(): void + { + $this->service->setEnabled(true); + $this->service->setEnabled(false); + self::assertFalse($this->service->isEnabled()); + } + + #[Test] + public function getExternalPortReturnsNullWhenNoOffset(): void + { + self::assertNull($this->service->getExternalPort(10000)); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/ElasticsearchTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/ElasticsearchTest.php new file mode 100644 index 00000000..d4477ea4 --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/ElasticsearchTest.php @@ -0,0 +1,49 @@ +getVersion()); + } + + #[Test] + public function setVersionTo56Succeeds(): void + { + $es = new Elasticsearch(); + $es->setVersion('5.6'); + self::assertSame('5.6', $es->getVersion()); + } + + #[Test] + public function setVersionWithInvalidVersionThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + (new Elasticsearch())->setVersion('invalid'); + } + + #[Test] + public function getExternalPortReturnsNullAsNoOffsetDefined(): void + { + $es = new Elasticsearch(); + self::assertNull($es->getExternalPort(10000)); + } + + #[Test] + public function getChoicesContainsExpectedVersions(): void + { + $choices = Elasticsearch::getChoices(); + self::assertArrayHasKey('6.5.4', $choices); + self::assertArrayHasKey('5.6', $choices); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/MariaDBTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/MariaDBTest.php new file mode 100644 index 00000000..d2081417 --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/MariaDBTest.php @@ -0,0 +1,56 @@ +getVersion()); + } + + #[Test] + public function setVersionTo104Succeeds(): void + { + $mariadb = new MariaDB(); + $mariadb->setVersion('10.4'); + self::assertSame('10.4', $mariadb->getVersion()); + } + + #[Test] + public function setVersionWithInvalidVersionThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + (new MariaDB())->setVersion('invalid'); + } + + #[Test] + public function getExternalPortReturnsBasePortPlusOffset3(): void + { + $mariadb = new MariaDB(); + self::assertSame(10003, $mariadb->getExternalPort(10000)); + } + + #[Test] + public function getChoicesContainsAllExpectedVersions(): void + { + $choices = MariaDB::getChoices(); + self::assertArrayHasKey('11.0', $choices); + self::assertArrayHasKey('10.11', $choices); + self::assertArrayHasKey('10.10', $choices); + self::assertArrayHasKey('10.9', $choices); + self::assertArrayHasKey('10.8', $choices); + self::assertArrayHasKey('10.7', $choices); + self::assertArrayHasKey('10.6', $choices); + self::assertArrayHasKey('10.5', $choices); + self::assertArrayHasKey('10.4', $choices); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/MySQLTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/MySQLTest.php new file mode 100644 index 00000000..a1cd0a13 --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/MySQLTest.php @@ -0,0 +1,67 @@ +getVersion()); + } + + #[Test] + public function setVersionTo57Succeeds(): void + { + $mysql = new MySQL(); + $mysql->setVersion('5.7'); + self::assertSame('5.7', $mysql->getVersion()); + } + + #[Test] + public function setVersionTo56Succeeds(): void + { + $mysql = new MySQL(); + $mysql->setVersion('5.6'); + self::assertSame('5.6', $mysql->getVersion()); + } + + #[Test] + public function setVersionTo55Succeeds(): void + { + $mysql = new MySQL(); + $mysql->setVersion('5.5'); + self::assertSame('5.5', $mysql->getVersion()); + } + + #[Test] + public function setVersionWithInvalidVersionThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + (new MySQL())->setVersion('invalid'); + } + + #[Test] + public function getExternalPortReturnsBasePortPlusOffset2(): void + { + $mysql = new MySQL(); + self::assertSame(10002, $mysql->getExternalPort(10000)); + } + + #[Test] + public function getChoicesContainsAllExpectedVersions(): void + { + $choices = MySQL::getChoices(); + self::assertArrayHasKey('8.0', $choices); + self::assertArrayHasKey('5.7', $choices); + self::assertArrayHasKey('5.6', $choices); + self::assertArrayHasKey('5.5', $choices); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/PhpTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/PhpTest.php new file mode 100644 index 00000000..d22fb8cd --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/PhpTest.php @@ -0,0 +1,75 @@ +getVersion()); + self::assertTrue($php->isEnabled()); + } + + #[Test] + public function constructorWithHasGitTrueReturnsTrue(): void + { + $php = new Php('8.4', [], true, 'public/index.php'); + self::assertTrue($php->hasGit()); + } + + #[Test] + public function constructorWithHasGitFalseReturnsFalse(): void + { + $php = new Php('8.4', [], false, 'public/index.php'); + self::assertFalse($php->hasGit()); + } + + #[Test] + public function constructorWithKnownExtensionNameAddsExtension(): void + { + $php = new Php('8.4', ['Xdebug'], false, 'public/index.php'); + $extensions = $php->getExtensions(); + self::assertCount(1, $extensions); + self::assertSame('Xdebug', $extensions[0]->getName()); + } + + #[Test] + public function constructorWithUnknownExtensionNameThrowsNotFoundException(): void + { + $this->expectException(NotFoundException::class); + new Php('8.4', ['nonexistent-ext'], false, 'public/index.php'); + } + + #[Test] + public function constructorWithUnsupportedVersionThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + new Php('7.4', [], false, 'public/index.php'); + } + + #[Test] + public function getSupportedVersionsContainsAllSupportedVersions(): void + { + $versions = Php::getSupportedVersions(); + self::assertContains('8.2', $versions); + self::assertContains('8.3', $versions); + self::assertContains('8.4', $versions); + self::assertContains('8.5', $versions); + } + + #[Test] + public function getFrontControllerPathReturnsConstructorValue(): void + { + $php = new Php('8.4', [], false, 'public/index.php'); + self::assertSame('public/index.php', $php->getFrontControllerPath()); + } +} diff --git a/tests/Unit/PHPDocker/Project/ServiceOptions/PostgresTest.php b/tests/Unit/PHPDocker/Project/ServiceOptions/PostgresTest.php new file mode 100644 index 00000000..a088d6b1 --- /dev/null +++ b/tests/Unit/PHPDocker/Project/ServiceOptions/PostgresTest.php @@ -0,0 +1,54 @@ +getVersion()); + } + + #[Test] + public function setVersionTo96Succeeds(): void + { + $postgres = new Postgres(); + $postgres->setVersion('9.6'); + self::assertSame('9.6', $postgres->getVersion()); + } + + #[Test] + public function setVersionWithInvalidVersionThrowsInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + (new Postgres())->setVersion('invalid'); + } + + #[Test] + public function getExternalPortReturnsBasePortPlusOffset4(): void + { + $postgres = new Postgres(); + self::assertSame(10004, $postgres->getExternalPort(10000)); + } + + #[Test] + public function getChoicesContainsAllExpectedVersions(): void + { + $choices = Postgres::getChoices(); + self::assertArrayHasKey('15', $choices); + self::assertArrayHasKey('14', $choices); + self::assertArrayHasKey('13', $choices); + self::assertArrayHasKey('12', $choices); + self::assertArrayHasKey('11', $choices); + self::assertArrayHasKey('10', $choices); + self::assertArrayHasKey('9.6', $choices); + } +} diff --git a/tests/Unit/PHPDocker/Zip/ArchiverTest.php b/tests/Unit/PHPDocker/Zip/ArchiverTest.php new file mode 100644 index 00000000..6e420a5b --- /dev/null +++ b/tests/Unit/PHPDocker/Zip/ArchiverTest.php @@ -0,0 +1,113 @@ +filename; + } + + public function getContents(): string + { + return $this->contents; + } + }; + } + + #[Test] + public function generateArchiveReturnsArchiveWithCorrectFilename(): void + { + $archiver = new Archiver(); + $archive = $archiver->generateArchive('test.zip'); + + self::assertInstanceOf(ArchiveInterface::class, $archive); + self::assertSame('test.zip', $archive->getFilename()); + } + + #[Test] + public function generateArchiveReturnsTmpFilenamePointingToActualFile(): void + { + $archiver = new Archiver(); + $archiver->addFile($this->makeFile('placeholder.txt', 'content')); + $archive = $archiver->generateArchive('test.zip'); + + self::assertFileExists($archive->getTmpFilename()); + } + + #[Test] + public function addFileWithBaseFolderPrefixesFilename(): void + { + $archiver = new Archiver(); + $archiver->setBaseFolder('myfolder'); + $archiver->addFile($this->makeFile('config.txt', 'content')); + + $archive = $archiver->generateArchive('out.zip'); + + $zip = new ZipArchive(); + $zip->open($archive->getTmpFilename()); + self::assertNotFalse($zip->locateName('myfolder/config.txt')); + $zip->close(); + } + + #[Test] + public function addFileWithIgnorePrefixDoesNotApplyBaseFolder(): void + { + $archiver = new Archiver(); + $archiver->setBaseFolder('myfolder'); + $archiver->addFile($this->makeFile('readme.txt', 'content'), ignorePrefix: true); + + $archive = $archiver->generateArchive('out.zip'); + + $zip = new ZipArchive(); + $zip->open($archive->getTmpFilename()); + self::assertNotFalse($zip->locateName('readme.txt')); + self::assertFalse($zip->locateName('myfolder/readme.txt')); + $zip->close(); + } + + #[Test] + public function addFileWithoutBaseFolderDoesNotPrefix(): void + { + $archiver = new Archiver(); + $archiver->addFile($this->makeFile('app.php', 'content')); + + $archive = $archiver->generateArchive('out.zip'); + + $zip = new ZipArchive(); + $zip->open($archive->getTmpFilename()); + self::assertNotFalse($zip->locateName('app.php')); + $zip->close(); + } + + #[Test] + public function addedFileCanBeReadBackFromZip(): void + { + $archiver = new Archiver(); + $archiver->addFile($this->makeFile('hello.txt', 'hello world')); + + $archive = $archiver->generateArchive('out.zip'); + + $zip = new ZipArchive(); + $zip->open($archive->getTmpFilename()); + self::assertSame('hello world', $zip->getFromName('hello.txt')); + $zip->close(); + } +} From 01bd982bb867963d7a12c12ff507a3e1f7e330a0 Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 11 Mar 2026 23:24:02 +0000 Subject: [PATCH 3/4] Add a whole bunch of new functional test cases --- config/services.yaml | 5 + src/Controller/GeneratorController.php | 14 ++- tests/Functional/GeneratorTest.php | 145 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 84d7caf3..46b7f89e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -26,10 +26,15 @@ services: App\Controller\: resource: '../src/Controller/' tags: [ 'controller.service_arguments' ] + bind: + $environment: '%kernel.environment%' # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + App\PHPDocker\Zip\Archiver: + shared: false + # Instantiate some third party libraries we need for autowiring our own services Michelf\MarkdownExtra: ~ Symfony\Component\Yaml\Dumper: ~ diff --git a/src/Controller/GeneratorController.php b/src/Controller/GeneratorController.php index 33acd7e2..9f45083d 100644 --- a/src/Controller/GeneratorController.php +++ b/src/Controller/GeneratorController.php @@ -35,8 +35,10 @@ */ class GeneratorController extends AbstractController { - public function __construct(private readonly Generator $generator) - { + public function __construct( + private readonly Generator $generator, + private readonly string $environment, + ) { } /** @@ -55,12 +57,14 @@ public function create(Request $request): BinaryFileResponse|Response // Generate zip file with docker project $zipFile = $this->generator->generate($project); - // Generate file download & cleanup + // Generate file download & cleanup (keep file in test env so functional tests can read it) $response = new BinaryFileResponse($zipFile->getTmpFilename()); $response ->prepare($request) - ->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $zipFile->getFilename()) - ->deleteFileAfterSend(true); + ->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $zipFile->getFilename()); + if ($this->environment !== 'test') { + $response->deleteFileAfterSend(true); + } return $response; } diff --git a/tests/Functional/GeneratorTest.php b/tests/Functional/GeneratorTest.php index 048d02e7..c840d876 100644 --- a/tests/Functional/GeneratorTest.php +++ b/tests/Functional/GeneratorTest.php @@ -22,6 +22,8 @@ use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use ZipArchive; class GeneratorTest extends WebTestCase { @@ -52,4 +54,147 @@ public function generatorLoads(): void self::assertResponseIsSuccessful(); } + + #[Test] + public function testPhpExtensionsAppearInDockerfile(): void + { + $this->generateAndGetZip([ + 'project[phpOptions][version]' => '8.4', + 'project[phpOptions][phpExtensions84]' => ['Xdebug', 'GD'], + 'project[globalOptions][basePort]' => '8000', + ]); + + $dockerfile = $this->getZipFileContent('phpdocker/php-fpm/Dockerfile'); + + self::assertStringContainsString('php8.4-xdebug', $dockerfile); + self::assertStringContainsString('php8.4-gd', $dockerfile); + } + + #[Test] + public function testGitPackageAppearsInDockerfileWhenEnabled(): void + { + $this->generateAndGetZip([ + 'project[phpOptions][version]' => '8.4', + 'project[phpOptions][hasGit]' => '1', + 'project[globalOptions][basePort]' => '8000', + ]); + + $dockerfile = $this->getZipFileContent('phpdocker/php-fpm/Dockerfile'); + self::assertStringContainsString('git', $dockerfile); + + $this->generateAndGetZip([ + 'project[phpOptions][version]' => '8.4', + 'project[globalOptions][basePort]' => '8000', + ]); + + $dockerfile = $this->getZipFileContent('phpdocker/php-fpm/Dockerfile'); + self::assertStringNotContainsString(' git', $dockerfile); + } + + #[Test] + public function testCustomPathsFlowToMultipleGeneratedFiles(): void + { + $this->generateAndGetZip([ + 'project[globalOptions][basePort]' => '8000', + 'project[globalOptions][appPath]' => '/var/www/myapp', + 'project[globalOptions][dockerWorkingDir]' => '/srv', + 'project[phpOptions][version]' => '8.4', + 'project[phpOptions][frontControllerPath]' => 'app/index.php', + ]); + + $nginxConf = $this->getZipFileContent('phpdocker/nginx/nginx.conf'); + self::assertStringContainsString('index.php', $nginxConf); + self::assertStringContainsString('app', $nginxConf); + + $dockerCompose = $this->getZipFileContent('docker-compose.yml'); + self::assertStringContainsString('/var/www/myapp', $dockerCompose); + + $dockerfile = $this->getZipFileContent('phpdocker/php-fpm/Dockerfile'); + self::assertStringContainsString('/srv', $dockerfile); + } + + #[Test] + public function testNonDefaultDatabaseVersionsAppearInDockerCompose(): void + { + $this->generateAndGetZip([ + 'project[globalOptions][basePort]' => '8000', + 'project[phpOptions][version]' => '8.4', + 'project[mysqlOptions][hasMysql]' => '1', + 'project[mysqlOptions][version]' => '5.7', + 'project[mysqlOptions][rootPassword]' => 'root', + 'project[mysqlOptions][databaseName]' => 'mydb', + 'project[mysqlOptions][username]' => 'user', + 'project[mysqlOptions][password]' => 'pass', + 'project[postgresOptions][hasPostgres]' => '1', + 'project[postgresOptions][version]' => '14', + 'project[postgresOptions][rootUser]' => 'pguser', + 'project[postgresOptions][rootPassword]' => 'pgpass', + 'project[postgresOptions][databaseName]' => 'pgdb', + 'project[mariadbOptions][hasMariadb]' => '1', + 'project[mariadbOptions][version]' => '10.4', + 'project[mariadbOptions][rootPassword]' => 'root', + 'project[mariadbOptions][databaseName]' => 'mydb', + 'project[mariadbOptions][username]' => 'user', + 'project[mariadbOptions][password]' => 'pass', + ]); + + $dockerCompose = $this->getZipFileContent('docker-compose.yml'); + + self::assertStringContainsString('mysql:5.7', $dockerCompose); + self::assertStringContainsString('postgres:14', $dockerCompose); + self::assertStringContainsString('mariadb:10.4', $dockerCompose); + } + + #[Test] + public function testPortOffsetsRespectCustomBasePort(): void + { + $this->generateAndGetZip([ + 'project[globalOptions][basePort]' => '3000', + 'project[phpOptions][version]' => '8.4', + 'project[hasMailhog]' => '1', + 'project[mysqlOptions][hasMysql]' => '1', + 'project[mysqlOptions][rootPassword]' => 'root', + 'project[mysqlOptions][databaseName]' => 'mydb', + 'project[mysqlOptions][username]' => 'user', + 'project[mysqlOptions][password]' => 'pass', + 'project[postgresOptions][hasPostgres]' => '1', + 'project[postgresOptions][rootUser]' => 'pguser', + 'project[postgresOptions][rootPassword]' => 'pgpass', + 'project[postgresOptions][databaseName]' => 'pgdb', + ]); + + $dockerCompose = $this->getZipFileContent('docker-compose.yml'); + + self::assertStringContainsString('3001', $dockerCompose); // Mailhog offset +1 + self::assertStringContainsString('3002', $dockerCompose); // MySQL offset +2 + self::assertStringContainsString('3004', $dockerCompose); // Postgres offset +4 + } + + private function generateAndGetZip(array $formData): void + { + $this->client->request('GET', '/'); + $this->client->submitForm('Generate project archive', $formData); + + self::assertResponseIsSuccessful(); + } + + private function getZipFileContent(string $filename): string + { + $response = $this->client->getResponse(); + self::assertInstanceOf(BinaryFileResponse::class, $response, 'Expected a zip file response; form submission may have failed validation'); + + $path = $response->getFile()->getPathname(); + self::assertFileExists($path, sprintf('Zip temp file does not exist at: %s', $path)); + + $zip = new ZipArchive(); + $result = $zip->open($path); + self::assertSame(true, $result, sprintf('Failed to open zip at %s: error %d', $path, $result)); + + $fileContent = $zip->getFromName($filename); + $zip->close(); + + self::assertNotFalse($fileContent, sprintf('File "%s" not found in zip archive', $filename)); + + return $fileContent; + } } From 907bc18fb0c36f25188739825411e7ad7202f51d Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 11 Mar 2026 23:26:47 +0000 Subject: [PATCH 4/4] Fix old config throwing errors on phpstan --- phpstan.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon b/phpstan.neon index 12602903..931ce8c7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,7 +6,7 @@ parameters: - src/ - tests/ - checkMissingIterableValueType: false ignoreErrors: + - identifier: missingType.iterableValue - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::\$message#' - '#Method App\\PHPDocker\\Project\\ServiceOptions\\Postgres::getChoices\(\) should return array but returns array#'