Split detail page contracts

This commit is contained in:
2026-03-19 19:04:28 +01:00
parent f6046c9168
commit 522705dd33
21 changed files with 729 additions and 121 deletions

View File

@@ -2,6 +2,8 @@
Multi-tenant admin application built on MintyPHP. Manages tenants, users, departments, roles, permissions, address books, mail, CSV imports, scheduled jobs, and Microsoft Entra ID SSO.
> **Agent workflow:** guards, quality gates, role prompts, contracts, and skills live in `.agents/` — start with `.agents/README.md`.
## Tech Stack
- **Runtime:** PHP 8.5 (FPM) on MintyPHP framework (`mintyphp/core`)
@@ -25,8 +27,16 @@ docker compose exec php vendor/bin/phpunit
# Run PHPStan (level 5)
docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
# Sync module runtime (after module changes or APP_ENABLED_MODULES change)
docker compose exec php php bin/module-runtime-sync.php
# CLI commands (single entry point)
docker compose exec php php bin/console list
docker compose exec php php bin/console module:sync # full module runtime sync
docker compose exec php php bin/console module:migrate # apply module SQL migrations
docker compose exec php php bin/console module:permissions-sync # sync + deactivate orphaned
docker compose exec php php bin/console module:build # build runtime page root
docker compose exec php php bin/console module:assets-sync # publish module web assets
docker compose exec php php bin/console module:deactivate <id> --confirm
docker compose exec php php bin/console scheduler:run
docker compose exec php php bin/console doctor
# Check unused Composer packages
docker compose exec php vendor/bin/composer-unused
@@ -42,6 +52,8 @@ lib/ # All backend PHP (namespace MintyPHP\)
Service/ # Business logic, validation, orchestration
Http/ # Auth guards, API auth, request helpers
Input/ # Request input handling (RequestInput, FormErrors)
Console/ # CLI command framework (ConsoleApplication, Command base class)
Commands/ # Command classes (Module/, Scheduler/, Tools/)
Support/ # Crypto, flash, guard, search, helpers
modules/ # Self-contained feature modules (namespace MintyPHP\Module\<Name>\)
<id>/module.php # Module manifest (routes, slots, providers, permissions)
@@ -70,9 +82,9 @@ db/updates/ # Idempotent SQL update scripts for existing installs
tests/ # PHPUnit tests (namespace MintyPHP\Tests\)
docs/ # German-language documentation (Markdown)
i18n/ # Translation files (de, en)
bin/ # CLI scripts (scheduler, module-runtime-sync, doctor.php)
tools/ # Dev tooling scripts
bin/ # CLI entry point (bin/console) + legacy scripts + shell tools
docker/ # Dockerfiles, Nginx configs, PHP configs
.agents/ # Agent workflow system (contracts, prompts, checks, skills, runs)
```
## Architecture Rules

43
bin/console Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* CoreCore CLI — single entry point for all commands.
*
* Usage:
* php bin/console <command> [arguments] [options]
* php bin/console list
*
* Examples:
* php bin/console module:sync
* php bin/console module:deactivate addressbook --confirm
* php bin/console scheduler:run
* php bin/console doctor
*/
require_once __DIR__ . '/../vendor/autoload.php';
use MintyPHP\Console\ConsoleApplication;
use MintyPHP\Console\Commands\DoctorCommand;
use MintyPHP\Console\Commands\Module\AssetsSyncCommand;
use MintyPHP\Console\Commands\Module\BuildCommand;
use MintyPHP\Console\Commands\Module\DeactivateCommand;
use MintyPHP\Console\Commands\Module\MigrateCommand;
use MintyPHP\Console\Commands\Module\PermissionsSyncCommand;
use MintyPHP\Console\Commands\Module\RuntimeSyncCommand;
use MintyPHP\Console\Commands\Scheduler\RunCommand;
$app = new ConsoleApplication();
$app->register(new RuntimeSyncCommand());
$app->register(new MigrateCommand());
$app->register(new PermissionsSyncCommand());
$app->register(new BuildCommand());
$app->register(new AssetsSyncCommand());
$app->register(new DeactivateCommand());
$app->register(new RunCommand());
$app->register(new DoctorCommand());
exit($app->run($argv));

View File

@@ -15,70 +15,75 @@ declare(strict_types=1);
require_once __DIR__ . '/module-cli-bootstrap.php';
$exitCode = moduleCliRunStep('module-deactivate', static function (): int {
global $argv;
function moduleDeactivateRun(): int
{
return moduleCliRunStep('module-deactivate', static function (): int {
global $argv;
$moduleId = $argv[1] ?? '';
$flags = array_slice($argv, 2);
$moduleId = $argv[1] ?? '';
$flags = array_slice($argv, 2);
if ($moduleId === '' || $moduleId === '--help') {
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
return $moduleId === '--help' ? 0 : 1;
}
if ($moduleId === '' || $moduleId === '--help') {
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
return $moduleId === '--help' ? 0 : 1;
}
$confirm = in_array('--confirm', $flags, true);
$dryRun = in_array('--dry-run', $flags, true);
$confirm = in_array('--confirm', $flags, true);
$dryRun = in_array('--dry-run', $flags, true);
if (!$confirm && !$dryRun) {
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
return 1;
}
if (!$confirm && !$dryRun) {
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
return 1;
}
$projectRoot = moduleCliProjectRoot();
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
$projectRoot = moduleCliProjectRoot();
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
if (!is_file($manifestFile)) {
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
return 1;
}
if (!is_file($manifestFile)) {
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
return 1;
}
$raw = require $manifestFile;
if (!is_array($raw)) {
fwrite(STDERR, "Error: manifest must return an array.\n");
return 1;
}
$raw = require $manifestFile;
if (!is_array($raw)) {
fwrite(STDERR, "Error: manifest must return an array.\n");
return 1;
}
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
$handlerClass = $manifest->deactivationHandler;
if ($handlerClass === null) {
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
$handlerClass = $manifest->deactivationHandler;
if ($handlerClass === null) {
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
return 0;
}
if (!class_exists($handlerClass)) {
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
return 1;
}
$handler = new $handlerClass();
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
return 1;
}
if ($dryRun) {
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
return 0;
}
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
$container = app();
$handler->deactivate($container);
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
return 0;
}
});
}
if (!class_exists($handlerClass)) {
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
return 1;
}
$handler = new $handlerClass();
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
return 1;
}
if ($dryRun) {
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
return 0;
}
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
$container = app();
$handler->deactivate($container);
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
return 0;
});
exit($exitCode);
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
exit(moduleDeactivateRun());
}

23
lib/Console/Command.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace MintyPHP\Console;
/**
* Base class for CLI commands.
*
* Subclasses define a name (e.g. 'module:sync') and implement execute().
* Arguments and options are passed as a simple parsed array.
*/
abstract class Command
{
abstract public function name(): string;
abstract public function description(): string;
/**
* @param list<string> $args Positional arguments (after the command name)
* @param array<string, string|bool> $options Parsed --key=value and --flag options
* @return int Exit code (0 = success)
*/
abstract public function execute(array $args, array $options): int;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace MintyPHP\Console\Commands;
use MintyPHP\Console\Command;
final class DoctorCommand extends Command
{
public function name(): string
{
return 'doctor';
}
public function description(): string
{
return 'Run system health checks';
}
public function execute(array $args, array $options): int
{
// doctor.php is self-contained — include and let it run
$scriptPath = __DIR__ . '/../../../bin/doctor.php';
// Capture the exit code by running as a subprocess to avoid
// the script's own exit() call terminating our process.
$command = PHP_BINARY . ' ' . escapeshellarg($scriptPath);
passthru($command, $exitCode);
return $exitCode;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class AssetsSyncCommand extends Command
{
public function name(): string
{
return 'module:assets-sync';
}
public function description(): string
{
return 'Publish module web assets to web/modules/';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
require_once __DIR__ . '/../../../../bin/module-assets-sync.php';
return moduleAssetsSyncRun();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class BuildCommand extends Command
{
public function name(): string
{
return 'module:build';
}
public function description(): string
{
return 'Build runtime page root with module symlinks';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
require_once __DIR__ . '/../../../../bin/module-build.php';
return moduleBuildRun();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class DeactivateCommand extends Command
{
public function name(): string
{
return 'module:deactivate';
}
public function description(): string
{
return 'Run a module\'s deactivation handler (--confirm or --dry-run)';
}
public function execute(array $args, array $options): int
{
$moduleId = $args[0] ?? '';
if ($moduleId === '') {
fwrite(STDERR, "Usage: php bin/console module:deactivate <module-id> --confirm|--dry-run\n");
return 1;
}
// Rebuild global argv for the underlying script
global $argv;
$argv = ['module-deactivate.php', $moduleId];
if ($options['confirm'] ?? false) {
$argv[] = '--confirm';
}
if ($options['dry-run'] ?? false) {
$argv[] = '--dry-run';
}
require_once __DIR__ . '/../../../../bin/module-deactivate.php';
return moduleDeactivateRun();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class MigrateCommand extends Command
{
public function name(): string
{
return 'module:migrate';
}
public function description(): string
{
return 'Apply pending module SQL migrations';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
require_once __DIR__ . '/../../../../bin/module-migrate.php';
return moduleMigrateRun();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class PermissionsSyncCommand extends Command
{
public function name(): string
{
return 'module:permissions-sync';
}
public function description(): string
{
return 'Sync module permissions to DB and deactivate orphaned ones';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/module-cli-bootstrap.php';
require_once __DIR__ . '/../../../../bin/module-permissions-sync.php';
return modulePermissionsSyncRun();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace MintyPHP\Console\Commands\Module;
use MintyPHP\Console\Command;
final class RuntimeSyncCommand extends Command
{
public function name(): string
{
return 'module:sync';
}
public function description(): string
{
return 'Run full module runtime sync (migrate, permissions, build, assets)';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/module-runtime-sync.php';
return moduleRuntimeSyncRun();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace MintyPHP\Console\Commands\Scheduler;
use MintyPHP\Console\Command;
use MintyPHP\DB;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
final class RunCommand extends Command
{
public function name(): string
{
return 'scheduler:run';
}
public function description(): string
{
return 'Execute due scheduled jobs';
}
public function execute(array $args, array $options): int
{
require_once __DIR__ . '/../../../../bin/cli-bootstrap.php';
$exitCode = 0;
$startedAt = microtime(true);
try {
cliBootstrapApp('scheduler', 'bin/console scheduler:run');
$factory = app(SchedulerServicesFactory::class);
$result = $factory->createSchedulerRunService()->runDueJobs();
if (!($result['ok'] ?? false)) {
$error = (string) ($result['error'] ?? 'unexpected_error');
fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error));
$exitCode = 1;
} else {
fwrite(STDOUT, sprintf(
"scheduler completed: processed=%d success=%d failed=%d skipped=%d duration_ms=%d\n",
(int) ($result['processed'] ?? 0),
(int) ($result['success'] ?? 0),
(int) ($result['failed'] ?? 0),
(int) ($result['skipped'] ?? 0),
(int) ($result['duration_ms'] ?? 0)
));
}
} catch (\Throwable $exception) {
try {
app(SystemAuditService::class)->record('scheduler.run', 'failed', [
'error_code' => 'unexpected_error',
'metadata' => [
'trigger_type' => 'scheduler',
'run_status' => 'failed',
'processed' => 0,
'success_count' => 0,
'failed_count' => 0,
'skipped_count' => 0,
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
'bootstrap_error' => true,
],
]);
} catch (\Throwable) {
// fail-open
}
fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
DB::close();
}
return $exitCode;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace MintyPHP\Console;
/**
* Minimal CLI command dispatcher.
*
* Parses argv into command name, positional args, and --options,
* then dispatches to the matching registered Command instance.
*/
final class ConsoleApplication
{
/** @var array<string, Command> */
private array $commands = [];
public function register(Command $command): void
{
$this->commands[$command->name()] = $command;
}
/**
* @param list<string> $argv Raw CLI arguments (including script name at [0])
*/
public function run(array $argv): int
{
$commandName = $argv[1] ?? '';
if ($commandName === '' || $commandName === 'list' || $commandName === '--help') {
$this->printCommandList();
return 0;
}
if (!isset($this->commands[$commandName])) {
fwrite(STDERR, "Unknown command: {$commandName}\n\n");
$this->printCommandList();
return 1;
}
[$args, $options] = self::parseArgv(array_slice($argv, 2));
return $this->commands[$commandName]->execute($args, $options);
}
private function printCommandList(): void
{
fwrite(STDOUT, "Usage: php bin/console <command> [arguments] [options]\n\n");
fwrite(STDOUT, "Available commands:\n");
// Group by namespace prefix
$grouped = [];
foreach ($this->commands as $name => $command) {
$parts = explode(':', $name, 2);
$group = count($parts) === 2 ? $parts[0] : '';
$grouped[$group][$name] = $command->description();
}
ksort($grouped);
foreach ($grouped as $group => $commands) {
if ($group !== '') {
fwrite(STDOUT, " {$group}\n");
}
ksort($commands);
foreach ($commands as $name => $description) {
fwrite(STDOUT, sprintf(" %-30s %s\n", $name, $description));
}
}
}
/**
* @param list<string> $tokens
* @return array{list<string>, array<string, string|bool>}
*/
private static function parseArgv(array $tokens): array
{
$args = [];
$options = [];
foreach ($tokens as $token) {
if (str_starts_with($token, '--')) {
$option = substr($token, 2);
if (str_contains($option, '=')) {
[$key, $value] = explode('=', $option, 2);
$options[$key] = $value;
} else {
$options[$option] = true;
}
} else {
$args[] = $token;
}
}
return [$args, $options];
}
}

View File

@@ -53,7 +53,9 @@
<file>tests/Architecture/DetailActionPolicyMigrationContractTest.php</file>
<file>tests/Architecture/DetailActionPolicyPartialContractTest.php</file>
<file>tests/Architecture/DetailActionPolicyRuntimeContractTest.php</file>
<file>tests/Architecture/DetailPageContractTest.php</file>
<file>tests/Architecture/DetailPageFormContractTest.php</file>
<file>tests/Architecture/DetailPageRuntimeContractTest.php</file>
<file>tests/Architecture/DetailPageTitlebarContractTest.php</file>
<file>tests/Architecture/DetailValidationActionContractTest.php</file>
<file>tests/Architecture/DetailValidationAssetContractTest.php</file>
<file>tests/Architecture/DetailValidationTemplateContractTest.php</file>

View File

@@ -0,0 +1,22 @@
<?php
namespace MintyPHP\Tests\Architecture;
final class DetailPageContractFiles
{
/**
* @return list<string>
*/
public static function standardDetailFormFiles(): array
{
return [
'pages/admin/users/_form.phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/departments/_form.phtml',
'pages/admin/roles/_form.phtml',
'pages/admin/permissions/_form.phtml',
'pages/admin/scheduled-jobs/edit(default).phtml',
'pages/admin/settings/index(default).phtml',
];
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class DetailPageContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function standardDetailFormFiles(): array
{
return [
'pages/admin/users/_form.phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/departments/_form.phtml',
'pages/admin/roles/_form.phtml',
'pages/admin/permissions/_form.phtml',
'pages/admin/scheduled-jobs/edit(default).phtml',
'pages/admin/settings/index(default).phtml',
];
}
public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void
{
foreach ($this->standardDetailFormFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-standard-detail-form="1"', $content, $file);
}
}
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString("import { initStandardDetailPages } from './components/app-standard-detail-page.js';", $content);
$this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content);
}
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
{
$content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml');
$this->assertStringContainsString('data-detail-unsaved-message=', $content);
$this->assertStringContainsString('data-detail-action-policy="1"', $content);
$this->assertStringContainsString('data-detail-action-kind=', $content);
$this->assertStringContainsString('data-detail-save-primary="1"', $content);
}
public function testImportsWizardFormsUseStandardDetailOptIn(): void
{
$content = $this->readProjectFile('pages/admin/imports/index(default).phtml');
$this->assertStringContainsString('id="imports-upload-form"', $content);
$this->assertStringContainsString('id="imports-mapping-form"', $content);
$this->assertStringContainsString('id="imports-commit-form"', $content);
$this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"'));
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class DetailPageFormContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testScopedDetailFormsUseStandardDetailOptInAttribute(): void
{
foreach (DetailPageContractFiles::standardDetailFormFiles() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-standard-detail-form="1"', $content, $file);
}
}
public function testImportsWizardFormsUseStandardDetailOptIn(): void
{
$content = $this->readProjectFile('pages/admin/imports/index(default).phtml');
$this->assertStringContainsString('id="imports-upload-form"', $content);
$this->assertStringContainsString('id="imports-mapping-form"', $content);
$this->assertStringContainsString('id="imports-commit-form"', $content);
$this->assertSame(3, substr_count($content, 'data-standard-detail-form="1"'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class DetailPageRuntimeContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString(
"import { initStandardDetailPages } from './components/app-standard-detail-page.js';",
$content
);
$this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class DetailPageTitlebarContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void
{
$content = $this->readProjectFile('templates/partials/app-details-titlebar.phtml');
$this->assertStringContainsString('data-detail-unsaved-message=', $content);
$this->assertStringContainsString('data-detail-save-primary="1"', $content);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace MintyPHP\Tests\Console;
use MintyPHP\Console\Command;
use MintyPHP\Console\ConsoleApplication;
use PHPUnit\Framework\TestCase;
final class ConsoleApplicationTest extends TestCase
{
public function testListReturnsZero(): void
{
$app = new ConsoleApplication();
$app->register(new DummyCommand());
$exitCode = $app->run(['bin/console', 'list']);
self::assertSame(0, $exitCode);
}
public function testEmptyArgvShowsListAndReturnsZero(): void
{
$app = new ConsoleApplication();
$exitCode = $app->run(['bin/console']);
self::assertSame(0, $exitCode);
}
public function testUnknownCommandReturnsOne(): void
{
$app = new ConsoleApplication();
$exitCode = $app->run(['bin/console', 'nonexistent']);
self::assertSame(1, $exitCode);
}
public function testDispatchesRegisteredCommand(): void
{
$command = new DummyCommand();
$app = new ConsoleApplication();
$app->register($command);
$exitCode = $app->run(['bin/console', 'test:dummy', 'arg1', '--flag', '--key=value']);
self::assertSame(0, $exitCode);
self::assertSame(['arg1'], $command->lastArgs);
self::assertSame(['flag' => true, 'key' => 'value'], $command->lastOptions);
}
public function testCommandExitCodePropagates(): void
{
$app = new ConsoleApplication();
$app->register(new FailingCommand());
$exitCode = $app->run(['bin/console', 'test:fail']);
self::assertSame(42, $exitCode);
}
public function testMultiplePositionalArgs(): void
{
$command = new DummyCommand();
$app = new ConsoleApplication();
$app->register($command);
$app->run(['bin/console', 'test:dummy', 'first', 'second', 'third']);
self::assertSame(['first', 'second', 'third'], $command->lastArgs);
}
public function testOptionWithEqualsSign(): void
{
$command = new DummyCommand();
$app = new ConsoleApplication();
$app->register($command);
$app->run(['bin/console', 'test:dummy', '--module=addressbook']);
self::assertSame([], $command->lastArgs);
self::assertSame(['module' => 'addressbook'], $command->lastOptions);
}
}
final class DummyCommand extends Command
{
public array $lastArgs = [];
public array $lastOptions = [];
public function name(): string
{
return 'test:dummy';
}
public function description(): string
{
return 'A dummy command';
}
public function execute(array $args, array $options): int
{
$this->lastArgs = $args;
$this->lastOptions = $options;
return 0;
}
}
final class FailingCommand extends Command
{
public function name(): string
{
return 'test:fail';
}
public function description(): string
{
return 'Always fails';
}
public function execute(array $args, array $options): int
{
return 42;
}
}

View File

@@ -35,4 +35,4 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss.
- `tests/Architecture/ContainerFactoryContractTest.php`
- `tests/Architecture/SecurityLoggingContractTest.php`
- `tests/Architecture/StatusTaxonomyContractTest.php`
- `tests/Architecture/DetailPageContractTest.php`
- `tests/Architecture/AuthHelpLinksContractTest.php`