Files
breadcrumb-the-shire/tests/Service/User/UserAccountServiceTest.php

606 lines
24 KiB
PHP
Raw Normal View History

2026-03-06 00:44:52 +01:00
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
2026-03-06 00:44:52 +01:00
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserDirectoryGateway;
use MintyPHP\Service\User\UserPasswordService;
use MintyPHP\Service\User\UserScopeGateway;
use MintyPHP\Service\User\UserSettingsGateway;
use PHPUnit\Framework\TestCase;
class UserAccountServiceTest extends TestCase
{
public function testListPagedDropsTenantUserIdForGlobalAccess(): void
{
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->expects($this->once())
->method('hasGlobalAccess')
->with(99)
->willReturn(true);
$listQueryRepository = $this->createMock(UserListQueryRepositoryInterface::class);
$listQueryRepository->expects($this->once())
->method('listPaged')
->with($this->callback(static function (array $options): bool {
return !isset($options['tenantUserId'])
&& (int) ($options['page'] ?? 0) === 2;
}))
->willReturn(['rows' => [], 'total' => 0]);
$service = $this->newService(null, null, $listQueryRepository, null, null, null, $scopeGateway);
$result = $service->listPaged(['page' => 2, 'tenantUserId' => 99]);
$this->assertSame(0, (int) ($result['total'] ?? -1));
}
public function testDeleteByUuidReturnsNotFoundForEmptyUuid(): void
{
$service = $this->newService();
$result = $service->deleteByUuid('');
$this->assertFalse($result['ok']);
$this->assertSame(404, (int) ($result['status'] ?? 0));
$this->assertSame('not_found', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidPreventsSelfDelete(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('user-uuid')
->willReturn(['id' => 10, 'uuid' => 'user-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('delete');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->deleteByUuid('user-uuid', 10);
$this->assertFalse($result['ok']);
$this->assertSame(400, (int) ($result['status'] ?? 0));
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidDeletesAndRecordsAuditEvent(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('user-uuid')
->willReturn(['id' => 11, 'uuid' => 'user-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('delete')
->with(11)
->willReturn(true);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.delete',
'success',
$this->callback(static function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 44
&& (int) ($context['target_id'] ?? 0) === 11
&& (string) ($context['target_uuid'] ?? '') === 'user-uuid';
})
);
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, null, null, $auditService);
$result = $service->deleteByUuid('user-uuid', 44);
$this->assertTrue($result['ok']);
$this->assertSame(11, (int) (($result['user'] ?? [])['id'] ?? 0));
}
public function testDeleteByUuidsReturnsPermissionDeniedWhenNothingIsInScope(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 51, 'uuid' => 'other-uuid']);
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('canAccess')->willReturn(false);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('deleteByUuids');
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway);
$result = $service->deleteByUuids(['other-uuid'], 99);
$this->assertFalse($result['ok']);
$this->assertSame('permission_denied', (string) ($result['error'] ?? ''));
}
public function testDeleteByUuidsReturnsSelfDeleteWhenOnlyOwnUuidRemains(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('self-uuid')
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
$readRepository->expects($this->once())
->method('find')
->with(99)
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('deleteByUuids');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->deleteByUuids(['self-uuid'], 99);
$this->assertFalse($result['ok']);
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
}
public function testSetActiveByUuidPreventsSelfDeactivate(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 42, 'uuid' => 'u-42']);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('setActive');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->setActiveByUuid('u-42', false, 42);
$this->assertFalse($result['ok']);
$this->assertSame(400, (int) ($result['status'] ?? 0));
$this->assertSame('self_deactivate', (string) ($result['error'] ?? ''));
}
public function testSetActiveByUuidUpdatesAssignmentsAndAudit(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 33, 'uuid' => 'u-33', 'active' => 0]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('setActive')
->with(33, true, 90)
->willReturn(true);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('bumpAuthzVersion')
->with(33);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.activate',
'success',
$this->callback(static fn ($context): bool => is_array($context))
);
$service = $this->newService($readRepository, $writeRepository, null, $assignmentService, null, null, null, null, $auditService);
$result = $service->setActiveByUuid('u-33', true, 90);
$this->assertTrue($result['ok']);
}
public function testSetActiveByUuidsBumpsAuthzVersionForResolvedUserIds(): void
{
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('setActiveByUuids')
->with(['u-1', 'u-2'], false, 7)
->willReturn(true);
$writeRepository->expects($this->once())
->method('bumpAuthzVersionByUserIds')
->with([11, 12])
->willReturn(2);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturnMap([
['u-1', ['id' => 11, 'uuid' => 'u-1']],
['u-2', ['id' => 12, 'uuid' => 'u-2']],
]);
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('canAccess')->willReturn(true);
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->once())
->method('record')
->with(
'admin.users.bulk_update',
'success',
$this->callback(static fn ($context): bool => is_array($context))
);
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway, null, $auditService);
$result = $service->setActiveByUuids(['u-1', 'u-2'], false, 7);
$this->assertTrue($result['ok']);
$this->assertSame(2, (int) ($result['count'] ?? 0));
}
public function testRegisterCreatesUserAndAssignsDefaultEntities(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getAppTheme')->willReturn('dark');
$settingsGateway->method('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('create')
->with($this->callback(static function (array $data): bool {
return (string) ($data['email'] ?? '') === 'user@example.com'
&& (string) ($data['theme'] ?? '') === 'dark'
&& (int) ($data['primary_tenant_id'] ?? 0) === 3
&& (int) ($data['active'] ?? 0) === 1;
}))
->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(true);
$assignmentService->expects($this->once())->method('syncDepartments')->with(77, [5])->willReturn(true);
2026-03-06 00:44:52 +01:00
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertTrue($result['ok']);
}
public function testCreateFromAdminRollsBackTransactionWhenTenantSyncFails(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
$settingsGateway->method('getAppTheme')->willReturn('light');
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())->method('create')->willReturn(88);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('create@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 88, 'uuid' => 'user-uuid-88']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('normalizeTenantIds')->with([3])->willReturn([3]);
$assignmentService->expects($this->once())->method('syncTenants')->with(88, [3])->willReturn(false);
$assignmentService->expects($this->never())->method('syncRoles');
$assignmentService->expects($this->never())->method('syncDepartments');
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->never())->method('record');
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())->method('beginTransaction');
$databaseSessionRepository->expects($this->never())->method('commitTransaction');
$databaseSessionRepository->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway,
null,
null,
$auditService,
$databaseSessionRepository
);
$result = $service->createFromAdmin([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'create@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
'tenant_ids' => [3],
'active' => 1,
], 42);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors'] ?? []);
}
public function testRegisterRollsBackTransactionWhenRoleSyncFails(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getAppTheme')->willReturn('dark');
$settingsGateway->method('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())->method('create')->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(false);
$assignmentService->expects($this->never())->method('syncDepartments');
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())->method('beginTransaction');
$databaseSessionRepository->expects($this->never())->method('commitTransaction');
$databaseSessionRepository->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway,
null,
null,
null,
$databaseSessionRepository
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertFalse($result['ok']);
$this->assertNotSame('', trim((string) ($result['error'] ?? '')));
}
2026-03-06 00:44:52 +01:00
public function testUpdateSelfProfileReturnsErrorWhenRequestedTenantIsNotAssigned(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(15)
->willReturn([
'id' => 15,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-2')
->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(15)
->willReturn([
'tenants' => [['id' => 1, 'uuid' => 'tenant-uuid-1']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('update');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(15, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-2',
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testUpdateSelfProfilePersistsDataAndTenantSwitchWhenAllowed(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(21)
->willReturn([
'id' => 21,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'profile_description' => '',
'job_title' => '',
'phone' => '',
'mobile' => '',
'short_dial' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-9')
->willReturn(['id' => 9, 'uuid' => 'tenant-uuid-9']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(21)
->willReturn([
'tenants' => [['id' => 9, 'uuid' => 'tenant-uuid-9']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('update')
->with(21, $this->callback(static fn ($payload): bool => is_array($payload)))
->willReturn(true);
$writeRepository->expects($this->once())
->method('setCurrentTenant')
->with(21, 9)
->willReturn(true);
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(21, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-9',
'locale' => 'de',
'theme' => 'light',
]);
$this->assertTrue($result['ok']);
}
private function newService(
?UserReadRepositoryInterface $readRepository = null,
?UserWriteRepositoryInterface $writeRepository = null,
?UserListQueryRepositoryInterface $listQueryRepository = null,
?UserAssignmentService $assignmentService = null,
?UserPasswordService $passwordService = null,
?UserSettingsGateway $settingsGateway = null,
?UserScopeGateway $scopeGateway = null,
?UserDirectoryGateway $directoryGateway = null,
?SystemAuditService $auditService = null,
?DatabaseSessionRepository $databaseSessionRepository = null
2026-03-06 00:44:52 +01:00
): UserAccountService {
$readRepository = $readRepository ?? $this->createMock(UserReadRepositoryInterface::class);
$writeRepository = $writeRepository ?? $this->createMock(UserWriteRepositoryInterface::class);
$listQueryRepository = $listQueryRepository ?? $this->createMock(UserListQueryRepositoryInterface::class);
if ($assignmentService === null) {
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->method('normalizeTenantIds')->willReturn([]);
$assignmentService->method('normalizeIdInput')->willReturn([]);
$assignmentService->method('buildAssignmentsForUser')->willReturn([
'tenants' => [],
'roles' => [],
'departments' => [],
]);
$assignmentService->method('syncTenants')->willReturn(true);
$assignmentService->method('syncRoles')->willReturn(true);
$assignmentService->method('syncDepartments')->willReturn(true);
}
if ($passwordService === null) {
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
}
if ($settingsGateway === null) {
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
$settingsGateway->method('getAppTheme')->willReturn('light');
}
if ($scopeGateway === null) {
$scopeGateway = $this->createMock(UserScopeGateway::class);
$scopeGateway->method('hasGlobalAccess')->willReturn(false);
$scopeGateway->method('canAccess')->willReturn(false);
}
if ($directoryGateway === null) {
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->method('findTenantByUuid')->willReturn(null);
}
$auditService = $auditService ?? $this->createMock(SystemAuditService::class);
$databaseSessionRepository = $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class);
2026-03-06 00:44:52 +01:00
return new UserAccountService(
$readRepository,
$writeRepository,
$listQueryRepository,
$assignmentService,
$passwordService,
$settingsGateway,
$scopeGateway,
$directoryGateway,
$auditService,
$databaseSessionRepository
2026-03-06 00:44:52 +01:00
);
}
}