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
@@ -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'));
}
}