From 934fb24217960ba13c30d0c3b7fa5d801f1ec2ca Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sun, 19 Jul 2026 22:38:43 -0400 Subject: [PATCH] feat: tenant management console commands Signed-off-by: Sebastian Krupinski --- .../{ => Module}/ModuleDisableCommand.php | 0 .../{ => Module}/ModuleEnableCommand.php | 0 .../{ => Module}/ModuleInstallCommand.php | 0 .../{ => Module}/ModuleListCommand.php | 0 .../{ => Module}/ModuleUninstallCommand.php | 0 .../{ => Module}/ModuleUpgradeCommand.php | 0 .../Console/Tenant/TenantCreateCommand.php | 120 ++++++++++++ .../Console/Tenant/TenantDeleteCommand.php | 100 ++++++++++ core/lib/Console/Tenant/TenantListCommand.php | 71 +++++++ core/lib/Models/Tenant/TenantObject.php | 4 +- core/lib/Service/TenantService.php | 25 +++ core/lib/Stores/TenantStore.php | 11 +- .../Tenant/TenantCreateCommandTest.php | 175 ++++++++++++++++++ .../Tenant/TenantDeleteCommandTest.php | 128 +++++++++++++ .../Console/Tenant/TenantListCommandTest.php | 93 ++++++++++ tests/php/unit/Service/TenantServiceTest.php | 71 +++++++ 16 files changed, 793 insertions(+), 5 deletions(-) rename core/lib/Console/{ => Module}/ModuleDisableCommand.php (100%) rename core/lib/Console/{ => Module}/ModuleEnableCommand.php (100%) rename core/lib/Console/{ => Module}/ModuleInstallCommand.php (100%) rename core/lib/Console/{ => Module}/ModuleListCommand.php (100%) rename core/lib/Console/{ => Module}/ModuleUninstallCommand.php (100%) rename core/lib/Console/{ => Module}/ModuleUpgradeCommand.php (100%) create mode 100644 core/lib/Console/Tenant/TenantCreateCommand.php create mode 100644 core/lib/Console/Tenant/TenantDeleteCommand.php create mode 100644 core/lib/Console/Tenant/TenantListCommand.php create mode 100644 tests/php/unit/Console/Tenant/TenantCreateCommandTest.php create mode 100644 tests/php/unit/Console/Tenant/TenantDeleteCommandTest.php create mode 100644 tests/php/unit/Console/Tenant/TenantListCommandTest.php create mode 100644 tests/php/unit/Service/TenantServiceTest.php diff --git a/core/lib/Console/ModuleDisableCommand.php b/core/lib/Console/Module/ModuleDisableCommand.php similarity index 100% rename from core/lib/Console/ModuleDisableCommand.php rename to core/lib/Console/Module/ModuleDisableCommand.php diff --git a/core/lib/Console/ModuleEnableCommand.php b/core/lib/Console/Module/ModuleEnableCommand.php similarity index 100% rename from core/lib/Console/ModuleEnableCommand.php rename to core/lib/Console/Module/ModuleEnableCommand.php diff --git a/core/lib/Console/ModuleInstallCommand.php b/core/lib/Console/Module/ModuleInstallCommand.php similarity index 100% rename from core/lib/Console/ModuleInstallCommand.php rename to core/lib/Console/Module/ModuleInstallCommand.php diff --git a/core/lib/Console/ModuleListCommand.php b/core/lib/Console/Module/ModuleListCommand.php similarity index 100% rename from core/lib/Console/ModuleListCommand.php rename to core/lib/Console/Module/ModuleListCommand.php diff --git a/core/lib/Console/ModuleUninstallCommand.php b/core/lib/Console/Module/ModuleUninstallCommand.php similarity index 100% rename from core/lib/Console/ModuleUninstallCommand.php rename to core/lib/Console/Module/ModuleUninstallCommand.php diff --git a/core/lib/Console/ModuleUpgradeCommand.php b/core/lib/Console/Module/ModuleUpgradeCommand.php similarity index 100% rename from core/lib/Console/ModuleUpgradeCommand.php rename to core/lib/Console/Module/ModuleUpgradeCommand.php diff --git a/core/lib/Console/Tenant/TenantCreateCommand.php b/core/lib/Console/Tenant/TenantCreateCommand.php new file mode 100644 index 0000000..280b817 --- /dev/null +++ b/core/lib/Console/Tenant/TenantCreateCommand.php @@ -0,0 +1,120 @@ +addArgument('domain', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Domain(s) served by this tenant') + ->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the tenant') + ->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description') + ->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)') + ->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state') + ->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $identifier = $input->getOption('identifier') ?? UUID::v4(); + $domains = array_values(array_unique(array_filter(array_map('trim', $input->getArgument('domain'))))); + + $io->title('Create Tenant'); + + try { + if (empty($domains)) { + $io->error('At least one non-empty domain is required.'); + return Command::FAILURE; + } + + // Ensure identifier is unique + if ($this->tenantService->fetchById($identifier)) { + $io->error("Tenant '{$identifier}' already exists."); + return Command::FAILURE; + } + + // Ensure domains are not claimed by another tenant + foreach ($domains as $domain) { + $existing = $this->tenantService->fetchByDomain($domain); + if ($existing) { + $io->error("Domain '{$domain}' is already assigned to tenant '{$existing->getIdentifier()}'."); + return Command::FAILURE; + } + } + + $tenant = new TenantObject(); + $tenant->setIdentifier($identifier); + $tenant->setEnabled(!$input->getOption('disabled')); + $tenant->setLabel($input->getOption('label') ?? $domains[0]); + $tenant->setDescription($input->getOption('description') ?? ''); + $tenant->setDomains(new DomainCollection($domains)); + $tenant->setConfiguration(new TenantConfiguration()); + + $tenant = $this->tenantService->deposit($tenant); + + if (!$tenant) { + $io->error('Failed to create tenant.'); + return Command::FAILURE; + } + + $this->logger->info('Tenant created via console', [ + 'identifier' => $identifier, + 'command' => $this->getName(), + ]); + + $io->success("Tenant '{$identifier}' created successfully!"); + $io->definitionList( + ['Id' => $tenant->getId()], + ['Identifier' => $tenant->getIdentifier()], + ['Label' => $tenant->getLabel()], + ['Enabled' => $tenant->getEnabled() ? 'yes' : 'no'], + ['Domains' => implode(', ', $domains)], + ); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to create tenant: ' . $e->getMessage()); + $this->logger->error('Tenant create failed', [ + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Tenant/TenantDeleteCommand.php b/core/lib/Console/Tenant/TenantDeleteCommand.php new file mode 100644 index 0000000..e36aafe --- /dev/null +++ b/core/lib/Console/Tenant/TenantDeleteCommand.php @@ -0,0 +1,100 @@ +addArgument('identifier', InputArgument::REQUIRED, 'Tenant identifier to delete') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') + ->setHelp('This command deletes a tenant. Deletion must be confirmed by typing the tenant\'s primary domain. User accounts and module data belonging to the tenant are not removed.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $identifier = $input->getArgument('identifier'); + $force = $input->getOption('force'); + + $io->title('Delete Tenant'); + + try { + $tenant = $this->tenantService->fetchById($identifier); + + if (!$tenant) { + $io->error("Tenant '{$identifier}' not found."); + return Command::FAILURE; + } + + if (!$force) { + // Confirm by typing the tenant's primary domain (identifier for + // legacy tenants without domains) + $domains = $tenant->getDomains()?->getArrayCopy() ?? []; + $confirmation = $domains[0] ?? $identifier; + + $io->definitionList( + ['Identifier' => $tenant->getIdentifier()], + ['Label' => $tenant->getLabel()], + ['Domains' => implode(', ', $domains)], + ); + + $answer = $io->ask("Type '{$confirmation}' to confirm deletion"); + + if ($answer !== $confirmation) { + $io->error('Confirmation did not match. Delete cancelled.'); + return Command::FAILURE; + } + } + + $this->tenantService->destroy($tenant); + + $this->logger->info('Tenant deleted via console', [ + 'identifier' => $identifier, + 'command' => $this->getName(), + ]); + + $io->success("Tenant '{$identifier}' deleted successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to delete tenant: ' . $e->getMessage()); + $this->logger->error('Tenant delete failed', [ + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Tenant/TenantListCommand.php b/core/lib/Console/Tenant/TenantListCommand.php new file mode 100644 index 0000000..07c5f24 --- /dev/null +++ b/core/lib/Console/Tenant/TenantListCommand.php @@ -0,0 +1,71 @@ +setHelp('This command lists all tenants.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Tenants'); + + try { + $tenants = $this->tenantService->list(); + + if (empty($tenants)) { + $io->text('No tenants found.'); + return Command::SUCCESS; + } + + $rows = []; + foreach ($tenants as $tenant) { + $domains = $tenant->getDomains(); + $rows[] = [ + $tenant->getIdentifier(), + $tenant->getLabel(), + $tenant->getEnabled() ? 'yes' : 'no', + $domains ? implode(', ', $domains->getArrayCopy()) : '', + ]; + } + + $io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows); + $io->text(sprintf('Total: %d tenant(s)', count($rows))); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to list tenants: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Models/Tenant/TenantObject.php b/core/lib/Models/Tenant/TenantObject.php index 50373d5..df20d94 100644 --- a/core/lib/Models/Tenant/TenantObject.php +++ b/core/lib/Models/Tenant/TenantObject.php @@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject 'enabled' => $this->enabled, 'label' => $this->label, 'description' => $this->description, - 'domains' => $this->domains, - 'configuration' => $this->configuration, + 'domains' => $this->domains?->getArrayCopy(), + 'configuration' => $this->configuration?->jsonSerialize(), ]; } diff --git a/core/lib/Service/TenantService.php b/core/lib/Service/TenantService.php index fb3f413..b86ceee 100644 --- a/core/lib/Service/TenantService.php +++ b/core/lib/Service/TenantService.php @@ -21,6 +21,31 @@ class TenantService return $this->store->fetch($identifier); } + /** + * List all tenants keyed by id + * + * @return array + */ + public function list(): array + { + return $this->store->list(); + } + + public function deposit(TenantObject $tenant): ?TenantObject + { + $domains = $tenant->getDomains(); + if ($domains === null || count($domains) === 0) { + throw new \InvalidArgumentException('A tenant must have at least one domain.'); + } + + return $this->store->deposit($tenant); + } + + public function destroy(TenantObject $tenant): void + { + $this->store->destroy($tenant); + } + // ========================================================================= // Settings // ========================================================================= diff --git a/core/lib/Stores/TenantStore.php b/core/lib/Stores/TenantStore.php index 157d4f1..8d3d1fe 100644 --- a/core/lib/Stores/TenantStore.php +++ b/core/lib/Stores/TenantStore.php @@ -3,6 +3,7 @@ namespace KTXC\Stores; use KTXC\Db\DataStore; +use KTXC\Db\ObjectId; use KTXC\Models\Tenant\TenantObject; class TenantStore @@ -52,7 +53,9 @@ class TenantStore private function create(TenantObject $entry): ?TenantObject { - $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); + $document = $entry->jsonSerialize(); + unset($document['id']); + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); $entry->setId((string)$result->getInsertedId()); return $entry; } @@ -61,7 +64,9 @@ class TenantStore { $id = $entry->getId(); if (!$id) { return null; } - $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + $document = $entry->jsonSerialize(); + unset($document['id']); + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]); return $entry; } @@ -69,7 +74,7 @@ class TenantStore { $id = $entry->getId(); if (!$id) { return; } - $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]); } // ========================================================================= diff --git a/tests/php/unit/Console/Tenant/TenantCreateCommandTest.php b/tests/php/unit/Console/Tenant/TenantCreateCommandTest.php new file mode 100644 index 0000000..a2820e8 --- /dev/null +++ b/tests/php/unit/Console/Tenant/TenantCreateCommandTest.php @@ -0,0 +1,175 @@ +tenantService = $this->createMock(TenantService::class); + $this->tester = new CommandTester( + new TenantCreateCommand($this->tenantService, new NullLogger()) + ); + } + + /** + * Configure the service mock to accept and capture the deposited tenant + */ + private function expectDeposit(): void + { + $this->tenantService + ->method('deposit') + ->willReturnCallback(function (TenantObject $tenant): TenantObject { + $tenant->setId('6a5d84498ac04b41fa0c43a2'); + $this->deposited = $tenant; + return $tenant; + }); + } + + public function testCreatesTenantWithGeneratedIdentifier(): void + { + $this->expectDeposit(); + + $status = $this->tester->execute(['domain' => ['acme.test']]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertNotNull($this->deposited); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', + $this->deposited->getIdentifier() + ); + $this->assertTrue($this->deposited->getEnabled()); + $this->assertSame(['acme.test'], $this->deposited->getDomains()->getArrayCopy()); + $this->assertStringContainsString('created successfully', $this->tester->getDisplay()); + } + + public function testCreatesTenantWithExplicitIdentifier(): void + { + $this->expectDeposit(); + + $status = $this->tester->execute([ + 'domain' => ['acme.test'], + '--identifier' => 'acme', + ]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertSame('acme', $this->deposited->getIdentifier()); + } + + public function testLabelDefaultsToFirstDomain(): void + { + $this->expectDeposit(); + + $this->tester->execute(['domain' => ['acme.test', 'acme.example']]); + + $this->assertSame('acme.test', $this->deposited->getLabel()); + } + + public function testAppliesOptions(): void + { + $this->expectDeposit(); + + $status = $this->tester->execute([ + 'domain' => ['acme.test'], + '--label' => 'Acme Inc', + '--description' => 'Test tenant', + '--disabled' => true, + ]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertSame('Acme Inc', $this->deposited->getLabel()); + $this->assertSame('Test tenant', $this->deposited->getDescription()); + $this->assertFalse($this->deposited->getEnabled()); + } + + public function testTrimsAndDeduplicatesDomains(): void + { + $this->expectDeposit(); + + $this->tester->execute(['domain' => [' acme.test ', 'acme.test', 'acme.example']]); + + $this->assertSame( + ['acme.test', 'acme.example'], + $this->deposited->getDomains()->getArrayCopy() + ); + } + + public function testFailsWhenNoValidDomainProvided(): void + { + $this->tenantService->expects($this->never())->method('deposit'); + + $status = $this->tester->execute(['domain' => [' ']]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('At least one non-empty domain is required', $this->tester->getDisplay()); + } + + public function testFailsWhenIdentifierAlreadyExists(): void + { + $this->tenantService + ->method('fetchById') + ->with('acme') + ->willReturn(new TenantObject()); + $this->tenantService->expects($this->never())->method('deposit'); + + $status = $this->tester->execute([ + 'domain' => ['acme.test'], + '--identifier' => 'acme', + ]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('already exists', $this->tester->getDisplay()); + } + + public function testFailsWhenDomainAssignedToAnotherTenant(): void + { + $owner = (new TenantObject())->setIdentifier('other'); + $this->tenantService + ->method('fetchByDomain') + ->with('acme.test') + ->willReturn($owner); + $this->tenantService->expects($this->never())->method('deposit'); + + $status = $this->tester->execute(['domain' => ['acme.test']]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString("already assigned to tenant 'other'", $this->tester->getDisplay()); + } + + public function testFailsWhenDepositReturnsNull(): void + { + $this->tenantService->method('deposit')->willReturn(null); + + $status = $this->tester->execute(['domain' => ['acme.test']]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('Failed to create tenant', $this->tester->getDisplay()); + } + + public function testFailsWhenServiceThrows(): void + { + $this->tenantService + ->method('deposit') + ->willThrowException(new \RuntimeException('datastore unavailable')); + + $status = $this->tester->execute(['domain' => ['acme.test']]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('datastore unavailable', $this->tester->getDisplay()); + } +} diff --git a/tests/php/unit/Console/Tenant/TenantDeleteCommandTest.php b/tests/php/unit/Console/Tenant/TenantDeleteCommandTest.php new file mode 100644 index 0000000..6cd9b59 --- /dev/null +++ b/tests/php/unit/Console/Tenant/TenantDeleteCommandTest.php @@ -0,0 +1,128 @@ +tenantService = $this->createMock(TenantService::class); + $this->tester = new CommandTester( + new TenantDeleteCommand($this->tenantService, new NullLogger()) + ); + } + + private function givenTenant(?array $domains = ['acme.test', 'acme.example']): TenantObject + { + $tenant = (new TenantObject()) + ->setId('6a5d84498ac04b41fa0c43a2') + ->setIdentifier('acme') + ->setLabel('Acme Inc') + ->setEnabled(true); + + if ($domains !== null) { + $tenant->setDomains(new DomainCollection($domains)); + } + + $this->tenantService->method('fetchById')->with('acme')->willReturn($tenant); + + return $tenant; + } + + public function testFailsWhenTenantNotFound(): void + { + $this->tenantService->method('fetchById')->willReturn(null); + $this->tenantService->expects($this->never())->method('destroy'); + + $status = $this->tester->execute(['identifier' => 'missing']); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('not found', $this->tester->getDisplay()); + } + + public function testForceDeletesWithoutPrompt(): void + { + $tenant = $this->givenTenant(); + $this->tenantService->expects($this->once())->method('destroy')->with($tenant); + + $status = $this->tester->execute(['identifier' => 'acme', '--force' => true]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('deleted successfully', $this->tester->getDisplay()); + } + + public function testDeletesWhenConfirmationMatchesPrimaryDomain(): void + { + $tenant = $this->givenTenant(); + $this->tenantService->expects($this->once())->method('destroy')->with($tenant); + + $this->tester->setInputs(['acme.test']); + $status = $this->tester->execute(['identifier' => 'acme'], ['interactive' => true]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString("Type 'acme.test' to confirm deletion", $this->tester->getDisplay()); + $this->assertStringContainsString('deleted successfully', $this->tester->getDisplay()); + } + + public function testCancelsWhenConfirmationDoesNotMatch(): void + { + $this->givenTenant(); + $this->tenantService->expects($this->never())->method('destroy'); + + $this->tester->setInputs(['wrong.test']); + $status = $this->tester->execute(['identifier' => 'acme'], ['interactive' => true]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('Confirmation did not match', $this->tester->getDisplay()); + } + + public function testConfirmationFallsBackToIdentifierWhenTenantHasNoDomains(): void + { + $tenant = $this->givenTenant(null); + $this->tenantService->expects($this->once())->method('destroy')->with($tenant); + + $this->tester->setInputs(['acme']); + $status = $this->tester->execute(['identifier' => 'acme'], ['interactive' => true]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString("Type 'acme' to confirm deletion", $this->tester->getDisplay()); + } + + public function testCancelsWhenRunNonInteractivelyWithoutForce(): void + { + $this->givenTenant(); + $this->tenantService->expects($this->never())->method('destroy'); + + $status = $this->tester->execute(['identifier' => 'acme'], ['interactive' => false]); + + $this->assertSame(Command::FAILURE, $status); + } + + public function testFailsWhenDestroyThrows(): void + { + $this->givenTenant(); + $this->tenantService + ->method('destroy') + ->willThrowException(new \RuntimeException('datastore unavailable')); + + $status = $this->tester->execute(['identifier' => 'acme', '--force' => true]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('datastore unavailable', $this->tester->getDisplay()); + } +} diff --git a/tests/php/unit/Console/Tenant/TenantListCommandTest.php b/tests/php/unit/Console/Tenant/TenantListCommandTest.php new file mode 100644 index 0000000..3e89692 --- /dev/null +++ b/tests/php/unit/Console/Tenant/TenantListCommandTest.php @@ -0,0 +1,93 @@ +tenantService = $this->createMock(TenantService::class); + $this->tester = new CommandTester( + new TenantListCommand($this->tenantService) + ); + } + + public function testReportsWhenNoTenantsExist(): void + { + $this->tenantService->method('list')->willReturn([]); + + $status = $this->tester->execute([]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('No tenants found', $this->tester->getDisplay()); + } + + public function testListsTenantsWithDomains(): void + { + $acme = (new TenantObject()) + ->setIdentifier('acme') + ->setLabel('Acme Inc') + ->setEnabled(true) + ->setDomains(new DomainCollection(['acme.test', 'acme.example'])); + + $wayne = (new TenantObject()) + ->setIdentifier('wayne') + ->setLabel('Wayne Corp') + ->setEnabled(false) + ->setDomains(new DomainCollection(['wayne.test'])); + + $this->tenantService->method('list')->willReturn(['a' => $acme, 'b' => $wayne]); + + $status = $this->tester->execute([]); + $display = $this->tester->getDisplay(); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('acme', $display); + $this->assertStringContainsString('Acme Inc', $display); + $this->assertStringContainsString('acme.test, acme.example', $display); + $this->assertStringContainsString('Wayne Corp', $display); + $this->assertStringContainsString('wayne.test', $display); + $this->assertStringContainsString('Total: 2 tenant(s)', $display); + } + + public function testHandlesTenantWithoutDomains(): void + { + $legacy = (new TenantObject()) + ->setIdentifier('legacy') + ->setLabel('Legacy') + ->setEnabled(true); + + $this->tenantService->method('list')->willReturn(['l' => $legacy]); + + $status = $this->tester->execute([]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('legacy', $this->tester->getDisplay()); + } + + public function testFailsWhenServiceThrows(): void + { + $this->tenantService + ->method('list') + ->willThrowException(new \RuntimeException('datastore unavailable')); + + $status = $this->tester->execute([]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('datastore unavailable', $this->tester->getDisplay()); + } +} diff --git a/tests/php/unit/Service/TenantServiceTest.php b/tests/php/unit/Service/TenantServiceTest.php new file mode 100644 index 0000000..8ad7c18 --- /dev/null +++ b/tests/php/unit/Service/TenantServiceTest.php @@ -0,0 +1,71 @@ +store = $this->createMock(TenantStore::class); + $this->service = new TenantService($this->store); + } + + public function testDepositRejectsTenantWithoutDomains(): void + { + $this->store->expects($this->never())->method('deposit'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('at least one domain'); + + $this->service->deposit(new TenantObject()); + } + + public function testDepositRejectsTenantWithEmptyDomainCollection(): void + { + $tenant = (new TenantObject())->setDomains(new DomainCollection([])); + $this->store->expects($this->never())->method('deposit'); + + $this->expectException(\InvalidArgumentException::class); + + $this->service->deposit($tenant); + } + + public function testDepositPassesTenantWithDomainsToStore(): void + { + $tenant = (new TenantObject()) + ->setIdentifier('acme') + ->setDomains(new DomainCollection(['acme.test'])); + + $this->store->expects($this->once())->method('deposit')->with($tenant)->willReturn($tenant); + + $this->assertSame($tenant, $this->service->deposit($tenant)); + } + + public function testListDelegatesToStore(): void + { + $tenants = ['a' => new TenantObject()]; + $this->store->expects($this->once())->method('list')->willReturn($tenants); + + $this->assertSame($tenants, $this->service->list()); + } + + public function testDestroyDelegatesToStore(): void + { + $tenant = new TenantObject(); + $this->store->expects($this->once())->method('destroy')->with($tenant); + + $this->service->destroy($tenant); + } +}