feat: introduce user management events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace KTXT\Unit\Console\Tenant;
|
||||
|
||||
use KTXC\Console\Tenant\TenantCreateCommand;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
@@ -22,6 +24,7 @@ class TenantCreateCommandTest extends TestCase
|
||||
private TenantService&MockObject $tenantService;
|
||||
private UserRolesStore $rolesStore;
|
||||
private UserAccountsStore $userStore;
|
||||
private UserAccountsService $userService;
|
||||
private CommandTester $tester;
|
||||
private ?TenantObject $deposited = null;
|
||||
|
||||
@@ -31,12 +34,21 @@ class TenantCreateCommandTest extends TestCase
|
||||
$this->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(),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user