createMock(TenantProvisioner::class); $provisioner->expects(self::once()) ->method('provision') ->with('classeo_tenant_abc123'); $handler = $this->buildHandler(provisioner: $provisioner); $handler($this->command()); } #[Test] public function itCreatesAdminUser(): void { $userRepository = new InMemoryUserRepository(); $handler = $this->buildHandler(userRepository: $userRepository); $handler($this->command()); $users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID)); self::assertCount(1, $users); self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email); } #[Test] public function itDispatchesInvitationEvent(): void { $dispatched = []; $eventBus = $this->spyEventBus($dispatched); $handler = $this->buildHandler(eventBus: $eventBus); $handler($this->command()); self::assertNotEmpty($dispatched); self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); } #[Test] public function itActivatesEstablishmentAfterProvisioning(): void { $establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment(); $handler = $this->buildHandler(establishmentRepository: $establishmentRepo); $handler($this->command()); $establishment = $establishmentRepo->get( EstablishmentId::fromString(self::ESTABLISHMENT_ID), ); self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); } #[Test] public function itIsIdempotentWhenAdminAlreadyExists(): void { $userRepository = new InMemoryUserRepository(); $dispatched = []; $eventBus = $this->spyEventBus($dispatched); $handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus); // First call creates the admin $handler($this->command()); self::assertCount(1, $dispatched); self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); // Second call is idempotent — re-sends invitation $dispatched = []; $handler($this->command()); self::assertCount(1, $dispatched); self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]); } #[Test] public function itSwitchesDatabaseAndRestores(): void { $switcher = new SpyDatabaseSwitcher(); $handler = $this->buildHandler(databaseSwitcher: $switcher); $handler($this->command()); self::assertCount(1, $switcher->switchedTo); self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]); self::assertTrue($switcher->restoredToDefault); } #[Test] public function itPreservesQueryParametersInDatabaseUrl(): void { $switcher = new SpyDatabaseSwitcher(); $handler = $this->buildHandler(databaseSwitcher: $switcher); $handler($this->command()); self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]); } #[Test] public function itRestoresDatabaseEvenOnFailure(): void { $switcher = new SpyDatabaseSwitcher(); $eventBus = $this->createMock(MessageBusInterface::class); $eventBus->method('dispatch') ->willThrowException(new RuntimeException('Event bus failure')); $handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus); try { $handler($this->command()); } catch (RuntimeException) { // Expected } self::assertTrue($switcher->restoredToDefault); } private function command(): ProvisionEstablishmentCommand { return new ProvisionEstablishmentCommand( establishmentId: self::ESTABLISHMENT_ID, establishmentTenantId: self::TENANT_ID, databaseName: 'classeo_tenant_abc123', subdomain: 'ecole-gamma', adminEmail: 'admin@ecole-gamma.fr', establishmentName: 'École Gamma', ); } private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository { $repo = new InMemoryEstablishmentRepository(); $establishment = Establishment::reconstitute( id: EstablishmentId::fromString(self::ESTABLISHMENT_ID), tenantId: TenantId::fromString(self::TENANT_ID), name: 'École Gamma', subdomain: 'ecole-gamma', databaseName: 'classeo_tenant_abc123', status: EstablishmentStatus::PROVISIONING, createdAt: new DateTimeImmutable('2026-04-07 10:00:00'), createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'), ); $repo->save($establishment); return $repo; } /** * @param object[] $dispatched */ private function spyEventBus(array &$dispatched): MessageBusInterface { $eventBus = $this->createMock(MessageBusInterface::class); $eventBus->method('dispatch') ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { $dispatched[] = $message; return new Envelope($message); }); return $eventBus; } private function buildHandler( ?TenantProvisioner $provisioner = null, ?InMemoryUserRepository $userRepository = null, ?SpyDatabaseSwitcher $databaseSwitcher = null, ?InMemoryEstablishmentRepository $establishmentRepository = null, ?MessageBusInterface $eventBus = null, ): ProvisionEstablishmentHandler { $provisioner ??= $this->createMock(TenantProvisioner::class); $clock = new class implements Clock { public function now(): DateTimeImmutable { return new DateTimeImmutable('2026-04-07 10:00:00'); } }; $userRepository ??= new InMemoryUserRepository(); $databaseSwitcher ??= new SpyDatabaseSwitcher(); $establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment(); $eventBus ??= $this->createMock(MessageBusInterface::class); $eventBus->method('dispatch') ->willReturnCallback(static fn (object $m): Envelope => new Envelope($m)); return new ProvisionEstablishmentHandler( tenantProvisioner: $provisioner, inviteUserHandler: new InviteUserHandler($userRepository, $clock), userRepository: $userRepository, clock: $clock, databaseSwitcher: $databaseSwitcher, establishmentRepository: $establishmentRepository, eventBus: $eventBus, logger: new NullLogger(), masterDatabaseUrl: self::MASTER_URL, ); } }