feat: introduce user management events

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-07 22:32:45 -04:00
parent 985e4a6450
commit f147ffc5c7
11 changed files with 457 additions and 19 deletions
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace KTXC\Console\Tenant; namespace KTXC\Console\Tenant;
use KTXC\Context\TenantContext;
use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use KTXF\Utile\UUID; use KTXF\Utile\UUID;
@@ -35,6 +37,8 @@ class TenantCreateCommand extends Command
private readonly TenantService $tenantService, private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore, private readonly UserRolesStore $rolesStore,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly TenantContext $tenantContext,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
parent::__construct(); parent::__construct();
@@ -97,6 +101,10 @@ class TenantCreateCommand extends Command
$io->error('Failed to create tenant.'); $io->error('Failed to create tenant.');
return Command::FAILURE; 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', [ $this->logger->info('Tenant created via console', [
'identifier' => $identifier, 'identifier' => $identifier,
@@ -134,7 +142,7 @@ class TenantCreateCommand extends Command
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) { if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation."); $io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
} else { } else {
$this->userStore->createUser($identifier, [ $this->userService->createUser([
'identity' => $adminIdentity, 'identity' => $adminIdentity,
'label' => 'Administrator', 'label' => 'Administrator',
'enabled' => true, 'enabled' => true,
+7 -5
View File
@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace KTXC\Console\User; namespace KTXC\Console\User;
use KTXC\Service\TenantService; use KTXC\Context\TenantContext;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -28,8 +29,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class UserCreateCommand extends Command class UserCreateCommand extends Command
{ {
public function __construct( public function __construct(
private readonly TenantService $tenantService, private readonly TenantContext $tenantContext,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly UserRolesStore $rolesStore, private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
@@ -59,11 +61,11 @@ class UserCreateCommand extends Command
$io->title('Create User'); $io->title('Create User');
try { try {
// Ensure the tenant exists if (!$this->tenantContext->resolveIdentifier($tenant)) {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found."); $io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE; return Command::FAILURE;
} }
$tenant = $this->tenantContext->requireIdentifier();
// Ensure identity is unique within the tenant // Ensure identity is unique within the tenant
if ($this->userStore->fetchByIdentity($tenant, $identity)) { if ($this->userStore->fetchByIdentity($tenant, $identity)) {
@@ -95,7 +97,7 @@ class UserCreateCommand extends Command
$userData['uid'] = $input->getOption('uid'); $userData['uid'] = $input->getOption('uid');
} }
$user = $this->userStore->createUser($tenant, $userData); $user = $this->userService->createUser($userData);
$this->logger->info('User created via console', [ $this->logger->info('User created via console', [
'tenant' => $tenant, 'tenant' => $tenant,
+11 -1
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace KTXC\Console\User; namespace KTXC\Console\User;
use KTXC\Context\TenantContext;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -26,7 +28,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class UserDeleteCommand extends Command class UserDeleteCommand extends Command
{ {
public function __construct( public function __construct(
private readonly TenantContext $tenantContext,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
parent::__construct(); parent::__construct();
@@ -52,6 +56,12 @@ class UserDeleteCommand extends Command
$io->title('Delete User'); $io->title('Delete User');
try { 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); $user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) { if (!$user) {
@@ -64,7 +74,7 @@ class UserDeleteCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
if (!$this->userStore->deleteUser($tenant, $user['uid'])) { if (!$this->userService->deleteUser($user['uid'])) {
$io->error("Failed to delete user '{$identity}'."); $io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE; return Command::FAILURE;
} }
+48 -11
View File
@@ -6,14 +6,19 @@ use KTXC\Models\Identity\User;
use KTXC\Context\IdentityContextInterface; use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore; 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 class UserAccountsService
{ {
public function __construct( public function __construct(
private readonly TenantContextInterface $tenantContext, private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore private readonly UserAccountsStore $userStore,
private readonly EventDispatcherInterface $events,
) { ) {
} }
@@ -65,17 +70,53 @@ class UserAccountsService
public function createUser(array $userData): array 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); return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
} }
// =========================================================================
// Helper Methods
// =========================================================================
/** /**
* Check if a profile field is editable by the user * Check if a profile field is editable by the user
* *
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserCreatedEvent extends UserEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserDeletingEvent extends UserEvent
{
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
use KTXF\Event\Event;
abstract class UserEvent extends Event
{
final public function __construct(
private readonly string $userIdentifier,
private readonly string $userIdentity,
private readonly string $userLabel,
private readonly bool $userEnabled,
private readonly array $userRoles,
private readonly string $tenantIdentifier,
private readonly ?string $actorIdentifier = null,
) {
if ($userIdentifier === '') {
throw new \InvalidArgumentException('User lifecycle events require a user ID.');
}
if ($userIdentity === '') {
throw new \InvalidArgumentException('User lifecycle events require a user identity.');
}
if ($tenantIdentifier === '') {
throw new \InvalidArgumentException('User lifecycle events require a tenant ID.');
}
parent::__construct(
static::class,
[
'identifier' => $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;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserUpdatedEvent extends UserEvent
{
}
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace KTXT\Unit\Console\Tenant; namespace KTXT\Unit\Console\Tenant;
use KTXC\Console\Tenant\TenantCreateCommand; use KTXC\Console\Tenant\TenantCreateCommand;
use KTXC\Context\TenantContext;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
@@ -22,6 +24,7 @@ class TenantCreateCommandTest extends TestCase
private TenantService&MockObject $tenantService; private TenantService&MockObject $tenantService;
private UserRolesStore $rolesStore; private UserRolesStore $rolesStore;
private UserAccountsStore $userStore; private UserAccountsStore $userStore;
private UserAccountsService $userService;
private CommandTester $tester; private CommandTester $tester;
private ?TenantObject $deposited = null; private ?TenantObject $deposited = null;
@@ -31,12 +34,21 @@ class TenantCreateCommandTest extends TestCase
$this->rolesStore = $this->createStub(UserRolesStore::class); $this->rolesStore = $this->createStub(UserRolesStore::class);
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']); $this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
$this->userStore = $this->createStub(UserAccountsStore::class); $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( $this->tester = new CommandTester(
new TenantCreateCommand( new TenantCreateCommand(
$this->tenantService, $this->tenantService,
$this->rolesStore, $this->rolesStore,
$this->userStore, $this->userStore,
$this->userService,
new TenantContext($contextTenantService),
new NullLogger(), new NullLogger(),
) )
); );
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXC\User\Event\UserUpdatedEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class UserEventTest extends TestCase
{
#[Test]
#[TestDox('User lifecycle events contain an immutable user snapshot')]
public function containsUserSnapshot(): void
{
$event = UserCreatedEvent::fromUser(
[
'uid' => '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');
}
}
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXC\User\Event\UserUpdatedEvent;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class UserAccountsServiceTest extends TestCase
{
#[Test]
#[TestDox('Successful user creation and deletion emit complete lifecycle events')]
public function emitsLifecycleEvents(): void
{
$user = [
'uid' => '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'));
}
}