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>
2026-03-13 13:57:54 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Service\Tenant;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
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>
2026-03-13 13:57:54 +01:00
|
|
|
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;
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
private AuditRecorderInterface&MockObject $systemAuditService;
|
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>
2026-03-13 13:57:54 +01:00
|
|
|
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);
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$this->systemAuditService = $this->createMock(AuditRecorderInterface::class);
|
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>
2026-03-13 13:57:54 +01:00
|
|
|
|
|
|
|
|
$this->service = new TenantService(
|
|
|
|
|
$this->tenantRepository,
|
|
|
|
|
$this->departmentRepository,
|
|
|
|
|
$this->settingsGateway,
|
|
|
|
|
$this->systemAuditService
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')
|
2026-03-19 20:15:48 +01:00
|
|
|
->with(10, $this->isArray())
|
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>
2026-03-13 13:57:54 +01:00
|
|
|
->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' => '',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|