From f147ffc5c71adda32607a2544f7181dd1d5e50fa Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 7 Aug 2026 22:32:45 -0400 Subject: [PATCH] feat: introduce user management events Signed-off-by: Sebastian Krupinski --- .../Console/Tenant/TenantCreateCommand.php | 10 +- core/lib/Console/User/UserCreateCommand.php | 12 +- core/lib/Console/User/UserDeleteCommand.php | 12 +- core/lib/Service/UserAccountsService.php | 59 +++++- core/lib/User/Event/UserCreatedEvent.php | 9 + core/lib/User/Event/UserDeletingEvent.php | 9 + core/lib/User/Event/UserEvent.php | 89 ++++++++ core/lib/User/Event/UserUpdatedEvent.php | 9 + .../Tenant/TenantCreateCommandTest.php | 14 +- tests/php/Unit/Event/UserEventTest.php | 62 ++++++ .../Unit/Service/UserAccountsServiceTest.php | 191 ++++++++++++++++++ 11 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 core/lib/User/Event/UserCreatedEvent.php create mode 100644 core/lib/User/Event/UserDeletingEvent.php create mode 100644 core/lib/User/Event/UserEvent.php create mode 100644 core/lib/User/Event/UserUpdatedEvent.php create mode 100644 tests/php/Unit/Event/UserEventTest.php create mode 100644 tests/php/Unit/Service/UserAccountsServiceTest.php diff --git a/core/lib/Console/Tenant/TenantCreateCommand.php b/core/lib/Console/Tenant/TenantCreateCommand.php index aa8ca93..2c24b30 100644 --- a/core/lib/Console/Tenant/TenantCreateCommand.php +++ b/core/lib/Console/Tenant/TenantCreateCommand.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace KTXC\Console\Tenant; +use KTXC\Context\TenantContext; use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Models\Tenant\TenantObject; use KTXC\Service\TenantService; +use KTXC\Service\UserAccountsService; use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserRolesStore; use KTXF\Utile\UUID; @@ -35,6 +37,8 @@ class TenantCreateCommand extends Command private readonly TenantService $tenantService, private readonly UserRolesStore $rolesStore, private readonly UserAccountsStore $userStore, + private readonly UserAccountsService $userService, + private readonly TenantContext $tenantContext, private readonly LoggerInterface $logger ) { parent::__construct(); @@ -97,6 +101,10 @@ class TenantCreateCommand extends Command $io->error('Failed to create tenant.'); return Command::FAILURE; } + if (!$this->tenantContext->resolveIdentifier($identifier)) { + throw new \RuntimeException("Failed to initialize tenant context for '{$identifier}'."); + } + $identifier = $this->tenantContext->requireIdentifier(); $this->logger->info('Tenant created via console', [ 'identifier' => $identifier, @@ -134,7 +142,7 @@ class TenantCreateCommand extends Command if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) { $io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation."); } else { - $this->userStore->createUser($identifier, [ + $this->userService->createUser([ 'identity' => $adminIdentity, 'label' => 'Administrator', 'enabled' => true, diff --git a/core/lib/Console/User/UserCreateCommand.php b/core/lib/Console/User/UserCreateCommand.php index f76c11d..275b4ad 100644 --- a/core/lib/Console/User/UserCreateCommand.php +++ b/core/lib/Console/User/UserCreateCommand.php @@ -4,7 +4,8 @@ declare(strict_types=1); namespace KTXC\Console\User; -use KTXC\Service\TenantService; +use KTXC\Context\TenantContext; +use KTXC\Service\UserAccountsService; use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserRolesStore; use Psr\Log\LoggerInterface; @@ -28,8 +29,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; class UserCreateCommand extends Command { public function __construct( - private readonly TenantService $tenantService, + private readonly TenantContext $tenantContext, private readonly UserAccountsStore $userStore, + private readonly UserAccountsService $userService, private readonly UserRolesStore $rolesStore, private readonly LoggerInterface $logger ) { @@ -59,11 +61,11 @@ class UserCreateCommand extends Command $io->title('Create User'); try { - // Ensure the tenant exists - if (!$this->tenantService->fetchById($tenant)) { + if (!$this->tenantContext->resolveIdentifier($tenant)) { $io->error("Tenant '{$tenant}' not found."); return Command::FAILURE; } + $tenant = $this->tenantContext->requireIdentifier(); // Ensure identity is unique within the tenant if ($this->userStore->fetchByIdentity($tenant, $identity)) { @@ -95,7 +97,7 @@ class UserCreateCommand extends Command $userData['uid'] = $input->getOption('uid'); } - $user = $this->userStore->createUser($tenant, $userData); + $user = $this->userService->createUser($userData); $this->logger->info('User created via console', [ 'tenant' => $tenant, diff --git a/core/lib/Console/User/UserDeleteCommand.php b/core/lib/Console/User/UserDeleteCommand.php index 642ca35..2f56653 100644 --- a/core/lib/Console/User/UserDeleteCommand.php +++ b/core/lib/Console/User/UserDeleteCommand.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace KTXC\Console\User; +use KTXC\Context\TenantContext; +use KTXC\Service\UserAccountsService; use KTXC\Stores\UserAccountsStore; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -26,7 +28,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; class UserDeleteCommand extends Command { public function __construct( + private readonly TenantContext $tenantContext, private readonly UserAccountsStore $userStore, + private readonly UserAccountsService $userService, private readonly LoggerInterface $logger ) { parent::__construct(); @@ -52,6 +56,12 @@ class UserDeleteCommand extends Command $io->title('Delete User'); try { + if (!$this->tenantContext->resolveIdentifier($tenant)) { + $io->error("Tenant '{$tenant}' not found."); + return Command::FAILURE; + } + $tenant = $this->tenantContext->requireIdentifier(); + $user = $this->userStore->fetchByIdentity($tenant, $identity); if (!$user) { @@ -64,7 +74,7 @@ class UserDeleteCommand extends Command return Command::SUCCESS; } - if (!$this->userStore->deleteUser($tenant, $user['uid'])) { + if (!$this->userService->deleteUser($user['uid'])) { $io->error("Failed to delete user '{$identity}'."); return Command::FAILURE; } diff --git a/core/lib/Service/UserAccountsService.php b/core/lib/Service/UserAccountsService.php index c29c9d0..c7d3771 100644 --- a/core/lib/Service/UserAccountsService.php +++ b/core/lib/Service/UserAccountsService.php @@ -6,14 +6,19 @@ use KTXC\Models\Identity\User; use KTXC\Context\IdentityContextInterface; use KTXC\Context\TenantContextInterface; use KTXC\Stores\UserAccountsStore; +use KTXC\User\Event\UserCreatedEvent; +use KTXC\User\Event\UserDeletingEvent; +use KTXC\User\Event\UserUpdatedEvent; +use KTXF\Event\EventDispatcherInterface; class UserAccountsService { public function __construct( private readonly TenantContextInterface $tenantContext, - private readonly IdentityContextInterface $identityContext, - private readonly UserAccountsStore $userStore + private readonly IdentityContextInterface $identityContext, + private readonly UserAccountsStore $userStore, + private readonly EventDispatcherInterface $events, ) { } @@ -65,17 +70,53 @@ class UserAccountsService public function createUser(array $userData): array { - return $this->userStore->createUser($this->tenantContext->identifier(), $userData); + $tenantId = $this->tenantContext->requireIdentifier(); + $user = $this->userStore->createUser($tenantId, $userData); + $this->events->dispatch(UserCreatedEvent::fromUser( + $user, + $tenantId, + $this->identityContext->identifier(), + )); + + return $user; } - public function updateUser(string $uid, array $updates): bool + public function updateUser(string $userId, array $updates): bool { - return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates); + $tenantId = $this->tenantContext->requireIdentifier(); + if (!$this->userStore->updateUser($tenantId, $userId, $updates)) { + return false; + } + + $user = $this->userStore->fetchByIdentifier($tenantId, $userId); + if ($user === null) { + throw new \RuntimeException("Updated user '{$userId}' could not be retrieved."); + } + + $this->events->dispatch(UserUpdatedEvent::fromUser( + $user, + $tenantId, + $this->identityContext->identifier(), + )); + + return true; } - public function deleteUser(string $uid): bool + public function deleteUser(string $userId): bool { - return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid); + $tenantId = $this->tenantContext->requireIdentifier(); + $user = $this->userStore->fetchByIdentifier($tenantId, $userId); + if ($user === null) { + return false; + } + + $this->events->dispatch(UserDeletingEvent::fromUser( + $user, + $tenantId, + $this->identityContext->identifier(), + )); + + return $this->userStore->deleteUser($tenantId, $userId); } // ========================================================================= @@ -126,10 +167,6 @@ class UserAccountsService return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings); } - // ========================================================================= - // Helper Methods - // ========================================================================= - /** * Check if a profile field is editable by the user * diff --git a/core/lib/User/Event/UserCreatedEvent.php b/core/lib/User/Event/UserCreatedEvent.php new file mode 100644 index 0000000..4515e3b --- /dev/null +++ b/core/lib/User/Event/UserCreatedEvent.php @@ -0,0 +1,9 @@ + $userIdentifier, + 'identity' => $userIdentity, + 'label' => $userLabel, + 'roles' => $userRoles, + 'enabled' => $userEnabled, + ], + $tenantIdentifier, + $actorIdentifier, + ); + } + + public static function fromUser( + array $user, + string $tenantIdentifier, + ?string $actorIdentifier = null, + ): static { + return new static( + (string) ($user['uid'] ?? ''), + (string) ($user['identity'] ?? ''), + (string) ($user['label'] ?? $user['identity'] ?? ''), + (bool) ($user['enabled'] ?? true), + array_values((array) ($user['roles'] ?? [])), + $tenantIdentifier, + $actorIdentifier, + ); + } + + public function userIdentifier(): string + { + return $this->userIdentifier; + } + + public function userIdentity(): string + { + return $this->userIdentity; + } + + public function userLabel(): string + { + return $this->userLabel; + } + + public function userRoles(): array + { + return $this->userRoles; + } + + public function userEnabled(): bool + { + return $this->userEnabled; + } + + public function actorIdentifier(): ?string + { + return $this->actorIdentifier; + } + +} diff --git a/core/lib/User/Event/UserUpdatedEvent.php b/core/lib/User/Event/UserUpdatedEvent.php new file mode 100644 index 0000000..a9757b2 --- /dev/null +++ b/core/lib/User/Event/UserUpdatedEvent.php @@ -0,0 +1,9 @@ +rolesStore = $this->createStub(UserRolesStore::class); $this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']); $this->userStore = $this->createStub(UserAccountsStore::class); - $this->userStore->method('createUser')->willReturn(['uid' => 'admin']); + $this->userService = $this->createStub(UserAccountsService::class); + $this->userService->method('createUser')->willReturn(['uid' => 'admin']); + $contextTenantService = $this->createStub(TenantService::class); + $contextTenantService->method('fetchById')->willReturnCallback( + fn(string $identifier): ?TenantObject => $this->deposited?->getIdentifier() === $identifier + ? $this->deposited + : null, + ); $this->tester = new CommandTester( new TenantCreateCommand( $this->tenantService, $this->rolesStore, $this->userStore, + $this->userService, + new TenantContext($contextTenantService), new NullLogger(), ) ); diff --git a/tests/php/Unit/Event/UserEventTest.php b/tests/php/Unit/Event/UserEventTest.php new file mode 100644 index 0000000..4ec1c43 --- /dev/null +++ b/tests/php/Unit/Event/UserEventTest.php @@ -0,0 +1,62 @@ + 'user-a', + 'identity' => 'person@example.test', + 'label' => 'Person', + 'enabled' => true, + 'roles' => ['member'], + ], + 'tenant-a', + 'actor-a', + ); + + self::assertSame(UserCreatedEvent::class, $event->getName()); + self::assertSame('user-a', $event->userIdentifier()); + self::assertSame('person@example.test', $event->userIdentity()); + self::assertSame('Person', $event->userLabel()); + self::assertTrue($event->userEnabled()); + self::assertSame(['member'], $event->userRoles()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('actor-a', $event->actorIdentifier()); + self::assertSame('actor-a', $event->getIdentityId()); + } + + #[Test] + #[TestDox('Created, updated, and deleting users have distinct event names')] + public function distinguishesLifecycleStage(): void + { + $user = ['uid' => 'user-a', 'identity' => 'person@example.test']; + + self::assertSame(UserCreatedEvent::class, UserCreatedEvent::fromUser($user, 'tenant-a')->getName()); + self::assertSame(UserUpdatedEvent::class, UserUpdatedEvent::fromUser($user, 'tenant-a')->getName()); + self::assertSame(UserDeletingEvent::class, UserDeletingEvent::fromUser($user, 'tenant-a')->getName()); + } + + #[Test] + #[TestDox('User lifecycle events reject incomplete snapshots')] + public function rejectsIncompleteSnapshot(): void + { + $this->expectException(\InvalidArgumentException::class); + + UserCreatedEvent::fromUser(['identity' => 'person@example.test'], 'tenant-a'); + } +} diff --git a/tests/php/Unit/Service/UserAccountsServiceTest.php b/tests/php/Unit/Service/UserAccountsServiceTest.php new file mode 100644 index 0000000..cf0816e --- /dev/null +++ b/tests/php/Unit/Service/UserAccountsServiceTest.php @@ -0,0 +1,191 @@ + 'user-a', + 'identity' => 'person@example.test', + 'label' => 'Person', + 'enabled' => true, + 'roles' => ['member'], + ]; + + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('identifier')->willReturn('actor-a'); + $store = $this->createMock(UserAccountsStore::class); + $store->expects($this->once()) + ->method('createUser') + ->with('tenant-a', ['identity' => 'person@example.test']) + ->willReturn($user); + $store->expects($this->once()) + ->method('fetchByIdentifier') + ->with('tenant-a', 'user-a') + ->willReturn($user); + $operations = []; + $store->expects($this->once()) + ->method('deleteUser') + ->with('tenant-a', 'user-a') + ->willReturnCallback(static function () use (&$operations): bool { + $operations[] = 'delete'; + return true; + }); + + $emitted = []; + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(static function ($event) use (&$emitted, &$operations): void { + $emitted[] = $event; + if ($event instanceof UserDeletingEvent) { + $operations[] = 'event'; + } + }); + + $service = new UserAccountsService($tenant, $identity, $store, $events); + + self::assertSame($user, $service->createUser(['identity' => 'person@example.test'])); + self::assertTrue($service->deleteUser('user-a')); + self::assertInstanceOf(UserCreatedEvent::class, $emitted[0]); + self::assertInstanceOf(UserDeletingEvent::class, $emitted[1]); + self::assertSame('actor-a', $emitted[0]->actorIdentifier()); + self::assertSame('tenant-a', $emitted[1]->getTenantId()); + self::assertSame(['event', 'delete'], $operations); + } + + #[Test] + #[TestDox('Deleting event precedes a failed persistence attempt')] + public function emitsBeforeFailedDeletion(): void + { + $store = $this->createStub(UserAccountsStore::class); + $store->method('fetchByIdentifier')->willReturn([ + 'uid' => 'user-a', + 'identity' => 'person@example.test', + ]); + $store->method('deleteUser')->willReturn(false); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->once()) + ->method('dispatch') + ->with(self::isInstanceOf(UserDeletingEvent::class)); + + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $service = new UserAccountsService( + $tenant, + $this->createStub(IdentityContextInterface::class), + $store, + $events, + ); + + self::assertFalse($service->deleteUser('user-a')); + } + + #[Test] + #[TestDox('Successful updates emit the persisted user snapshot after storage')] + public function emitsAfterSuccessfulUpdate(): void + { + $updatedUser = [ + 'uid' => 'user-a', + 'identity' => 'person@example.test', + 'label' => 'Updated Person', + 'enabled' => true, + 'roles' => ['admin'], + ]; + $operations = []; + $store = $this->createMock(UserAccountsStore::class); + $store->expects($this->once()) + ->method('updateUser') + ->with('tenant-a', 'user-a', ['label' => 'Updated Person']) + ->willReturnCallback(static function () use (&$operations): bool { + $operations[] = 'update'; + return true; + }); + $store->expects($this->once()) + ->method('fetchByIdentifier') + ->with('tenant-a', 'user-a') + ->willReturnCallback(static function () use (&$operations, $updatedUser): array { + $operations[] = 'fetch'; + return $updatedUser; + }); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->once()) + ->method('dispatch') + ->with(self::callback(static function ($event) use (&$operations): bool { + $operations[] = 'event'; + return $event instanceof UserUpdatedEvent + && $event->userLabel() === 'Updated Person' + && $event->userRoles() === ['admin']; + })); + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('identifier')->willReturn('actor-a'); + $service = new UserAccountsService($tenant, $identity, $store, $events); + + self::assertTrue($service->updateUser('user-a', ['label' => 'Updated Person'])); + self::assertSame(['update', 'fetch', 'event'], $operations); + } + + #[Test] + #[TestDox('Unchanged users do not emit an updated event')] + public function ignoresUnchangedUser(): void + { + $store = $this->createMock(UserAccountsStore::class); + $store->expects($this->once())->method('updateUser')->willReturn(false); + $store->expects($this->never())->method('fetchByIdentifier'); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->never())->method('dispatch'); + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $service = new UserAccountsService( + $tenant, + $this->createStub(IdentityContextInterface::class), + $store, + $events, + ); + + self::assertFalse($service->updateUser('user-a', ['label' => 'Person'])); + } + + #[Test] + #[TestDox('Missing users do not emit a deleting event')] + public function ignoresMissingUser(): void + { + $store = $this->createStub(UserAccountsStore::class); + $store->method('fetchByIdentifier')->willReturn(null); + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->never())->method('dispatch'); + + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $service = new UserAccountsService( + $tenant, + $this->createStub(IdentityContextInterface::class), + $store, + $events, + ); + + self::assertFalse($service->deleteUser('missing')); + } +}