test(services): add 88 unit tests for 7 critical service classes

RateLimiterServiceTest (17): hit/isBlocked/reset/registerFailure, fail-open,
sliding window, scope normalization.
PermissionServiceTest (24): userHas, getUserPermissions, cache hit/miss/refresh,
clearUserCache, CRUD, system permission protection.
RoleServiceTest (9): createFromAdmin, updateFromAdmin, deleteByUuid,
Admin role protection, duplicate code rejection.
TenantServiceTest (8): CRUD, department-dependent deletion blocking.
DepartmentServiceTest (14): listPaged scope filtering, groupActiveByTenantIds,
createFromAdmin, deleteByUuid, syncTenants.
MailServiceTest (8): send logging, MailerException handling, template metadata.
UserLifecycleServiceTest (8): advisory locking, deactivation, deletion,
snapshot failure skip, privileged user exclusion, manual trigger type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 13:57:54 +01:00
parent f6777113ec
commit d4978d1504
15 changed files with 2152 additions and 1 deletions

View File

@@ -0,0 +1,324 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
use MintyPHP\Repository\Access\RolePermissionRepositoryInterface;
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\SystemAuditService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class PermissionServiceTest extends TestCase
{
private PermissionRepositoryInterface&MockObject $permissionRepository;
private RolePermissionRepositoryInterface&MockObject $rolePermissionRepository;
private UserRoleRepositoryInterface&MockObject $userRoleRepository;
private SystemAuditService&MockObject $systemAuditService;
private SessionStoreInterface&MockObject $sessionStore;
private PermissionService $service;
protected function setUp(): void
{
$this->permissionRepository = $this->createMock(PermissionRepositoryInterface::class);
$this->rolePermissionRepository = $this->createMock(RolePermissionRepositoryInterface::class);
$this->userRoleRepository = $this->createMock(UserRoleRepositoryInterface::class);
$this->systemAuditService = $this->createMock(SystemAuditService::class);
$this->sessionStore = $this->createMock(SessionStoreInterface::class);
$this->service = new PermissionService(
$this->permissionRepository,
$this->rolePermissionRepository,
$this->userRoleRepository,
$this->systemAuditService,
$this->sessionStore,
);
}
// ── userHas ──────────────────────────────────────────────────────
public function testUserHasReturnsFalseForZeroUserId(): void
{
$this->assertFalse($this->service->userHas(0, 'users.view'));
}
public function testUserHasReturnsFalseForEmptyKey(): void
{
$this->assertFalse($this->service->userHas(1, ''));
$this->assertFalse($this->service->userHas(1, ' '));
}
public function testUserHasReturnsTrueWhenPermissionExists(): void
{
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn(null);
$this->userRoleRepository->expects($this->any())->method('listRoleIdsByUserId')
->with(5)
->willReturn([1, 2]);
$this->rolePermissionRepository->expects($this->any())->method('listPermissionKeysByRoleIds')
->with([1, 2])
->willReturn(['users.view', 'users.create']);
$this->sessionStore->expects($this->atLeastOnce())
->method('set')
->with('permissions', $this->callback(fn (array $v) => $v['user_id'] === 5 && $v['keys'] === ['users.view', 'users.create']));
$this->assertTrue($this->service->userHas(5, 'users.view'));
}
// ── getUserPermissions ───────────────────────────────────────────
public function testGetUserPermissionsReturnsEmptyForNonPositiveUserId(): void
{
$this->assertSame([], $this->service->getUserPermissions(0));
$this->assertSame([], $this->service->getUserPermissions(-1));
}
public function testGetUserPermissionsUsesSessionCache(): void
{
$cached = ['user_id' => 7, 'keys' => ['settings.view']];
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn($cached);
// Repositories should NOT be called when cache is present.
$this->userRoleRepository->expects($this->never())->method('listRoleIdsByUserId');
$this->rolePermissionRepository->expects($this->never())->method('listPermissionKeysByRoleIds');
$result = $this->service->getUserPermissions(7);
$this->assertSame(['settings.view'], $result);
}
public function testGetUserPermissionsRefreshBypassesCache(): void
{
$cached = ['user_id' => 7, 'keys' => ['old.key']];
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn($cached);
$this->userRoleRepository->expects($this->any())->method('listRoleIdsByUserId')
->with(7)
->willReturn([3]);
$this->rolePermissionRepository->expects($this->any())->method('listPermissionKeysByRoleIds')
->with([3])
->willReturn(['new.key']);
$this->sessionStore->expects($this->once())
->method('set')
->with('permissions', $this->callback(fn (array $v) => $v['keys'] === ['new.key']));
$result = $this->service->getUserPermissions(7, true);
$this->assertSame(['new.key'], $result);
}
// ── getCachedPermissions ─────────────────────────────────────────
public function testGetCachedPermissionsReturnsEmptyWhenNoCache(): void
{
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn(null);
$this->assertSame([], $this->service->getCachedPermissions(1));
}
public function testGetCachedPermissionsReturnsCachedKeysForMatchingUser(): void
{
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn(['user_id' => 4, 'keys' => ['a', 'b']]);
$this->assertSame(['a', 'b'], $this->service->getCachedPermissions(4));
}
// ── clearUserCache ───────────────────────────────────────────────
public function testClearUserCacheRemovesSessionEntry(): void
{
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn(['user_id' => 9, 'keys' => ['x']]);
$this->sessionStore->expects($this->once())
->method('remove')
->with('permissions');
$this->service->clearUserCache(9);
}
public function testClearUserCacheIgnoresMismatchedUserId(): void
{
$this->sessionStore->expects($this->any())->method('get')
->with('permissions')
->willReturn(['user_id' => 9, 'keys' => ['x']]);
$this->sessionStore->expects($this->never())->method('remove');
$this->service->clearUserCache(42);
}
// ── Delegation methods ───────────────────────────────────────────
public function testListDelegatesToRepository(): void
{
$expected = [['id' => 1, 'key' => 'a']];
$this->permissionRepository->expects($this->any())->method('list')->willReturn($expected);
$this->assertSame($expected, $this->service->list());
}
public function testListActiveDelegatesToRepository(): void
{
$expected = [['id' => 2, 'key' => 'b', 'active' => 1]];
$this->permissionRepository->expects($this->any())->method('listActive')->willReturn($expected);
$this->assertSame($expected, $this->service->listActive());
}
public function testListPagedDelegatesToRepository(): void
{
$options = ['page' => 1, 'limit' => 10];
$expected = ['rows' => [], 'total' => 0];
$this->permissionRepository->expects($this->any())->method('listPaged')->with($options)->willReturn($expected);
$this->assertSame($expected, $this->service->listPaged($options));
}
public function testFindDelegatesToRepository(): void
{
$this->permissionRepository->expects($this->any())->method('find')->with(5)->willReturn(['id' => 5, 'key' => 'x']);
$this->assertSame(['id' => 5, 'key' => 'x'], $this->service->find(5));
}
public function testFindByKeyDelegatesToRepository(): void
{
$this->permissionRepository->expects($this->any())->method('findByKey')->with('users.view')->willReturn(['id' => 1, 'key' => 'users.view']);
$this->assertSame(['id' => 1, 'key' => 'users.view'], $this->service->findByKey('users.view'));
}
// ── createFromAdmin ──────────────────────────────────────────────
public function testCreateFromAdminSucceeds(): void
{
$input = ['key' => 'custom.perm', 'description' => 'A custom permission', 'active' => '1', 'is_system' => '0'];
$this->permissionRepository->expects($this->any())->method('findByKey')->with('custom.perm')->willReturn(null);
$this->permissionRepository->expects($this->any())->method('create')->willReturn(42);
$this->systemAuditService->expects($this->once())->method('record');
$result = $this->service->createFromAdmin($input);
$this->assertTrue($result['ok']);
$this->assertSame(42, $result['id']);
$this->assertSame('custom.perm', $result['form']['key']);
}
public function testCreateFromAdminRejectsEmptyKey(): void
{
$result = $this->service->createFromAdmin(['key' => '', 'description' => '']);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testCreateFromAdminRejectsInvalidKeyFormat(): void
{
$result = $this->service->createFromAdmin(['key' => 'invalid key!', 'description' => '']);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testCreateFromAdminRejectsDuplicateKey(): void
{
$this->permissionRepository->expects($this->any())->method('findByKey')
->with('users.view')
->willReturn(['id' => 1, 'key' => 'users.view']);
$result = $this->service->createFromAdmin(['key' => 'users.view', 'description' => '']);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
// ── updateFromAdmin ──────────────────────────────────────────────
public function testUpdateFromAdminSucceeds(): void
{
$input = ['key' => 'custom.perm', 'description' => 'Updated', 'active' => '1', 'is_system' => '0'];
// findByKey returns the same id → no uniqueness conflict
$this->permissionRepository->expects($this->any())->method('findByKey')
->with('custom.perm')
->willReturn(['id' => 10, 'key' => 'custom.perm']);
$this->permissionRepository->expects($this->any())->method('find')
->with(10)
->willReturn(['id' => 10, 'key' => 'custom.perm', 'active' => 0]);
$this->permissionRepository->expects($this->any())->method('update')->with(10, $this->isType('array'))->willReturn(true);
$this->systemAuditService->expects($this->once())->method('record');
$result = $this->service->updateFromAdmin(10, $input);
$this->assertTrue($result['ok']);
}
public function testUpdateFromAdminRejectsDuplicateKeyFromAnotherPermission(): void
{
$this->permissionRepository->expects($this->any())->method('findByKey')
->with('taken.key')
->willReturn(['id' => 99, 'key' => 'taken.key']);
$result = $this->service->updateFromAdmin(10, ['key' => 'taken.key', 'description' => '']);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
// ── deleteById ───────────────────────────────────────────────────
public function testDeleteByIdSucceeds(): void
{
$permission = ['id' => 5, 'key' => 'custom.perm', 'is_system' => 0];
$this->permissionRepository->expects($this->any())->method('find')->with(5)->willReturn($permission);
$this->permissionRepository->expects($this->any())->method('delete')->with(5)->willReturn(true);
$this->systemAuditService->expects($this->once())->method('record');
$result = $this->service->deleteById(5);
$this->assertTrue($result['ok']);
$this->assertSame($permission, $result['permission']);
}
public function testDeleteByIdProtectsSystemPermission(): void
{
$permission = ['id' => 1, 'key' => 'users.view', 'is_system' => 1];
$this->permissionRepository->expects($this->any())->method('find')->with(1)->willReturn($permission);
$result = $this->service->deleteById(1);
$this->assertFalse($result['ok']);
$this->assertSame(403, $result['status']);
$this->assertSame('system_permission_protected', $result['error']);
}
public function testDeleteByIdReturnsNotFoundForMissing(): void
{
$this->permissionRepository->expects($this->any())->method('find')->with(999)->willReturn(null);
$result = $this->service->deleteById(999);
$this->assertFalse($result['ok']);
$this->assertSame(404, $result['status']);
$this->assertSame('not_found', $result['error']);
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace MintyPHP\Tests\Service\Access;
use MintyPHP\Repository\Access\RoleRepositoryInterface;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class RoleServiceTest extends TestCase
{
private RoleRepositoryInterface&MockObject $roleRepo;
private DirectorySettingsGateway&MockObject $settingsGateway;
private SystemAuditService&MockObject $auditService;
private RoleService $service;
protected function setUp(): void
{
$this->roleRepo = $this->createMock(RoleRepositoryInterface::class);
$this->settingsGateway = $this->createMock(DirectorySettingsGateway::class);
$this->auditService = $this->createMock(SystemAuditService::class);
$this->service = new RoleService(
$this->roleRepo,
$this->settingsGateway,
$this->auditService
);
}
public function testCreateFromAdminSucceeds(): void
{
$this->roleRepo
->expects($this->once())
->method('existsByCode')
->with('EDITOR', 0)
->willReturn(false);
$this->roleRepo
->expects($this->once())
->method('create')
->willReturn(42);
$this->roleRepo
->expects($this->once())
->method('find')
->with(42)
->willReturn(['id' => 42, 'uuid' => 'uuid-42', 'description' => 'Editor']);
$this->settingsGateway
->expects($this->never())
->method('setDefaultRoleId');
$this->auditService
->expects($this->once())
->method('record')
->with('admin.roles.create', 'success', $this->anything());
$result = $this->service->createFromAdmin([
'description' => 'Editor',
'code' => 'EDITOR',
'active' => 1,
], 5);
$this->assertTrue($result['ok']);
$this->assertSame('uuid-42', $result['uuid']);
$this->assertSame(42, $result['id']);
}
public function testCreateFromAdminRejectsEmptyDescription(): void
{
$this->roleRepo
->expects($this->never())
->method('create');
$result = $this->service->createFromAdmin([
'description' => ' ',
'code' => '',
'active' => 1,
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testCreateFromAdminSetsDefaultRoleWhenRequested(): void
{
$this->roleRepo
->method('existsByCode')
->willReturn(false);
$this->roleRepo
->method('create')
->willReturn(99);
$this->roleRepo
->expects($this->any())
->method('find')
->with(99)
->willReturn(['id' => 99, 'uuid' => 'uuid-99', 'description' => 'Default']);
$this->settingsGateway
->expects($this->once())
->method('setDefaultRoleId')
->with(99);
$this->auditService
->expects($this->once())
->method('record');
$result = $this->service->createFromAdmin([
'description' => 'Default',
'code' => '',
'active' => 1,
'is_default' => 1,
], 3);
$this->assertTrue($result['ok']);
}
public function testUpdateFromAdminSucceeds(): void
{
$this->roleRepo
->expects($this->once())
->method('existsByCode')
->with('MGR', 10)
->willReturn(false);
$this->roleRepo
->expects($this->once())
->method('find')
->with(10)
->willReturn(['id' => 10, 'uuid' => 'uuid-10', 'active' => 1]);
$this->roleRepo
->expects($this->once())
->method('update')
->with(10, $this->anything())
->willReturn(true);
$this->auditService
->expects($this->once())
->method('record')
->with('admin.roles.update', 'success', $this->anything());
$result = $this->service->updateFromAdmin(10, [
'description' => 'Manager',
'code' => 'MGR',
'active' => 1,
], 5);
$this->assertTrue($result['ok']);
}
public function testUpdateFromAdminRejectsDuplicateCode(): void
{
$this->roleRepo
->expects($this->once())
->method('existsByCode')
->with('ADMIN', 10)
->willReturn(true);
$this->roleRepo
->expects($this->never())
->method('update');
$result = $this->service->updateFromAdmin(10, [
'description' => 'Manager',
'code' => 'ADMIN',
'active' => 1,
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testDeleteByUuidSucceeds(): void
{
$role = ['id' => 7, 'uuid' => 'uuid-7', 'description' => 'Guest'];
$this->roleRepo
->expects($this->once())
->method('findByUuid')
->with('uuid-7')
->willReturn($role);
$this->roleRepo
->expects($this->once())
->method('delete')
->with(7)
->willReturn(true);
$this->auditService
->expects($this->once())
->method('record')
->with('admin.roles.delete', 'success', $this->anything());
$result = $this->service->deleteByUuid('uuid-7');
$this->assertTrue($result['ok']);
$this->assertSame($role, $result['role']);
}
public function testDeleteByUuidProtectsAdminRole(): void
{
$this->roleRepo
->expects($this->once())
->method('findByUuid')
->with('uuid-admin')
->willReturn(['id' => 1, 'uuid' => 'uuid-admin', 'description' => 'Admin']);
$this->roleRepo
->expects($this->never())
->method('delete');
$result = $this->service->deleteByUuid('uuid-admin');
$this->assertFalse($result['ok']);
$this->assertSame(403, $result['status']);
$this->assertSame('admin_role_protected', $result['error']);
}
public function testDeleteByUuidReturnsNotFoundForEmptyUuid(): void
{
$this->roleRepo
->expects($this->never())
->method('findByUuid');
$result = $this->service->deleteByUuid(' ');
$this->assertFalse($result['ok']);
$this->assertSame(404, $result['status']);
$this->assertSame('not_found', $result['error']);
}
public function testDeleteByUuidReturnsDeleteFailedOnRepoFailure(): void
{
$this->roleRepo
->expects($this->once())
->method('findByUuid')
->with('uuid-8')
->willReturn(['id' => 8, 'uuid' => 'uuid-8', 'description' => 'Viewer']);
$this->roleRepo
->expects($this->once())
->method('delete')
->with(8)
->willReturn(false);
$this->auditService
->expects($this->never())
->method('record');
$result = $this->service->deleteByUuid('uuid-8');
$this->assertFalse($result['ok']);
$this->assertSame(500, $result['status']);
$this->assertSame('delete_failed', $result['error']);
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace MintyPHP\Tests\Service\Mail;
use MintyPHP\Repository\Mail\MailLogRepositoryInterface;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\Service\Settings\SettingsSmtpGateway;
use PHPUnit\Framework\TestCase;
class MailServiceTest extends TestCase
{
private function createSmtpGatewayMock(): SettingsSmtpGateway
{
$gateway = $this->createMock(SettingsSmtpGateway::class);
$gateway->method('getSmtpHost')->willReturn('localhost');
$gateway->method('getSmtpPort')->willReturn(587);
$gateway->method('getSmtpUser')->willReturn('');
$gateway->method('getSmtpPassword')->willReturn('');
$gateway->method('getSmtpSecure')->willReturn('tls');
$gateway->method('getSmtpFrom')->willReturn('test@example.com');
$gateway->method('getSmtpFromName')->willReturn('Test');
return $gateway;
}
private function newService(
?MailLogRepositoryInterface $mailLogRepository = null,
?SettingsSmtpGateway $settingsSmtpGateway = null,
): MailService {
return new MailService(
$mailLogRepository ?? $this->createMock(MailLogRepositoryInterface::class),
$settingsSmtpGateway ?? $this->createSmtpGatewayMock(),
);
}
public function testSendLogsBeforeSending(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
return $data['to_email'] === 'user@example.com'
&& $data['subject'] === 'Hello'
&& $data['status'] === 'queued';
}))
->willReturn(1);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$service->send('user@example.com', 'Hello', '<p>Hi</p>', 'Hi');
}
public function testSendHandlesMailerException(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->method('create')->willReturn(1);
$mailLogRepository->expects($this->once())->method('markFailed');
$mailLogRepository->expects($this->never())->method('markSent');
$service = $this->newService(mailLogRepository: $mailLogRepository);
$result = $service->send('user@example.com', 'Hello', '<p>Hi</p>', 'Hi');
$this->assertFalse($result['ok']);
$this->assertSame('send_failed', $result['error']);
}
public function testSendHandlesNullLogId(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->method('create')->willReturn(null);
$mailLogRepository->expects($this->never())->method('markFailed');
$mailLogRepository->expects($this->never())->method('markSent');
$service = $this->newService(mailLogRepository: $mailLogRepository);
$result = $service->send('user@example.com', 'Hello', '<p>Hi</p>', 'Hi');
$this->assertFalse($result['ok']);
$this->assertSame('send_failed', $result['error']);
}
public function testSendWithEmptyLogIdStillReturnsError(): void
{
// create() returns null (e.g. DB insert failed) — error path still returns a result.
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->method('create')->willReturn(null);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$result = $service->send('user@example.com', 'Subject', '<p>Body</p>', 'Body');
$this->assertArrayHasKey('ok', $result);
$this->assertArrayHasKey('error', $result);
$this->assertFalse($result['ok']);
}
public function testSendTemplateCallsSend(): void
{
// Templates won't exist in test context, so html/text will be empty strings.
// But the send path is still exercised, creating a log entry.
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
return $data['to_email'] === 'user@example.com'
&& $data['template'] === 'welcome';
}))
->willReturn(2);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$result = $service->sendTemplate('welcome', ['name' => 'Alice'], 'user@example.com', 'Welcome');
$this->assertArrayHasKey('ok', $result);
}
public function testSendCallsMarkFailedWithErrorMessage(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->method('create')->willReturn(5);
$mailLogRepository->expects($this->once())
->method('markFailed')
->with(
$this->identicalTo(5),
$this->isType('string'),
);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$service->send('user@example.com', 'Test', '<p>Body</p>', 'Body');
}
public function testSendPassesMetaTemplateToLogEntry(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
return $data['template'] === 'invoice';
}))
->willReturn(3);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$service->send('user@example.com', 'Invoice', '<p>Invoice</p>', 'Invoice', [
'template' => 'invoice',
]);
}
public function testSendWithoutMetaTemplateLogsNullTemplate(): void
{
$mailLogRepository = $this->createMock(MailLogRepositoryInterface::class);
$mailLogRepository->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
return $data['template'] === null;
}))
->willReturn(4);
$service = $this->newService(mailLogRepository: $mailLogRepository);
$service->send('user@example.com', 'Ad hoc', '<p>Ad hoc</p>', 'Ad hoc');
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace MintyPHP\Tests\Service\Org;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserServicesFactory;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class DepartmentServiceTest extends TestCase
{
private UserServicesFactory&MockObject $userServicesFactory;
private DepartmentRepositoryInterface&MockObject $departmentRepository;
private DirectorySettingsGateway&MockObject $settingsGateway;
private TenantScopeService&MockObject $scopeGateway;
private SystemAuditService&MockObject $systemAuditService;
private DepartmentService $service;
protected function setUp(): void
{
$this->userServicesFactory = $this->createMock(UserServicesFactory::class);
$this->departmentRepository = $this->createMock(DepartmentRepositoryInterface::class);
$this->settingsGateway = $this->createMock(DirectorySettingsGateway::class);
$this->scopeGateway = $this->createMock(TenantScopeService::class);
$this->systemAuditService = $this->createMock(SystemAuditService::class);
$this->service = new DepartmentService(
$this->userServicesFactory,
$this->departmentRepository,
$this->settingsGateway,
$this->scopeGateway,
$this->systemAuditService
);
}
public function testListPagedRemovesTenantFilterForGlobalAccessUser(): void
{
$this->scopeGateway
->expects($this->once())
->method('hasGlobalAccess')
->with(5)
->willReturn(true);
$this->departmentRepository
->expects($this->once())
->method('listPaged')
->with($this->callback(function (array $options): bool {
return !isset($options['tenantUserId']) && !isset($options['tenantIds']);
}))
->willReturn([['id' => 1]]);
$result = $this->service->listPaged([
'tenantUserId' => 5,
'tenantIds' => [1, 2],
'page' => 1,
]);
$this->assertSame([['id' => 1]], $result);
}
public function testListPagedKeepsTenantFilterForNonGlobalUser(): void
{
$this->scopeGateway
->expects($this->once())
->method('hasGlobalAccess')
->with(5)
->willReturn(false);
$this->departmentRepository
->expects($this->once())
->method('listPaged')
->with($this->callback(function (array $options): bool {
return isset($options['tenantUserId']) && $options['tenantUserId'] === 5;
}))
->willReturn([]);
$this->service->listPaged(['tenantUserId' => 5, 'tenantIds' => [1]]);
}
public function testGroupActiveByTenantIdsGroupsAndSorts(): void
{
$this->departmentRepository
->expects($this->once())
->method('listByTenantIds')
->with([1, 2])
->willReturn([
['id' => 10, 'tenant_id' => 1, 'description' => 'Zebra'],
['id' => 11, 'tenant_id' => 1, 'description' => 'Alpha'],
['id' => 20, 'tenant_id' => 2, 'description' => 'Beta'],
]);
$result = $this->service->groupActiveByTenantIds([1, 2]);
$this->assertCount(2, $result);
$this->assertArrayHasKey(1, $result);
$this->assertArrayHasKey(2, $result);
// Tenant 1 departments sorted by description: Alpha before Zebra
$this->assertSame('Alpha', $result[1][0]['description']);
$this->assertSame('Zebra', $result[1][1]['description']);
// Tenant 2 has one department
$this->assertCount(1, $result[2]);
$this->assertSame('Beta', $result[2][0]['description']);
}
public function testGroupActiveByTenantIdsReturnsEmptyForInvalidIds(): void
{
$this->departmentRepository
->expects($this->never())
->method('listByTenantIds');
$result = $this->service->groupActiveByTenantIds([0, -1, -5]);
$this->assertSame([], $result);
}
public function testGroupActiveByTenantIdsDeduplicatesIds(): void
{
$this->departmentRepository
->expects($this->once())
->method('listByTenantIds')
->with([1])
->willReturn([]);
$this->service->groupActiveByTenantIds([1, 1, 1]);
}
public function testCreateFromAdminSucceeds(): void
{
$input = [
'description' => 'Sales',
'tenant_id' => 1,
'code' => '',
'cost_center' => '',
'active' => 1,
];
$this->departmentRepository
->method('existsByCode')
->willReturn(false);
$this->departmentRepository
->expects($this->once())
->method('create')
->willReturn(42);
$this->departmentRepository
->expects($this->once())
->method('find')
->with(42)
->willReturn(['id' => 42, 'uuid' => 'dept-uuid-123', 'description' => 'Sales']);
$this->systemAuditService
->expects($this->once())
->method('record')
->with('admin.departments.create', 'success', $this->anything());
$result = $this->service->createFromAdmin($input, 7);
$this->assertTrue($result['ok']);
$this->assertSame(42, $result['id']);
$this->assertSame('dept-uuid-123', $result['uuid']);
}
public function testCreateFromAdminSetsDefaultDepartmentWhenRequested(): void
{
$input = [
'description' => 'Default Dept',
'tenant_id' => 1,
'code' => '',
'cost_center' => '',
'active' => 1,
'is_default' => true,
];
$this->departmentRepository->method('existsByCode')->willReturn(false);
$this->departmentRepository->method('create')->willReturn(10);
$this->departmentRepository->method('find')->willReturn(['id' => 10, 'uuid' => 'u1']);
$this->settingsGateway
->expects($this->once())
->method('setDefaultDepartmentId')
->with(10);
$result = $this->service->createFromAdmin($input, 1);
$this->assertTrue($result['ok']);
}
public function testCreateFromAdminRejectsEmptyDescription(): void
{
$input = [
'description' => '',
'tenant_id' => 1,
'code' => '',
'cost_center' => '',
'active' => 1,
];
$this->departmentRepository
->expects($this->never())
->method('create');
$result = $this->service->createFromAdmin($input);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testCreateFromAdminRejectsMissingTenantId(): void
{
$input = [
'description' => 'Sales',
'tenant_id' => 0,
'code' => '',
'cost_center' => '',
'active' => 1,
];
$this->departmentRepository
->expects($this->never())
->method('create');
$result = $this->service->createFromAdmin($input);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testDeleteByUuidSucceeds(): void
{
$department = ['id' => 5, 'uuid' => 'abc-123', 'description' => 'HR'];
$this->departmentRepository
->expects($this->once())
->method('findByUuid')
->with('abc-123')
->willReturn($department);
$this->departmentRepository
->expects($this->once())
->method('delete')
->with(5)
->willReturn(true);
$this->systemAuditService
->expects($this->once())
->method('record')
->with('admin.departments.delete', 'success', $this->anything());
$result = $this->service->deleteByUuid('abc-123');
$this->assertTrue($result['ok']);
$this->assertSame($department, $result['department']);
}
public function testDeleteByUuidReturnsNotFoundForMissing(): void
{
$this->departmentRepository
->expects($this->once())
->method('findByUuid')
->with('nonexistent')
->willReturn(null);
$this->departmentRepository
->expects($this->never())
->method('delete');
$result = $this->service->deleteByUuid('nonexistent');
$this->assertFalse($result['ok']);
$this->assertSame(404, $result['status']);
$this->assertSame('not_found', $result['error']);
}
public function testDeleteByUuidReturnsNotFoundForEmptyUuid(): void
{
$this->departmentRepository
->expects($this->never())
->method('findByUuid');
$result = $this->service->deleteByUuid(' ');
$this->assertFalse($result['ok']);
$this->assertSame(404, $result['status']);
}
public function testSyncTenantsUsesFirstId(): void
{
$this->departmentRepository
->expects($this->once())
->method('setTenant')
->with(10, 3)
->willReturn(true);
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
$userDeptRepo->expects($this->any())->method('removeInvalidForDepartment')->with(10)->willReturn(2);
$this->userServicesFactory
->expects($this->once())
->method('createUserDepartmentRepository')
->willReturn($userDeptRepo);
$result = $this->service->syncTenants(10, [3, 5, 7]);
$this->assertSame(2, $result);
}
public function testSyncTenantsRejectsEmptyArray(): void
{
$this->departmentRepository
->expects($this->never())
->method('setTenant');
$result = $this->service->syncTenants(10, []);
$this->assertSame(-1, $result);
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace MintyPHP\Tests\Service\Security;
use MintyPHP\Repository\Security\RateLimitRepositoryInterface;
use MintyPHP\Service\Security\RateLimiterService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class RateLimiterServiceTest extends TestCase
{
private RateLimitRepositoryInterface&MockObject $repository;
private RateLimiterService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(RateLimitRepositoryInterface::class);
$this->service = new RateLimiterService($this->repository);
}
// ---------------------------------------------------------------
// hit — fail-open on empty input
// ---------------------------------------------------------------
public function testHitWithEmptyScopeFailsOpen(): void
{
$this->repository->expects($this->never())->method('findByScopeAndHash');
$result = $this->service->hit('', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testHitWithEmptySubjectFailsOpen(): void
{
$this->repository->expects($this->never())->method('findByScopeAndHash');
$result = $this->service->hit('login', '', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — new entry creation
// ---------------------------------------------------------------
public function testHitCreatesNewEntryOnFirstAttempt(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$this->repository->expects($this->once())
->method('create')
->with(
'login',
hash('sha256', 'user@example.com'),
1,
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — increment existing entry
// ---------------------------------------------------------------
public function testHitIncrementsExistingEntry(): void
{
$nowSql = gmdate('Y-m-d H:i:s');
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 2,
'window_started_at' => $nowSql,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
3, // 2 + 1
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — blocks when over limit
// ---------------------------------------------------------------
public function testHitBlocksWhenOverLimit(): void
{
$nowSql = gmdate('Y-m-d H:i:s');
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 5, // at maxHits; next increment pushes to 6 > 5
'window_started_at' => $nowSql,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
6,
$this->anything(),
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/')
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — returns blocked when already blocked
// ---------------------------------------------------------------
public function testHitReturnsBlockedWhenAlreadyBlocked(): void
{
$blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $blockedUntil,
]);
// Should not update — early return when already blocked.
$this->repository->expects($this->never())->method('updateStateById');
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — sliding window resets expired window
// ---------------------------------------------------------------
public function testHitResetsHitsWhenWindowExpired(): void
{
$expiredWindowStart = gmdate('Y-m-d H:i:s', time() - 7200);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 4,
'window_started_at' => $expiredWindowStart,
'blocked_until' => null,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
1, // reset to 0 then incremented to 1
$this->anything(),
null
)
->willReturn(true);
$result = $this->service->hit('login', 'user@example.com', 5, 3600, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — fail-open on storage exception
// ---------------------------------------------------------------
public function testHitFailsOpenOnStorageException(): void
{
$this->repository->method('findByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
$result = $this->service->hit('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// hit — scope normalization
// ---------------------------------------------------------------
public function testHitNormalizesScopeToLowercaseTrimmed(): void
{
$this->repository->expects($this->once())
->method('findByScopeAndHash')
->with('login', $this->anything())
->willReturn(null);
$this->repository->method('create')->willReturn(true);
$this->service->hit(' LOGIN ', 'user@example.com', 5, 60, 300);
}
// ---------------------------------------------------------------
// isBlocked — not blocked
// ---------------------------------------------------------------
public function testIsBlockedReturnsTrueWhenBlocked(): void
{
$blockedUntil = gmdate('Y-m-d H:i:s', time() + 600);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $blockedUntil,
]);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertFalse($result['allowed']);
$this->assertGreaterThan(0, $result['retry_after']);
}
public function testIsBlockedClearsExpiredBlock(): void
{
$expiredBlock = gmdate('Y-m-d H:i:s', time() - 60);
$this->repository->method('findByScopeAndHash')->willReturn([
'id' => 42,
'hits' => 10,
'window_started_at' => gmdate('Y-m-d H:i:s'),
'blocked_until' => $expiredBlock,
]);
$this->repository->expects($this->once())
->method('updateStateById')
->with(
42,
0,
$this->matchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'),
null
)
->willReturn(true);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testIsBlockedReturnsAllowedWhenNoRowExists(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
public function testIsBlockedFailsOpenOnException(): void
{
$this->repository->method('findByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
$result = $this->service->isBlocked('login', 'user@example.com');
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
// ---------------------------------------------------------------
// reset
// ---------------------------------------------------------------
public function testResetDeletesEntry(): void
{
$this->repository->expects($this->once())
->method('deleteByScopeAndHash')
->with('login', hash('sha256', 'user@example.com'))
->willReturn(true);
$this->service->reset('login', 'user@example.com');
}
public function testResetIgnoresException(): void
{
$this->repository->method('deleteByScopeAndHash')
->willThrowException(new \RuntimeException('DB down'));
// Should not throw — exceptions are silently ignored.
$this->service->reset('login', 'user@example.com');
$this->assertTrue(true);
}
public function testResetWithEmptyScopeDoesNothing(): void
{
$this->repository->expects($this->never())->method('deleteByScopeAndHash');
$this->service->reset('', 'user@example.com');
}
// ---------------------------------------------------------------
// registerFailure — delegates to apply() same as hit()
// ---------------------------------------------------------------
public function testRegisterFailureBehavesLikeHit(): void
{
$this->repository->method('findByScopeAndHash')->willReturn(null);
$this->repository->method('create')->willReturn(true);
$result = $this->service->registerFailure('login', 'user@example.com', 5, 60, 300);
$this->assertTrue($result['allowed']);
$this->assertSame(0, $result['retry_after']);
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace MintyPHP\Tests\Service\Tenant;
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Directory\DirectorySettingsGateway;
use MintyPHP\Service\Tenant\TenantService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class TenantServiceTest extends TestCase
{
private TenantRepositoryInterface&MockObject $tenantRepository;
private DepartmentRepositoryInterface&MockObject $departmentRepository;
private DirectorySettingsGateway&MockObject $settingsGateway;
private SystemAuditService&MockObject $systemAuditService;
private TenantService $service;
protected function setUp(): void
{
$this->tenantRepository = $this->createMock(TenantRepositoryInterface::class);
$this->departmentRepository = $this->createMock(DepartmentRepositoryInterface::class);
$this->settingsGateway = $this->createMock(DirectorySettingsGateway::class);
$this->systemAuditService = $this->createMock(SystemAuditService::class);
$this->service = new TenantService(
$this->tenantRepository,
$this->departmentRepository,
$this->settingsGateway,
$this->systemAuditService
);
}
public function testListDelegatesToRepository(): void
{
$expected = [
['id' => 1, 'description' => 'Tenant A'],
['id' => 2, 'description' => 'Tenant B'],
];
$this->tenantRepository->expects($this->once())
->method('list')
->willReturn($expected);
$result = $this->service->list();
self::assertSame($expected, $result);
}
public function testCreateFromAdminSucceeds(): void
{
$input = $this->validInput();
$this->tenantRepository->expects($this->once())
->method('create')
->willReturn(42);
$this->tenantRepository->expects($this->once())
->method('find')
->with(42)
->willReturn(['id' => 42, 'uuid' => 'abc-uuid-123']);
$this->systemAuditService->expects($this->once())
->method('record');
$result = $this->service->createFromAdmin($input, 5);
self::assertTrue($result['ok']);
self::assertSame('abc-uuid-123', $result['uuid']);
self::assertSame(42, $result['id']);
self::assertSame('Test Tenant', $result['form']['description']);
}
public function testCreateFromAdminRejectsEmptyDescription(): void
{
$input = $this->validInput();
$input['description'] = '';
$this->tenantRepository->expects($this->never())
->method('create');
$result = $this->service->createFromAdmin($input);
self::assertFalse($result['ok']);
self::assertNotEmpty($result['errors']);
}
public function testUpdateFromAdminSucceeds(): void
{
$input = $this->validInput();
$this->tenantRepository->expects($this->once())
->method('find')
->with(10)
->willReturn(['id' => 10, 'uuid' => 'existing-uuid', 'status' => 'active']);
$this->tenantRepository->expects($this->once())
->method('update')
->with(10, $this->isType('array'))
->willReturn(true);
$this->systemAuditService->expects($this->once())
->method('record');
$result = $this->service->updateFromAdmin(10, $input, 5);
self::assertTrue($result['ok']);
self::assertSame('Test Tenant', $result['form']['description']);
}
public function testDeleteByUuidSucceeds(): void
{
$tenant = ['id' => 7, 'uuid' => 'del-uuid-789'];
$this->tenantRepository->expects($this->once())
->method('findByUuid')
->with('del-uuid-789')
->willReturn($tenant);
$this->departmentRepository->expects($this->once())
->method('countByTenantId')
->with(7)
->willReturn(0);
$this->tenantRepository->expects($this->once())
->method('delete')
->with(7)
->willReturn(true);
$this->systemAuditService->expects($this->once())
->method('record');
$result = $this->service->deleteByUuid('del-uuid-789');
self::assertTrue($result['ok']);
self::assertSame($tenant, $result['tenant']);
}
public function testDeleteByUuidBlocksWhenDepartmentsExist(): void
{
$tenant = ['id' => 7, 'uuid' => 'dep-uuid-789'];
$this->tenantRepository->expects($this->once())
->method('findByUuid')
->with('dep-uuid-789')
->willReturn($tenant);
$this->departmentRepository->expects($this->once())
->method('countByTenantId')
->with(7)
->willReturn(3);
$this->tenantRepository->expects($this->never())
->method('delete');
$result = $this->service->deleteByUuid('dep-uuid-789');
self::assertFalse($result['ok']);
self::assertSame(409, $result['status']);
self::assertSame('tenant_has_departments', $result['error']);
}
public function testDeleteByUuidReturnsNotFoundForEmptyUuid(): void
{
$this->tenantRepository->expects($this->never())
->method('findByUuid');
$result = $this->service->deleteByUuid('');
self::assertFalse($result['ok']);
self::assertSame(404, $result['status']);
self::assertSame('not_found', $result['error']);
}
public function testDeleteByUuidReturnsNotFoundForMissingTenant(): void
{
$this->tenantRepository->expects($this->once())
->method('findByUuid')
->with('nonexistent-uuid')
->willReturn(null);
$result = $this->service->deleteByUuid('nonexistent-uuid');
self::assertFalse($result['ok']);
self::assertSame(404, $result['status']);
self::assertSame('not_found', $result['error']);
}
/**
* @return array<string, mixed>
*/
private function validInput(): array
{
return [
'description' => 'Test Tenant',
'status' => 'active',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'vat_id' => '',
'tax_number' => '',
'phone' => '',
'fax' => '',
'email' => '',
'support_email' => '',
'support_phone' => '',
'billing_email' => '',
'website' => '',
'privacy_url' => '',
'imprint_url' => '',
'primary_color' => '',
'primary_color_use_default' => true,
'default_theme' => '',
'allow_user_theme_mode' => '',
];
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserSettingsGateway;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class UserLifecycleServiceTest extends TestCase
{
private UserReadRepositoryInterface&MockObject $userReadRepository;
private UserWriteRepositoryInterface&MockObject $userWriteRepository;
private UserSettingsGateway&MockObject $settingsGateway;
private UserLifecycleAuditService&MockObject $auditService;
private DatabaseSessionRepository&MockObject $databaseSessionRepository;
protected function setUp(): void
{
$this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$this->userWriteRepository = $this->createMock(UserWriteRepositoryInterface::class);
$this->settingsGateway = $this->createMock(UserSettingsGateway::class);
$this->auditService = $this->createMock(UserLifecycleAuditService::class);
$this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
// Default: lock succeeds, release is a no-op
$this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(true);
$this->databaseSessionRepository->method('releaseAdvisoryLock');
// Default: no privileged users
$this->userReadRepository->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]);
}
private function createService(): UserLifecycleService
{
return new UserLifecycleService(
$this->userReadRepository,
$this->userWriteRepository,
$this->settingsGateway,
$this->auditService,
$this->databaseSessionRepository
);
}
public function testRunReturnsLockedWhenLockNotAcquired(): void
{
$this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(false);
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90);
$this->userWriteRepository->expects($this->never())->method('setInactiveByIds');
$this->userWriteRepository->expects($this->never())->method('deleteByIds');
$result = $this->createService()->run();
$this->assertFalse($result['ok']);
$this->assertTrue($result['locked']);
$this->assertSame('lock_not_acquired', $result['error']);
}
public function testRunSkipsWhenDeactivateDaysIsZero(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(0);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90);
$this->userReadRepository->expects($this->never())->method('listIdsForAutoDeactivate');
$this->userReadRepository->expects($this->never())->method('listIdsForAutoDelete');
$this->userWriteRepository->expects($this->never())->method('setInactiveByIds');
$this->userWriteRepository->expects($this->never())->method('deleteByIds');
$result = $this->createService()->run();
$this->assertTrue($result['ok']);
$this->assertSame(0, $result['deactivated_count']);
$this->assertSame(0, $result['deleted_count']);
$this->assertSame(0, $result['policy']['deactivate_days']);
$this->assertSame(0, $result['policy']['delete_days']);
}
public function testRunDeactivatesUsers(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0);
$this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([1, 2]);
$user1 = ['id' => 1, 'email' => 'a@example.com'];
$user2 = ['id' => 2, 'email' => 'b@example.com'];
$this->userReadRepository->method('find')->willReturnCallback(
fn (int $id) => match ($id) {
1 => $user1,
2 => $user2,
default => null,
}
);
$this->userWriteRepository->expects($this->once())
->method('setInactiveByIds')
->with([1, 2], null)
->willReturn(2);
$this->userWriteRepository->expects($this->once())
->method('bumpAuthzVersionByUserIds')
->with([1, 2]);
$this->auditService->expects($this->exactly(2))->method('logDeactivate');
$result = $this->createService()->run();
$this->assertTrue($result['ok']);
$this->assertSame(2, $result['deactivated_count']);
}
public function testRunDeletesUsers(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90);
$this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]);
$this->userReadRepository->method('listIdsForAutoDelete')->willReturn([3]);
$user3 = ['id' => 3, 'email' => 'c@example.com'];
$this->userReadRepository->method('find')->willReturn($user3);
$this->auditService->expects($this->once())
->method('logDeleteWithSnapshot')
->willReturn(42);
$this->userWriteRepository->expects($this->once())
->method('deleteByIds')
->with([3])
->willReturn(1);
$result = $this->createService()->run();
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['deleted_count']);
$this->assertSame(0, $result['skipped_count']);
}
public function testRunSkipsDeleteWhenSnapshotFails(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90);
$this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]);
$this->userReadRepository->method('listIdsForAutoDelete')->willReturn([5]);
$user5 = ['id' => 5, 'email' => 'e@example.com'];
$this->userReadRepository->method('find')->willReturn($user5);
$this->auditService->expects($this->once())
->method('logDeleteWithSnapshot')
->willReturn(0);
$this->auditService->expects($this->once())
->method('logDeleteFailure');
$this->userWriteRepository->expects($this->never())->method('deleteByIds');
$result = $this->createService()->run();
$this->assertSame(1, $result['skipped_count']);
$this->assertSame(0, $result['deleted_count']);
}
public function testRunReleasesLockEvenOnException(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0);
$this->userReadRepository->method('listIdsForAutoDeactivate')
->willThrowException(new \RuntimeException('DB error'));
$this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(true);
$this->databaseSessionRepository->expects($this->once())->method('releaseAdvisoryLock');
$result = $this->createService()->run();
$this->assertFalse($result['ok']);
$this->assertSame('unexpected_error', $result['error']);
}
public function testRunReportsPrivilegedUserCount(): void
{
$this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
$this->userReadRepository->method('listPrivilegedUserIdsByPermissionKeys')
->willReturn([10, 20, 30]);
$this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]);
$this->userReadRepository->method('listIdsForAutoDelete')->willReturn([]);
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90);
$result = $this->createService()->run();
$this->assertSame(3, $result['skipped_privileged_count']);
}
public function testRunTriggerTypeIsManualWhenActorProvided(): void
{
$this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30);
$this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0);
$this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([7]);
$user7 = ['id' => 7, 'email' => 'g@example.com'];
$this->userReadRepository->method('find')->willReturn($user7);
$this->userWriteRepository->method('setInactiveByIds')->willReturn(1);
$this->auditService->expects($this->once())
->method('logDeactivate')
->with(
$this->isType('string'),
'manual',
$this->isType('array'),
99,
$user7,
'success',
null
);
$result = $this->createService()->run(99);
$this->assertSame(1, $result['deactivated_count']);
}
}