From 49c1d06ccbc8a0cf3450ceecd44ad3c7734482f0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:17:06 -0300 Subject: [PATCH 01/41] refactor: add typed release planning failures Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/ReleasePlanFailure.php | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/Application/Release/Exception/ReleasePlanFailure.php diff --git a/src/Application/Release/Exception/ReleasePlanFailure.php b/src/Application/Release/Exception/ReleasePlanFailure.php new file mode 100644 index 0000000..904022e --- /dev/null +++ b/src/Application/Release/Exception/ReleasePlanFailure.php @@ -0,0 +1,9 @@ + Date: Wed, 30 Sep 2026 08:17:09 -0300 Subject: [PATCH 02/41] refactor: add typed release planning failures Signed-off-by: Vitor Mattos --- .../Release/Exception/InvalidReleasePlanRequest.php | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/Application/Release/Exception/InvalidReleasePlanRequest.php diff --git a/src/Application/Release/Exception/InvalidReleasePlanRequest.php b/src/Application/Release/Exception/InvalidReleasePlanRequest.php new file mode 100644 index 0000000..8e2159d --- /dev/null +++ b/src/Application/Release/Exception/InvalidReleasePlanRequest.php @@ -0,0 +1,9 @@ + Date: Wed, 30 Sep 2026 08:17:12 -0300 Subject: [PATCH 03/41] refactor: add typed release planning failures Signed-off-by: Vitor Mattos --- .../Exception/ReleasePlanConfigurationFailure.php | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/Application/Release/Exception/ReleasePlanConfigurationFailure.php diff --git a/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php new file mode 100644 index 0000000..64d5ce3 --- /dev/null +++ b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php @@ -0,0 +1,9 @@ + Date: Wed, 30 Sep 2026 08:17:15 -0300 Subject: [PATCH 04/41] refactor: add typed release planning failures Signed-off-by: Vitor Mattos --- .../Release/Exception/ReleasePlanRuleViolation.php | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/Application/Release/Exception/ReleasePlanRuleViolation.php diff --git a/src/Application/Release/Exception/ReleasePlanRuleViolation.php b/src/Application/Release/Exception/ReleasePlanRuleViolation.php new file mode 100644 index 0000000..410c770 --- /dev/null +++ b/src/Application/Release/Exception/ReleasePlanRuleViolation.php @@ -0,0 +1,9 @@ + Date: Wed, 30 Sep 2026 08:17:27 -0300 Subject: [PATCH 05/41] refactor: use typed release plan request errors Signed-off-by: Vitor Mattos --- .../Console/Command/ReleasePlanCommandRequest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Application/Console/Command/ReleasePlanCommandRequest.php b/src/Application/Console/Command/ReleasePlanCommandRequest.php index af9f5f1..1782aa9 100644 --- a/src/Application/Console/Command/ReleasePlanCommandRequest.php +++ b/src/Application/Console/Command/ReleasePlanCommandRequest.php @@ -4,7 +4,7 @@ namespace LibreCode\ReleaseTool\Application\Console\Command; -use InvalidArgumentException; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; use LibreCode\ReleaseTool\Application\Release\PlanReleaseInput; use LibreCode\ReleaseTool\Domain\Security\ReleaseMode; use LibreCode\ReleaseTool\Domain\Version\ReleaseChannel; @@ -43,7 +43,7 @@ private static function requiredString(InputInterface $input, string $name): str { $value = self::optionalString($input->getOption($name)); if ($value === null) { - throw new InvalidArgumentException(sprintf('--%s is required.', $name)); + throw new InvalidReleasePlanRequest(sprintf('--%s is required.', $name)); } return $value; @@ -63,7 +63,7 @@ private static function enumOption( $value = (string) $input->getOption($name); $resolved = $enum::tryFrom($value); if ($resolved === null) { - throw new InvalidArgumentException(sprintf('Invalid --%s; expected %s.', $name, $expected)); + throw new InvalidReleasePlanRequest(sprintf('Invalid --%s; expected %s.', $name, $expected)); } return $resolved; From 76cc929c6f30fb717886d1235275314b1a23d805 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:17:31 -0300 Subject: [PATCH 06/41] refactor: isolate release plan use case orchestration Signed-off-by: Vitor Mattos --- .../Release/ReleasePlanUseCase.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Application/Release/ReleasePlanUseCase.php diff --git a/src/Application/Release/ReleasePlanUseCase.php b/src/Application/Release/ReleasePlanUseCase.php new file mode 100644 index 0000000..5f57717 --- /dev/null +++ b/src/Application/Release/ReleasePlanUseCase.php @@ -0,0 +1,34 @@ +configLoader->load($request->configPath); + $this->contextValidator->validate($config, $request->root); + } catch (InvalidArgumentException $exception) { + throw new ReleasePlanConfigurationFailure($exception->getMessage(), 0, $exception); + } + + return $this->planner->plan($config, $request->planInput); + } +} From 958b4be45e35ed461565393378ce4cd86e7a2d03 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:17:47 -0300 Subject: [PATCH 07/41] refactor: keep release plan use case independent from console Signed-off-by: Vitor Mattos --- src/Application/Release/ReleasePlanUseCase.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Application/Release/ReleasePlanUseCase.php b/src/Application/Release/ReleasePlanUseCase.php index 5f57717..5097ced 100644 --- a/src/Application/Release/ReleasePlanUseCase.php +++ b/src/Application/Release/ReleasePlanUseCase.php @@ -8,7 +8,6 @@ use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanConfigurationFailure; -use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; use LibreCode\ReleaseTool\Domain\Release\ReleasePlan; final readonly class ReleasePlanUseCase @@ -20,15 +19,15 @@ public function __construct( ) { } - public function execute(ReleasePlanCommandRequest $request): ReleasePlan + public function execute(string $configPath, string $root, PlanReleaseInput $input): ReleasePlan { try { - $config = $this->configLoader->load($request->configPath); - $this->contextValidator->validate($config, $request->root); + $config = $this->configLoader->load($configPath); + $this->contextValidator->validate($config, $root); } catch (InvalidArgumentException $exception) { throw new ReleasePlanConfigurationFailure($exception->getMessage(), 0, $exception); } - return $this->planner->plan($config, $request->planInput); + return $this->planner->plan($config, $input); } } From 023ba9039c278ad28ae8bcd66e93133c342dad58 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:18:14 -0300 Subject: [PATCH 08/41] refactor: expose explicit git file existence check Signed-off-by: Vitor Mattos --- src/Application/Release/Port/GitRepository.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Application/Release/Port/GitRepository.php b/src/Application/Release/Port/GitRepository.php index 252706a..1efcc56 100644 --- a/src/Application/Release/Port/GitRepository.php +++ b/src/Application/Release/Port/GitRepository.php @@ -19,6 +19,8 @@ public function isAncestor(string $ancestorSha, string $descendantSha): bool; public function tagExists(string $tag): bool; + public function fileExists(string $sha, string $path): bool; + public function readFile(string $sha, string $path): string; public function commitDate(string $sha): string; From c011e2c553cc7da4bbebc6edd86d33edf0618b71 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:18:18 -0300 Subject: [PATCH 09/41] refactor: distinguish missing files from git failures Signed-off-by: Vitor Mattos --- src/Infrastructure/Git/LocalGitRepository.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Infrastructure/Git/LocalGitRepository.php b/src/Infrastructure/Git/LocalGitRepository.php index 45a966c..4783d1e 100644 --- a/src/Infrastructure/Git/LocalGitRepository.php +++ b/src/Infrastructure/Git/LocalGitRepository.php @@ -83,6 +83,20 @@ public function tagExists(string $tag): bool return $process->getExitCode() === 0; } + public function fileExists(string $sha, string $path): bool + { + $process = new Process(['git', 'ls-tree', '--name-only', $sha, '--', $path], $this->root); + $process->run(); + + if (!$process->isSuccessful()) { + throw new DomainException( + trim($process->getErrorOutput()) ?: sprintf('Unable to inspect file %s at %s.', $path, $sha), + ); + } + + return trim($process->getOutput()) !== ''; + } + public function readFile(string $sha, string $path): string { return $this->run(['git', 'show', $sha . ':' . $path]); From 80ddff292d182a170cd0e71e8418b1e0945fc505 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:18:22 -0300 Subject: [PATCH 10/41] test: support explicit git file existence checks Signed-off-by: Vitor Mattos --- tests/Fixtures/Release/InMemoryGitRepository.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Fixtures/Release/InMemoryGitRepository.php b/tests/Fixtures/Release/InMemoryGitRepository.php index beadd81..0d2fcad 100644 --- a/tests/Fixtures/Release/InMemoryGitRepository.php +++ b/tests/Fixtures/Release/InMemoryGitRepository.php @@ -63,6 +63,11 @@ public function tagExists(string $tag): bool return in_array($tag, $this->tags, true); } + public function fileExists(string $sha, string $path): bool + { + return isset($this->files[$sha . ':' . $path]); + } + public function readFile(string $sha, string $path): string { $key = $sha . ':' . $path; From 840dd6d4c7e04931b30db3160df6bf6ec0d10813 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:18:37 -0300 Subject: [PATCH 11/41] refactor: use typed planning violations and explicit file checks Signed-off-by: Vitor Mattos --- src/Application/Release/ReleasePlanner.php | 24 +++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/Application/Release/ReleasePlanner.php b/src/Application/Release/ReleasePlanner.php index 775eba5..a98268b 100644 --- a/src/Application/Release/ReleasePlanner.php +++ b/src/Application/Release/ReleasePlanner.php @@ -4,7 +4,7 @@ namespace LibreCode\ReleaseTool\Application\Release; -use DomainException; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\Port\GitHubRepository; use LibreCode\ReleaseTool\Application\Release\Port\GitRepository; use LibreCode\ReleaseTool\Application\Release\Port\ReleaseMetadataReader; @@ -42,7 +42,7 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $branchHead = $this->git->branchHead($input->branch); $baseSha = $input->ref !== null ? $this->git->resolve($input->ref) : $branchHead; if (!$this->git->isAncestor($baseSha, $branchHead)) { - throw new DomainException('Planning ref must be equal to or an ancestor of the selected branch head.'); + throw new ReleasePlanRuleViolation('Planning ref must be equal to or an ancestor of the selected branch head.'); } $repository = $this->repositoryIdentity($config); @@ -52,7 +52,7 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $previous = $this->git->previousRelease($baseSha, $config->tagPrefix, $config->initialRef); if (!$this->git->isAncestor($previous->sha, $baseSha)) { - throw new DomainException('Previous release baseline is not an ancestor of the planning base.'); + throw new ReleasePlanRuleViolation('Previous release baseline is not an ancestor of the planning base.'); } [$activity, $activityData, $warnings] = $this->releaseActivity( @@ -62,27 +62,23 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $baseSha, ); if (!$activity->hasReleasableActivity()) { - throw new DomainException('No releasable activity exists after the previous release.'); + throw new ReleasePlanRuleViolation('No releasable activity exists after the previous release.'); } $proposed = $this->proposedVersion($config, $metadata->version, $activity, $input); if ($proposed->major !== $appMajor) { - throw new DomainException('A release plan cannot change the app major implicitly.'); + throw new ReleasePlanRuleViolation('A release plan cannot change the app major implicitly.'); } $targetTag = $config->tagPrefix . (string) $proposed; if ($this->git->tagExists($targetTag) || $this->github->releaseExists($repository, $targetTag)) { - throw new DomainException(sprintf('Target tag or release already exists: %s', $targetTag)); + throw new ReleasePlanRuleViolation(sprintf('Target tag or release already exists: %s', $targetTag)); } $changelogTarget = str_replace('{major}', (string) $appMajor, $config->changelogPath); - try { - $this->git->readFile($baseSha, $changelogTarget); - } catch (\Throwable $exception) { - throw new DomainException( + if (!$this->git->fileExists($baseSha, $changelogTarget)) { + throw new ReleasePlanRuleViolation( sprintf('Configured changelog target does not exist at planning base: %s', $changelogTarget), - 0, - $exception, ); } @@ -181,12 +177,12 @@ private function repositoryIdentity(ConsumerConfig $config): string $repository = $config->repository ?? $gitRepository ?? $environmentRepository; if ($repository === null) { - throw new DomainException('Repository identity is unavailable; configure repository explicitly.'); + throw new ReleasePlanRuleViolation('Repository identity is unavailable; configure repository explicitly.'); } foreach ([$gitRepository, $environmentRepository] as $candidate) { if ($candidate !== null && strcasecmp($repository, $candidate) !== 0) { - throw new DomainException(sprintf( + throw new ReleasePlanRuleViolation(sprintf( 'Repository identity mismatch: configured %s, observed %s.', $repository, $candidate, From 93a6f7907a44d8c93af5fb5e5b6dd3d55dc79358 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:19:03 -0300 Subject: [PATCH 12/41] refactor: make release plan command depend on typed use case Signed-off-by: Vitor Mattos --- .../Console/Command/ReleasePlanCommand.php | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Application/Console/Command/ReleasePlanCommand.php b/src/Application/Console/Command/ReleasePlanCommand.php index 8809a6e..68cfebc 100644 --- a/src/Application/Console/Command/ReleasePlanCommand.php +++ b/src/Application/Console/Command/ReleasePlanCommand.php @@ -4,12 +4,8 @@ namespace LibreCode\ReleaseTool\Application\Console\Command; -use DomainException; -use InvalidArgumentException; -use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; -use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; -use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; -use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanFailure; +use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -22,9 +18,7 @@ final class ReleasePlanCommand extends Command public const int EXIT_NOT_READY = 3; public function __construct( - private readonly ReleasePlanning $planner, - private readonly ConsumerConfigLoader $configLoader = new ConsumerConfigLoader(), - private readonly ConsumerConfigContextValidator $contextValidator = new NoopConsumerConfigContextValidator(), + private readonly ReleasePlanUseCase $useCase, private readonly ReleasePlanOutputPublisher $publisher = new ReleasePlanOutputPublisher(), ) { parent::__construct(); @@ -55,11 +49,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { try { $request = ReleasePlanCommandRequest::fromInput($input); - $config = $this->configLoader->load($request->configPath); - $this->contextValidator->validate($config, $request->root); - $plan = $this->planner->plan($config, $request->planInput); + $plan = $this->useCase->execute($request->configPath, $request->root, $request->planInput); $this->publisher->publish($plan, $request->output, $output); - } catch (InvalidArgumentException|DomainException $exception) { + } catch (ReleasePlanFailure $exception) { return $this->error($output, $input, $exception->getMessage()); } From 1e47dcebe7a3bb5112772e7967ce3d1a3186f157 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:19:08 -0300 Subject: [PATCH 13/41] refactor: type explicit version planning errors Signed-off-by: Vitor Mattos --- src/Application/Release/ReleasePlanner.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Application/Release/ReleasePlanner.php b/src/Application/Release/ReleasePlanner.php index a98268b..9a25eea 100644 --- a/src/Application/Release/ReleasePlanner.php +++ b/src/Application/Release/ReleasePlanner.php @@ -379,8 +379,12 @@ private function proposedVersion( $value = substr($value, strlen($config->tagPrefix)); } - $override = Version::parse($value); - $this->versionTransitions->validateOverride($current, $override, $input->channel); + try { + $override = Version::parse($value); + $this->versionTransitions->validateOverride($current, $override, $input->channel); + } catch (\InvalidArgumentException $exception) { + throw new ReleasePlanRuleViolation($exception->getMessage(), 0, $exception); + } return $override; } From 86290168a2503a9d4ee692680fca2295a5f32c7e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:19:18 -0300 Subject: [PATCH 14/41] refactor: compose release plan use case in application factory Signed-off-by: Vitor Mattos --- src/Application/Console/ApplicationFactory.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Application/Console/ApplicationFactory.php b/src/Application/Console/ApplicationFactory.php index e899302..0c38e39 100644 --- a/src/Application/Console/ApplicationFactory.php +++ b/src/Application/Console/ApplicationFactory.php @@ -8,6 +8,7 @@ use LibreCode\ReleaseTool\Application\Artifact\ArtifactValidator; use LibreCode\ReleaseTool\Application\Artifact\NextcloudPackageValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\AppStorePublicationWaitCommand; use LibreCode\ReleaseTool\Application\Console\Command\ArtifactRestoreCommand; @@ -36,6 +37,7 @@ use LibreCode\ReleaseTool\Application\Release\ReleaseFinalizer; use LibreCode\ReleaseTool\Application\Release\ReleaseIdentityValidator; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; +use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use LibreCode\ReleaseTool\Application\Release\ReleasePreflight; use LibreCode\ReleaseTool\Application\Release\ReleasePreparationPublishing; use LibreCode\ReleaseTool\Application\Release\ReleasePreparer; @@ -157,8 +159,11 @@ public static function create( if ($planner !== null) { $application->add(new ReleasePlanCommand( - $planner, - contextValidator: $configValidator ?? new NoopConsumerConfigContextValidator(), + new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + $configValidator ?? new NoopConsumerConfigContextValidator(), + ), )); } From 46599636bb6751961dcf2f17b191c9a2f94d32c4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:19:33 -0300 Subject: [PATCH 15/41] test: use typed release planning failures in command tests Signed-off-by: Vitor Mattos --- .../Command/ReleasePlanCommandTest.php | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php index 42542fc..128ac9d 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php @@ -4,12 +4,14 @@ namespace LibreCode\ReleaseTool\Tests\Unit\Application\Console\Command; -use DomainException; -use InvalidArgumentException; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; +use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\InputInterface; @@ -44,7 +46,7 @@ public function testRequestRejectsInvalidInput(array $options, string $message): static fn (string $name): mixed => $values[$name] ?? null, ); - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidReleasePlanRequest::class); $this->expectExceptionMessage($message); ReleasePlanCommandRequest::fromInput($input); @@ -70,11 +72,14 @@ public static function invalidInputProvider(): iterable public function testExpectedDomainFailureIsRenderedAsInvalidCommand(): void { $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException(new DomainException('Release cannot be planned.')); + $planner->method('plan')->willThrowException(new ReleasePlanRuleViolation('Release cannot be planned.')); $tester = new CommandTester(new ReleasePlanCommand( - $planner, - contextValidator: new NoopConsumerConfigContextValidator(), + new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), + ), )); $status = $tester->execute([ @@ -92,8 +97,11 @@ public function testUnexpectedFailureIsNotMaskedAsUserError(): void $planner->method('plan')->willThrowException(new \RuntimeException('Unexpected infrastructure failure.')); $tester = new CommandTester(new ReleasePlanCommand( - $planner, - contextValidator: new NoopConsumerConfigContextValidator(), + new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), + ), )); $this->expectException(\RuntimeException::class); From 85e43c75b54d56f2b97a6afd3847c4af874a0194 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 08:19:59 -0300 Subject: [PATCH 16/41] test: classify release plan failures with data provider Signed-off-by: Vitor Mattos --- .../Release/ReleasePlanUseCaseTest.php | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/Unit/Application/Release/ReleasePlanUseCaseTest.php diff --git a/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php b/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php new file mode 100644 index 0000000..6797661 --- /dev/null +++ b/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php @@ -0,0 +1,130 @@ +createMock(ReleasePlanning::class); + $planner->expects(self::never())->method('plan'); + + $context = $this->createMock(ConsumerConfigContextValidator::class); + if ($contextError !== null) { + $context->method('validate')->willThrowException(new InvalidArgumentException($contextError)); + } + + $useCase = new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + $context, + ); + + $this->expectException(ReleasePlanConfigurationFailure::class); + $this->expectExceptionMessage($expectedMessage); + + $useCase->execute($configPath, '.', $this->input()); + } + + /** @return iterable */ + public static function configurationFailureProvider(): iterable + { + yield 'missing config file' => [ + '/definitely/missing/release-config.yml', + null, + 'Configuration file not found', + ]; + + yield 'invalid repository context' => [ + self::fixtureConfig(), + 'Repository identity mismatch.', + 'Repository identity mismatch.', + ]; + } + + public function testPlanningRuleViolationIsPreserved(): void + { + $planner = $this->createMock(ReleasePlanning::class); + $planner->method('plan')->willThrowException( + new ReleasePlanRuleViolation('No releasable activity exists after the previous release.'), + ); + + $useCase = new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + $this->validContext(), + ); + + $this->expectException(ReleasePlanRuleViolation::class); + $this->expectExceptionMessage('No releasable activity exists'); + + $useCase->execute(self::fixtureConfig(), '.', $this->input()); + } + + public function testUnexpectedInfrastructureFailureIsNotReclassified(): void + { + $planner = $this->createMock(ReleasePlanning::class); + $planner->method('plan')->willThrowException( + new \RuntimeException('git transport failed'), + ); + + $useCase = new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + $this->validContext(), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('git transport failed'); + + $useCase->execute(self::fixtureConfig(), '.', $this->input()); + } + + private function validContext(): ConsumerConfigContextValidator + { + return new class implements ConsumerConfigContextValidator { + public function validate(ConsumerConfig $config, string $root): void + { + } + }; + } + + private function input(): PlanReleaseInput + { + return new PlanReleaseInput( + 'stable35', + null, + null, + ReleaseChannel::Final, + false, + false, + ReleaseMode::Normal, + null, + ); + } + + private static function fixtureConfig(): string + { + return dirname(__DIR__, 3) . '/Fixtures/Configuration/libresign.yml'; + } +} From 1600d1800907185feeb9fce6bd72e138c9c5e36d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:18:47 -0300 Subject: [PATCH 17/41] test: keep request validation coverage focused Signed-off-by: Vitor Mattos --- .../Console/Command/ReleasePlanCommandRequestTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php index af746c8..8b0ba10 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php @@ -4,8 +4,8 @@ namespace LibreCode\ReleaseTool\Tests\Unit\Application\Console\Command; -use InvalidArgumentException; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; use LibreCode\ReleaseTool\Domain\Security\ReleaseMode; use LibreCode\ReleaseTool\Domain\Version\ReleaseChannel; use PHPUnit\Framework\Attributes\DataProvider; @@ -54,7 +54,7 @@ public function testBuildsTypedRequestFromConsoleOptions(): void #[DataProvider('invalidOptionProvider')] public function testRejectsInvalidOptions(array $options, string $message): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidReleasePlanRequest::class); $this->expectExceptionMessage($message); ReleasePlanCommandRequest::fromInput($this->input($options)); From 4682b4a84ba10cab8220dac48a3dfc194f7a71c5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:18:50 -0300 Subject: [PATCH 18/41] test: preserve release plan failure boundary Signed-off-by: Vitor Mattos --- .../Command/ReleasePlanCommandTest.php | 65 ++----------------- 1 file changed, 6 insertions(+), 59 deletions(-) diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php index 128ac9d..a723ffc 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php @@ -4,72 +4,19 @@ namespace LibreCode\ReleaseTool\Tests\Unit\Application\Console\Command; +use DomainException; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; -use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; -use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Tester\CommandTester; final class ReleasePlanCommandTest extends TestCase { - #[DataProvider('invalidInputProvider')] - public function testRequestRejectsInvalidInput(array $options, string $message): void - { - $input = $this->createMock(InputInterface::class); - $defaults = [ - 'config' => '.nextcloud-release.yml', - 'root' => '.', - 'branch' => 'stable35', - 'ref' => null, - 'release-version' => null, - 'channel' => 'final', - 'mode' => 'normal', - 'safe-public-text' => null, - 'ignore-open-backport' => false, - 'create-follow-up-milestone' => false, - 'json' => false, - 'output-file' => null, - 'github-output' => null, - 'github-step-summary' => null, - 'github-annotations' => false, - 'tool-version' => 'test', - ]; - $values = array_replace($defaults, $options); - $input->method('getOption')->willReturnCallback( - static fn (string $name): mixed => $values[$name] ?? null, - ); - - $this->expectException(InvalidReleasePlanRequest::class); - $this->expectExceptionMessage($message); - - ReleasePlanCommandRequest::fromInput($input); - } - - /** @return iterable,string}> */ - public static function invalidInputProvider(): iterable - { - yield 'missing branch' => [ - ['branch' => ''], - '--branch is required.', - ]; - yield 'invalid channel' => [ - ['channel' => 'nightly'], - 'Invalid --channel; expected alpha, beta, rc or final.', - ]; - yield 'invalid mode' => [ - ['mode' => 'unsafe'], - 'Invalid --mode; expected normal or security.', - ]; - } - - public function testExpectedDomainFailureIsRenderedAsInvalidCommand(): void + public function testExpectedPlanningFailureIsRenderedAsInvalidCommand(): void { $planner = $this->createMock(ReleasePlanning::class); $planner->method('plan')->willThrowException(new ReleasePlanRuleViolation('Release cannot be planned.')); @@ -91,10 +38,10 @@ public function testExpectedDomainFailureIsRenderedAsInvalidCommand(): void self::assertStringContainsString('Release cannot be planned.', $tester->getDisplay()); } - public function testUnexpectedFailureIsNotMaskedAsUserError(): void + public function testUnexpectedDomainFailureIsNotMaskedAsUserError(): void { $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException(new \RuntimeException('Unexpected infrastructure failure.')); + $planner->method('plan')->willThrowException(new DomainException('Unexpected Git failure.')); $tester = new CommandTester(new ReleasePlanCommand( new ReleasePlanUseCase( @@ -104,8 +51,8 @@ public function testUnexpectedFailureIsNotMaskedAsUserError(): void ), )); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unexpected infrastructure failure.'); + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unexpected Git failure.'); $tester->execute([ '--branch' => 'stable35', From 3baf61cfd53ad0597af78a57d5ab42760a641e62 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:19:53 -0300 Subject: [PATCH 19/41] test: cover release plan CLI business contract Signed-off-by: Vitor Mattos --- .../ReleasePlanCommandContractTest.php | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/Acceptance/ReleasePlanCommandContractTest.php diff --git a/tests/Acceptance/ReleasePlanCommandContractTest.php b/tests/Acceptance/ReleasePlanCommandContractTest.php new file mode 100644 index 0000000..bdf515c --- /dev/null +++ b/tests/Acceptance/ReleasePlanCommandContractTest.php @@ -0,0 +1,230 @@ +tester( + milestones: [new MilestoneInfo(150, '💚 Next Patch (35)', 'https://example.test/m150')], + ); + + $status = $tester->execute($this->arguments()); + + self::assertSame(0, $status); + + $plan = $this->json($tester); + self::assertSame(1, $plan['schema']); + self::assertSame('LibreSign/libresign', $plan['repository']); + self::assertSame('stable35', $plan['branch']); + self::assertSame('15.0.3', $plan['current_version']); + self::assertSame('15.0.4', $plan['proposed_version']); + self::assertTrue($plan['ready']); + self::assertSame(150, $plan['milestone']['number']); + self::assertSame('💚 Next Patch (35)', $plan['milestone']['title']); + self::assertSame([], $plan['backport_blockers']); + self::assertStringContainsString( + 'Matched decorated milestone title', + implode("\n", $plan['warnings']), + ); + } + + public function testMissingMilestoneKeepsPlanValidButNotReady(): void + { + $tester = $this->tester(milestones: []); + + $status = $tester->execute($this->arguments()); + + self::assertSame(ReleasePlanCommand::EXIT_NOT_READY, $status); + + $plan = $this->json($tester); + self::assertFalse($plan['ready']); + self::assertNull($plan['milestone']); + self::assertStringContainsString( + 'Expected open milestone not found', + implode("\n", $plan['warnings']), + ); + } + + public function testBackportBlockerRequiresExplicitOverride(): void + { + $backport = new PullRequestInfo( + 20, + '[stable35] backport: fix: pending fix', + '', + 'stable35', + null, + null, + 'https://example.test/20', + ['backport'], + 'contributor', + ); + + $blocked = $this->tester(open: [$backport]); + self::assertSame( + ReleasePlanCommand::EXIT_NOT_READY, + $blocked->execute($this->arguments()), + ); + + $blockedPlan = $this->json($blocked); + self::assertFalse($blockedPlan['ready']); + self::assertSame(20, $blockedPlan['backport_blockers'][0]['number']); + self::assertFalse($blockedPlan['ignore_open_backport']); + + $overridden = $this->tester(open: [$backport]); + self::assertSame( + 0, + $overridden->execute($this->arguments([ + '--ignore-open-backport' => true, + ])), + ); + + $overriddenPlan = $this->json($overridden); + self::assertTrue($overriddenPlan['ready']); + self::assertTrue($overriddenPlan['ignore_open_backport']); + self::assertSame(20, $overriddenPlan['backport_blockers'][0]['number']); + } + + public function testNoReleasableActivityRemainsAnInvalidPlanRequest(): void + { + $tester = $this->tester(closed: []); + + $status = $tester->execute($this->arguments()); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertStringContainsString( + 'No releasable activity exists after the previous release.', + $error['error'], + ); + } + + public function testConfigurationFailureKeepsJsonErrorContract(): void + { + $tester = $this->tester(); + + $status = $tester->execute($this->arguments([ + '--config' => '/definitely/missing/release-config.yml', + ])); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertStringContainsString('Configuration file not found', $error['error']); + } + + /** + * @param list|null $closed + * @param list $open + * @param list|null $milestones + */ + private function tester( + ?array $closed = null, + array $open = [], + ?array $milestones = null, + ): CommandTester { + $git = new InMemoryGitRepository( + 'LibreSign/libresign', + ['stable35' => self::HEAD], + [ + self::HEAD => [self::PREVIOUS, self::MERGE], + self::MERGE => [self::PREVIOUS], + self::PREVIOUS => [], + ], + new PreviousRelease('v15.0.3', self::PREVIOUS, 'v15.0.3'), + [ + self::HEAD . ':docs/changelogs/changelog-15.md' => "# Changelog\n", + ], + ); + + $github = new InMemoryGitHubRepository( + closed: $closed ?? [ + new PullRequestInfo( + 10, + 'fix: correct signature parsing', + '', + 'stable35', + self::MERGE, + '2026-09-20T00:00:00Z', + 'https://example.test/10', + [], + 'contributor', + ), + ], + open: $open, + milestones: $milestones ?? [ + new MilestoneInfo(7, 'Next Patch (35)', 'https://example.test/m7'), + ], + ); + + $planner = new ReleasePlanner( + $git, + $github, + new StaticMetadataReader(new ReleaseMetadata( + Version::parse('15.0.3'), + 35, + 35, + [], + )), + ); + + return new CommandTester(new ReleasePlanCommand( + new ReleasePlanUseCase( + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), + ), + )); + } + + /** @param array $overrides */ + private function arguments(array $overrides = []): array + { + return array_replace([ + '--branch' => 'stable35', + '--config' => dirname(__DIR__) . '/Fixtures/Configuration/libresign.yml', + '--json' => true, + ], $overrides); + } + + /** @return array */ + private function json(CommandTester $tester): array + { + return json_decode( + $tester->getDisplay(), + true, + flags: JSON_THROW_ON_ERROR, + ); + } +} From e36874dd3313c624725b22104aeed47f90ec2013 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:22:37 -0300 Subject: [PATCH 20/41] refactor: preserve release plan exception contracts Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/ReleasePlanFailure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/ReleasePlanFailure.php b/src/Application/Release/Exception/ReleasePlanFailure.php index 904022e..a9d233f 100644 --- a/src/Application/Release/Exception/ReleasePlanFailure.php +++ b/src/Application/Release/Exception/ReleasePlanFailure.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -abstract class ReleasePlanFailure extends \RuntimeException +interface ReleasePlanFailure { } From 4c2fb2fcac940d07ad12c0825dd971011d6c7dd5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:22:52 -0300 Subject: [PATCH 21/41] refactor: preserve release plan exception contracts Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/InvalidReleasePlanRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/InvalidReleasePlanRequest.php b/src/Application/Release/Exception/InvalidReleasePlanRequest.php index 8e2159d..6a70e1f 100644 --- a/src/Application/Release/Exception/InvalidReleasePlanRequest.php +++ b/src/Application/Release/Exception/InvalidReleasePlanRequest.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class InvalidReleasePlanRequest extends ReleasePlanFailure +final class InvalidReleasePlanRequest extends \InvalidArgumentException implements ReleasePlanFailure { } From 2eca3bd1f3faad2aa1d9b7b5b7f5fab627218075 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:22:55 -0300 Subject: [PATCH 22/41] refactor: preserve release plan exception contracts Signed-off-by: Vitor Mattos --- .../Release/Exception/ReleasePlanConfigurationFailure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php index 64d5ce3..3bbad9e 100644 --- a/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php +++ b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class ReleasePlanConfigurationFailure extends ReleasePlanFailure +final class ReleasePlanConfigurationFailure extends \InvalidArgumentException implements ReleasePlanFailure { } From 94bee1ddcffa9ba444ba20efe517988a59d38b34 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:22:58 -0300 Subject: [PATCH 23/41] refactor: preserve release plan exception contracts Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/ReleasePlanRuleViolation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/ReleasePlanRuleViolation.php b/src/Application/Release/Exception/ReleasePlanRuleViolation.php index 410c770..c95751a 100644 --- a/src/Application/Release/Exception/ReleasePlanRuleViolation.php +++ b/src/Application/Release/Exception/ReleasePlanRuleViolation.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class ReleasePlanRuleViolation extends ReleasePlanFailure +final class ReleasePlanRuleViolation extends \DomainException implements ReleasePlanFailure { } From 04c692caf1deade4437ed7f5466852d467e4deb3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:23:28 -0300 Subject: [PATCH 24/41] refactor: preserve release plan command constructor Signed-off-by: Vitor Mattos --- .../Console/Command/ReleasePlanCommand.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Application/Console/Command/ReleasePlanCommand.php b/src/Application/Console/Command/ReleasePlanCommand.php index 68cfebc..7825eb6 100644 --- a/src/Application/Console/Command/ReleasePlanCommand.php +++ b/src/Application/Console/Command/ReleasePlanCommand.php @@ -4,7 +4,11 @@ namespace LibreCode\ReleaseTool\Application\Console\Command; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; +use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanFailure; +use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -17,10 +21,15 @@ final class ReleasePlanCommand extends Command { public const int EXIT_NOT_READY = 3; + private readonly ReleasePlanUseCase $useCase; + public function __construct( - private readonly ReleasePlanUseCase $useCase, + ReleasePlanning $planner, + ConsumerConfigLoader $configLoader = new ConsumerConfigLoader(), + ConsumerConfigContextValidator $contextValidator = new NoopConsumerConfigContextValidator(), private readonly ReleasePlanOutputPublisher $publisher = new ReleasePlanOutputPublisher(), ) { + $this->useCase = new ReleasePlanUseCase($planner, $configLoader, $contextValidator); parent::__construct(); } From 41fe0acc2d8e8e64e66263fb22255866f976cb02 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:23:32 -0300 Subject: [PATCH 25/41] refactor: keep application factory release plan contract Signed-off-by: Vitor Mattos --- src/Application/Console/ApplicationFactory.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Application/Console/ApplicationFactory.php b/src/Application/Console/ApplicationFactory.php index bd6c43f..0f4bc44 100644 --- a/src/Application/Console/ApplicationFactory.php +++ b/src/Application/Console/ApplicationFactory.php @@ -8,7 +8,6 @@ use LibreCode\ReleaseTool\Application\Artifact\ArtifactValidator; use LibreCode\ReleaseTool\Application\Artifact\NextcloudPackageValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; -use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\AppStorePublicationWaitCommand; use LibreCode\ReleaseTool\Application\Console\Command\ArtifactRestoreCommand; @@ -40,7 +39,6 @@ use LibreCode\ReleaseTool\Application\Release\ReleaseIdentityValidator; use LibreCode\ReleaseTool\Application\Release\ReleasePipelinePinVerifier; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; -use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use LibreCode\ReleaseTool\Application\Release\ReleasePreflight; use LibreCode\ReleaseTool\Application\Release\ReleasePreparationPublishing; use LibreCode\ReleaseTool\Application\Release\ReleasePreparer; @@ -174,11 +172,8 @@ public static function create( if ($planner !== null) { $application->add(new ReleasePlanCommand( - new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - $configValidator ?? new NoopConsumerConfigContextValidator(), - ), + $planner, + contextValidator: $configValidator ?? new NoopConsumerConfigContextValidator(), )); } From c39789002667ffa72e421f54dd36f7a8990ff626 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:21 -0300 Subject: [PATCH 26/41] test: keep release plan command contract compatible Signed-off-by: Vitor Mattos --- .../Console/Command/ReleasePlanCommandTest.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php index a723ffc..e0c6ace 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php @@ -10,7 +10,6 @@ use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; -use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; @@ -22,11 +21,9 @@ public function testExpectedPlanningFailureIsRenderedAsInvalidCommand(): void $planner->method('plan')->willThrowException(new ReleasePlanRuleViolation('Release cannot be planned.')); $tester = new CommandTester(new ReleasePlanCommand( - new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - new NoopConsumerConfigContextValidator(), - ), + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), )); $status = $tester->execute([ @@ -44,11 +41,9 @@ public function testUnexpectedDomainFailureIsNotMaskedAsUserError(): void $planner->method('plan')->willThrowException(new DomainException('Unexpected Git failure.')); $tester = new CommandTester(new ReleasePlanCommand( - new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - new NoopConsumerConfigContextValidator(), - ), + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), )); $this->expectException(DomainException::class); From fc97c776baa3207881e902b3b0d29792e343e516 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:24:24 -0300 Subject: [PATCH 27/41] test: exercise preserved release plan command API Signed-off-by: Vitor Mattos --- tests/Acceptance/ReleasePlanCommandContractTest.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Acceptance/ReleasePlanCommandContractTest.php b/tests/Acceptance/ReleasePlanCommandContractTest.php index bdf515c..80cd97c 100644 --- a/tests/Acceptance/ReleasePlanCommandContractTest.php +++ b/tests/Acceptance/ReleasePlanCommandContractTest.php @@ -200,11 +200,9 @@ private function tester( ); return new CommandTester(new ReleasePlanCommand( - new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - new NoopConsumerConfigContextValidator(), - ), + $planner, + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), )); } From 690a621b55e2de0210acf451a672fbf2bd372f59 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:26:20 -0300 Subject: [PATCH 28/41] test: align LibreSign config fixture with consumer contract Signed-off-by: Vitor Mattos --- tests/Fixtures/Configuration/libresign.yml | 42 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/Fixtures/Configuration/libresign.yml b/tests/Fixtures/Configuration/libresign.yml index f025b9b..67d914c 100644 --- a/tests/Fixtures/Configuration/libresign.yml +++ b/tests/Fixtures/Configuration/libresign.yml @@ -4,7 +4,47 @@ app: id: libresign main_branch: main branches: - stable_pattern: '/^stable(?\d+)$/' + stable_pattern: '^stable(?\d+) +version: + source: appinfo/info.xml + mirrors: + - package.json + - package-lock.json + tag_prefix: v +history: + previous_release: reachable-tag +changelog: + strategy: per-major + path: 'docs/changelogs/changelog-{major}.md' + package_root: CHANGELOG.md +milestones: + patch: 'Next Patch ({nextcloud})' + rc: 'Next RC ({nextcloud})' +authorization: + prepare_min_permission: maintain + merge_min_permission: maintain +package: + command: + - make + - appstore + - verify-appstore-package + required_paths: + - appinfo + - lib + - css + - js + - vendor + - 3rdparty + forbidden_paths: + - .git + - node_modules + - tests + +publication: + publisher_workflow: appstore-build-publish.yml + asset_name: '{app}-{tag}.tar.gz' + appstore_api: https://apps.nextcloud.com/api/v1/platform/{nextcloud}.0.0/apps.json + version: source: appinfo/info.xml mirrors: From cd537fb0a6e9f8f97a24f66fcb39474bf7c77672 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:28:28 -0300 Subject: [PATCH 29/41] test: repair LibreSign configuration fixture Signed-off-by: Vitor Mattos --- tests/Fixtures/Configuration/libresign.yml | 42 +--------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/tests/Fixtures/Configuration/libresign.yml b/tests/Fixtures/Configuration/libresign.yml index 67d914c..5fb40b3 100644 --- a/tests/Fixtures/Configuration/libresign.yml +++ b/tests/Fixtures/Configuration/libresign.yml @@ -4,47 +4,7 @@ app: id: libresign main_branch: main branches: - stable_pattern: '^stable(?\d+) -version: - source: appinfo/info.xml - mirrors: - - package.json - - package-lock.json - tag_prefix: v -history: - previous_release: reachable-tag -changelog: - strategy: per-major - path: 'docs/changelogs/changelog-{major}.md' - package_root: CHANGELOG.md -milestones: - patch: 'Next Patch ({nextcloud})' - rc: 'Next RC ({nextcloud})' -authorization: - prepare_min_permission: maintain - merge_min_permission: maintain -package: - command: - - make - - appstore - - verify-appstore-package - required_paths: - - appinfo - - lib - - css - - js - - vendor - - 3rdparty - forbidden_paths: - - .git - - node_modules - - tests - -publication: - publisher_workflow: appstore-build-publish.yml - asset_name: '{app}-{tag}.tar.gz' - appstore_api: https://apps.nextcloud.com/api/v1/platform/{nextcloud}.0.0/apps.json - + stable_pattern: '^stable(?\d+)$' version: source: appinfo/info.xml mirrors: From 4f4559808d8b6689fc9e7170a12d7cb4c2928e45 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:28:52 -0300 Subject: [PATCH 30/41] refactor: keep release plan exception inheritance Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/InvalidReleasePlanRequest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/InvalidReleasePlanRequest.php b/src/Application/Release/Exception/InvalidReleasePlanRequest.php index 6a70e1f..8061f11 100644 --- a/src/Application/Release/Exception/InvalidReleasePlanRequest.php +++ b/src/Application/Release/Exception/InvalidReleasePlanRequest.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class InvalidReleasePlanRequest extends \InvalidArgumentException implements ReleasePlanFailure +final class InvalidReleasePlanRequest extends \InvalidArgumentException { } From 5bc95b38feba40f7b123aef032a5937723eded14 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:28:54 -0300 Subject: [PATCH 31/41] refactor: keep release plan exception inheritance Signed-off-by: Vitor Mattos --- .../Release/Exception/ReleasePlanConfigurationFailure.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php index 3bbad9e..52dbe34 100644 --- a/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php +++ b/src/Application/Release/Exception/ReleasePlanConfigurationFailure.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class ReleasePlanConfigurationFailure extends \InvalidArgumentException implements ReleasePlanFailure +final class ReleasePlanConfigurationFailure extends \InvalidArgumentException { } From 57150cdf92084ee4c110d90bb687e9bc70d17ba7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:28:57 -0300 Subject: [PATCH 32/41] refactor: keep release plan exception inheritance Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/ReleasePlanRuleViolation.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Release/Exception/ReleasePlanRuleViolation.php b/src/Application/Release/Exception/ReleasePlanRuleViolation.php index c95751a..1ea224e 100644 --- a/src/Application/Release/Exception/ReleasePlanRuleViolation.php +++ b/src/Application/Release/Exception/ReleasePlanRuleViolation.php @@ -4,6 +4,6 @@ namespace LibreCode\ReleaseTool\Application\Release\Exception; -final class ReleasePlanRuleViolation extends \DomainException implements ReleasePlanFailure +final class ReleasePlanRuleViolation extends \DomainException { } From 09918f2b5bc822f9e50654cb67f9666954a01c20 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:29:12 -0300 Subject: [PATCH 33/41] refactor: catch explicit release plan failures Signed-off-by: Vitor Mattos --- src/Application/Console/Command/ReleasePlanCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application/Console/Command/ReleasePlanCommand.php b/src/Application/Console/Command/ReleasePlanCommand.php index 7825eb6..bd78be7 100644 --- a/src/Application/Console/Command/ReleasePlanCommand.php +++ b/src/Application/Console/Command/ReleasePlanCommand.php @@ -60,7 +60,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $request = ReleasePlanCommandRequest::fromInput($input); $plan = $this->useCase->execute($request->configPath, $request->root, $request->planInput); $this->publisher->publish($plan, $request->output, $output); - } catch (ReleasePlanFailure $exception) { + } catch (InvalidReleasePlanRequest|ReleasePlanConfigurationFailure|ReleasePlanRuleViolation $exception) { return $this->error($output, $input, $exception->getMessage()); } From 7876e09c8e3c17c701caf7c2733670220d1e5ec3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:29:15 -0300 Subject: [PATCH 34/41] refactor: remove unnecessary release failure marker Signed-off-by: Vitor Mattos --- src/Application/Release/Exception/ReleasePlanFailure.php | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/Application/Release/Exception/ReleasePlanFailure.php diff --git a/src/Application/Release/Exception/ReleasePlanFailure.php b/src/Application/Release/Exception/ReleasePlanFailure.php deleted file mode 100644 index a9d233f..0000000 --- a/src/Application/Release/Exception/ReleasePlanFailure.php +++ /dev/null @@ -1,9 +0,0 @@ - Date: Wed, 30 Sep 2026 10:32:01 -0300 Subject: [PATCH 35/41] fix: import explicit release plan failures Signed-off-by: Vitor Mattos --- src/Application/Console/Command/ReleasePlanCommand.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Application/Console/Command/ReleasePlanCommand.php b/src/Application/Console/Command/ReleasePlanCommand.php index bd78be7..00b98e3 100644 --- a/src/Application/Console/Command/ReleasePlanCommand.php +++ b/src/Application/Console/Command/ReleasePlanCommand.php @@ -7,7 +7,9 @@ use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; -use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanFailure; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanConfigurationFailure; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use Symfony\Component\Console\Attribute\AsCommand; From 7962dfeb0d208b2e3067187f6234b7996f05d89c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:32:04 -0300 Subject: [PATCH 36/41] test: align stable pattern expectation Signed-off-by: Vitor Mattos From af03d1ffb5fde475faf946220637dfa0671c43a0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:32:28 -0300 Subject: [PATCH 37/41] test: expect delimiter-free stable pattern Signed-off-by: Vitor Mattos --- .../ConsumerConfigLoaderTest.php | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php index 7e4f86c..1b6c440 100644 --- a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php +++ b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php @@ -16,7 +16,39 @@ public function testLoadsLibreSignConfigurationWithSeparateAppAndBranchConcepts( $config = (new ConsumerConfigLoader())->load($this->fixture('libresign.yml')); self::assertSame('libresign', $config->appId); - self::assertSame('/^stable(?\d+)$/', $config->stablePattern); + self::assertSame('^stable(?\d+) + self::assertSame('appinfo/info.xml', $config->versionSource); + self::assertSame(['package.json', 'package-lock.json'], $config->versionMirrors); + self::assertSame('docs/changelogs/changelog-{major}.md', $config->changelogPath); + self::assertSame(['make', 'appstore', 'verify-appstore-package'], $config->packageCommand); + self::assertSame(['appinfo', 'lib', 'css', 'js', 'vendor', '3rdparty'], $config->packageRequiredPaths); + self::assertSame(['.git', 'node_modules', 'tests'], $config->packageForbiddenPaths); + self::assertSame('appstore-build-publish.yml', $config->publicationPublisherWorkflow); + self::assertSame('{app}-{tag}.tar.gz', $config->publicationAssetName); + self::assertSame('https://apps.nextcloud.com/api/v1/platform/{nextcloud}.0.0/apps.json', $config->publicationAppStoreApi); + } + + #[DataProvider('invalidConfigurationProvider')] + public function testRejectsInvalidConfiguration(string $fixture, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + (new ConsumerConfigLoader())->load($this->fixture($fixture)); + } + + public static function invalidConfigurationProvider(): iterable + { + yield 'unknown key' => ['invalid-unknown-key.yml', 'Unknown configuration key']; + yield 'shell command executable' => ['invalid-shell-command.yml', 'must not contain shell syntax']; + } + + private function fixture(string $name): string + { + return dirname(__DIR__, 3) . '/Fixtures/Configuration/' . $name; + } +} +, $config->stablePattern); self::assertSame('appinfo/info.xml', $config->versionSource); self::assertSame(['package.json', 'package-lock.json'], $config->versionMirrors); self::assertSame('docs/changelogs/changelog-{major}.md', $config->changelogPath); From 5da56864740f1a24769ab459254479013bdec1ac Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:32:58 -0300 Subject: [PATCH 38/41] test: repair consumer config loader test Signed-off-by: Vitor Mattos --- .../ConsumerConfigLoaderTest.php | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php index 1b6c440..cd9d7bd 100644 --- a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php +++ b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php @@ -16,39 +16,7 @@ public function testLoadsLibreSignConfigurationWithSeparateAppAndBranchConcepts( $config = (new ConsumerConfigLoader())->load($this->fixture('libresign.yml')); self::assertSame('libresign', $config->appId); - self::assertSame('^stable(?\d+) - self::assertSame('appinfo/info.xml', $config->versionSource); - self::assertSame(['package.json', 'package-lock.json'], $config->versionMirrors); - self::assertSame('docs/changelogs/changelog-{major}.md', $config->changelogPath); - self::assertSame(['make', 'appstore', 'verify-appstore-package'], $config->packageCommand); - self::assertSame(['appinfo', 'lib', 'css', 'js', 'vendor', '3rdparty'], $config->packageRequiredPaths); - self::assertSame(['.git', 'node_modules', 'tests'], $config->packageForbiddenPaths); - self::assertSame('appstore-build-publish.yml', $config->publicationPublisherWorkflow); - self::assertSame('{app}-{tag}.tar.gz', $config->publicationAssetName); - self::assertSame('https://apps.nextcloud.com/api/v1/platform/{nextcloud}.0.0/apps.json', $config->publicationAppStoreApi); - } - - #[DataProvider('invalidConfigurationProvider')] - public function testRejectsInvalidConfiguration(string $fixture, string $message): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($message); - - (new ConsumerConfigLoader())->load($this->fixture($fixture)); - } - - public static function invalidConfigurationProvider(): iterable - { - yield 'unknown key' => ['invalid-unknown-key.yml', 'Unknown configuration key']; - yield 'shell command executable' => ['invalid-shell-command.yml', 'must not contain shell syntax']; - } - - private function fixture(string $name): string - { - return dirname(__DIR__, 3) . '/Fixtures/Configuration/' . $name; - } -} -, $config->stablePattern); + self::assertSame('^stable(?\\d+)$', $config->stablePattern); self::assertSame('appinfo/info.xml', $config->versionSource); self::assertSame(['package.json', 'package-lock.json'], $config->versionMirrors); self::assertSame('docs/changelogs/changelog-{major}.md', $config->changelogPath); From 282d7f2200d81e8774ba38b5c34d292fa2ec972d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:38:32 -0300 Subject: [PATCH 39/41] test: cover configuration failures through CLI contract Signed-off-by: Vitor Mattos --- .../ReleasePlanCommandContractTest.php | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/Acceptance/ReleasePlanCommandContractTest.php b/tests/Acceptance/ReleasePlanCommandContractTest.php index 80cd97c..65e0edb 100644 --- a/tests/Acceptance/ReleasePlanCommandContractTest.php +++ b/tests/Acceptance/ReleasePlanCommandContractTest.php @@ -4,6 +4,8 @@ namespace LibreCode\ReleaseTool\Tests\Acceptance; +use InvalidArgumentException; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; @@ -12,7 +14,7 @@ use LibreCode\ReleaseTool\Application\Release\ReadModel\PullRequestInfo; use LibreCode\ReleaseTool\Application\Release\ReadModel\ReleaseMetadata; use LibreCode\ReleaseTool\Application\Release\ReleasePlanner; -use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; +use LibreCode\ReleaseTool\Domain\Configuration\ConsumerConfig; use LibreCode\ReleaseTool\Domain\Version\Version; use LibreCode\ReleaseTool\Tests\Fixtures\Release\InMemoryGitHubRepository; use LibreCode\ReleaseTool\Tests\Fixtures\Release\InMemoryGitRepository; @@ -144,6 +146,25 @@ public function testConfigurationFailureKeepsJsonErrorContract(): void self::assertStringContainsString('Configuration file not found', $error['error']); } + public function testRepositoryContextFailureKeepsJsonErrorContract(): void + { + $validator = new class implements ConsumerConfigContextValidator { + public function validate(ConsumerConfig $config, string $root): void + { + throw new InvalidArgumentException('Repository identity mismatch.'); + } + }; + $tester = $this->tester(contextValidator: $validator); + + $status = $tester->execute($this->arguments()); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertSame('Repository identity mismatch.', $error['error']); + } + /** * @param list|null $closed * @param list $open @@ -153,6 +174,7 @@ private function tester( ?array $closed = null, array $open = [], ?array $milestones = null, + ?ConsumerConfigContextValidator $contextValidator = null, ): CommandTester { $git = new InMemoryGitRepository( 'LibreSign/libresign', @@ -202,7 +224,7 @@ private function tester( return new CommandTester(new ReleasePlanCommand( $planner, new ConsumerConfigLoader(), - new NoopConsumerConfigContextValidator(), + $contextValidator ?? new NoopConsumerConfigContextValidator(), )); } From 40fab4e898db5b4c5690ef2ef349e0b43f008cec Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:38:52 -0300 Subject: [PATCH 40/41] test: prefer release plan behavior coverage Signed-off-by: Vitor Mattos --- .../Release/ReleasePlanUseCaseTest.php | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 tests/Unit/Application/Release/ReleasePlanUseCaseTest.php diff --git a/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php b/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php deleted file mode 100644 index 6797661..0000000 --- a/tests/Unit/Application/Release/ReleasePlanUseCaseTest.php +++ /dev/null @@ -1,130 +0,0 @@ -createMock(ReleasePlanning::class); - $planner->expects(self::never())->method('plan'); - - $context = $this->createMock(ConsumerConfigContextValidator::class); - if ($contextError !== null) { - $context->method('validate')->willThrowException(new InvalidArgumentException($contextError)); - } - - $useCase = new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - $context, - ); - - $this->expectException(ReleasePlanConfigurationFailure::class); - $this->expectExceptionMessage($expectedMessage); - - $useCase->execute($configPath, '.', $this->input()); - } - - /** @return iterable */ - public static function configurationFailureProvider(): iterable - { - yield 'missing config file' => [ - '/definitely/missing/release-config.yml', - null, - 'Configuration file not found', - ]; - - yield 'invalid repository context' => [ - self::fixtureConfig(), - 'Repository identity mismatch.', - 'Repository identity mismatch.', - ]; - } - - public function testPlanningRuleViolationIsPreserved(): void - { - $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException( - new ReleasePlanRuleViolation('No releasable activity exists after the previous release.'), - ); - - $useCase = new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - $this->validContext(), - ); - - $this->expectException(ReleasePlanRuleViolation::class); - $this->expectExceptionMessage('No releasable activity exists'); - - $useCase->execute(self::fixtureConfig(), '.', $this->input()); - } - - public function testUnexpectedInfrastructureFailureIsNotReclassified(): void - { - $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException( - new \RuntimeException('git transport failed'), - ); - - $useCase = new ReleasePlanUseCase( - $planner, - new ConsumerConfigLoader(), - $this->validContext(), - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('git transport failed'); - - $useCase->execute(self::fixtureConfig(), '.', $this->input()); - } - - private function validContext(): ConsumerConfigContextValidator - { - return new class implements ConsumerConfigContextValidator { - public function validate(ConsumerConfig $config, string $root): void - { - } - }; - } - - private function input(): PlanReleaseInput - { - return new PlanReleaseInput( - 'stable35', - null, - null, - ReleaseChannel::Final, - false, - false, - ReleaseMode::Normal, - null, - ); - } - - private static function fixtureConfig(): string - { - return dirname(__DIR__, 3) . '/Fixtures/Configuration/libresign.yml'; - } -} From 6c646cac07716254b838d26952e5a56c6105dccb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Wed, 30 Sep 2026 10:38:55 -0300 Subject: [PATCH 41/41] test: keep only unexpected failure boundary unit test Signed-off-by: Vitor Mattos --- .../Command/ReleasePlanCommandTest.php | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php index e0c6ace..e8b58e9 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php @@ -8,33 +8,12 @@ use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; -use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; final class ReleasePlanCommandTest extends TestCase { - public function testExpectedPlanningFailureIsRenderedAsInvalidCommand(): void - { - $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException(new ReleasePlanRuleViolation('Release cannot be planned.')); - - $tester = new CommandTester(new ReleasePlanCommand( - $planner, - new ConsumerConfigLoader(), - new NoopConsumerConfigContextValidator(), - )); - - $status = $tester->execute([ - '--branch' => 'stable35', - '--config' => dirname(__DIR__, 4) . '/Fixtures/Configuration/libresign.yml', - ]); - - self::assertSame(2, $status); - self::assertStringContainsString('Release cannot be planned.', $tester->getDisplay()); - } - public function testUnexpectedDomainFailureIsNotMaskedAsUserError(): void { $planner = $this->createMock(ReleasePlanning::class);