1
0
Files
breadcrumb-the-shire/tests/Service/Org/DepartmentServiceTest.php
fs c429ff43cd refactor: fix 7 CRUD system inconsistencies for robustness and maintainability
- Add user-assignment dependency check to DepartmentService.deleteByUuid (409 when users assigned)
- Standardize POST detection to isMethod('POST') in 10 create/edit actions
- Align RoleService code uniqueness to warning pattern (matching departments)
- Normalize repository create() return types from mixed to int|false (3 repos + 3 interfaces)
- Add JSON response branches to 4 non-user delete actions
- Document delete authorization ordering pattern
- Decompose AdminSettingsService.updateFromAdmin into domain-scoped private methods

Workflow: .agents/runs/CRUD-CONSISTENCY-001/
Reviewed by: Code Reviewer (pass), Security Reviewer (pass), Acceptance Tester (pass)
Quality gates: QG-001 PHPUnit pass, QG-002 PHPStan pass, QG-003 Architecture pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:25:33 +01:00

417 lines
14 KiB
PHP

<?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\Attributes\DataProvider;
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']);
}
#[DataProvider('groupActiveByTenantIdsInputProvider')]
public function testGroupActiveByTenantIdsNormalizesInput(array $tenantIds, array $expectedRepositoryIds): void
{
$this->departmentRepository
->expects($expectedRepositoryIds ? $this->once() : $this->never())
->method('listByTenantIds')
->with($expectedRepositoryIds)
->willReturn([]);
$this->assertSame([], $this->service->groupActiveByTenantIds($tenantIds));
}
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']);
}
#[DataProvider('invalidDepartmentInputProvider')]
public function testCreateFromAdminRejectsInvalidInput(array $input): void
{
$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);
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
$userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([5])->willReturn([]);
$this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo);
$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 testDeleteByUuidReturnsDeleteFailedOnRepositoryFailure(): void
{
$this->departmentRepository
->expects($this->once())
->method('findByUuid')
->with('abc-999')
->willReturn(['id' => 9, 'uuid' => 'abc-999', 'description' => 'Ops']);
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
$userDeptRepo->expects($this->any())->method('countUsersByDepartmentIds')->with([9])->willReturn([]);
$this->userServicesFactory->expects($this->any())->method('createUserDepartmentRepository')->willReturn($userDeptRepo);
$this->departmentRepository
->expects($this->once())
->method('delete')
->with(9)
->willReturn(false);
$this->systemAuditService
->expects($this->never())
->method('record');
$result = $this->service->deleteByUuid('abc-999');
$this->assertFalse($result['ok']);
$this->assertSame(500, $result['status']);
$this->assertSame('delete_failed', $result['error']);
}
public function testDeleteByUuidReturns409WhenUsersAssigned(): void
{
$this->departmentRepository
->expects($this->once())
->method('findByUuid')
->with('abc-dept')
->willReturn(['id' => 5, 'uuid' => 'abc-dept', 'description' => 'Sales']);
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
$userDeptRepo->expects($this->once())
->method('countUsersByDepartmentIds')
->with([5])
->willReturn([5 => 3]);
$this->userServicesFactory->expects($this->once())
->method('createUserDepartmentRepository')
->willReturn($userDeptRepo);
$this->departmentRepository
->expects($this->never())
->method('delete');
$this->systemAuditService
->expects($this->never())
->method('record');
$result = $this->service->deleteByUuid('abc-dept');
$this->assertFalse($result['ok']);
$this->assertSame(409, $result['status']);
$this->assertSame('department_has_users', $result['error']);
$this->assertSame(3, $result['user_count']);
}
public function testDeleteByUuidDeletesWhenNoUsersAssigned(): void
{
$this->departmentRepository
->expects($this->once())
->method('findByUuid')
->with('abc-empty')
->willReturn(['id' => 7, 'uuid' => 'abc-empty', 'description' => 'Empty Dept']);
$userDeptRepo = $this->createMock(UserDepartmentRepositoryInterface::class);
$userDeptRepo->expects($this->once())
->method('countUsersByDepartmentIds')
->with([7])
->willReturn([]);
$this->userServicesFactory->expects($this->once())
->method('createUserDepartmentRepository')
->willReturn($userDeptRepo);
$this->departmentRepository
->expects($this->once())
->method('delete')
->with(7)
->willReturn(true);
$this->systemAuditService
->expects($this->once())
->method('record');
$result = $this->service->deleteByUuid('abc-empty');
$this->assertTrue($result['ok']);
$this->assertSame('abc-empty', $result['department']['uuid']);
}
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);
}
public static function groupActiveByTenantIdsInputProvider(): array
{
return [
'invalid ids are ignored completely' => [[0, -1, -5], []],
'duplicate ids are reduced before querying' => [[1, 1, 1], [1]],
];
}
public static function invalidDepartmentInputProvider(): array
{
return [
'missing description' => [[
'description' => '',
'tenant_id' => 1,
'code' => '',
'cost_center' => '',
'active' => 1,
]],
'missing tenant id' => [[
'description' => 'Sales',
'tenant_id' => 0,
'code' => '',
'cost_center' => '',
'active' => 1,
]],
];
}
}