createMock(TenantScopeService::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(TenantScopeService::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(TenantScopeService::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); $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'] ?? ''))); } 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, ?TenantScopeService $scopeGateway = null, ?UserDirectoryGateway $directoryGateway = null, ?SystemAuditService $auditService = null, ?DatabaseSessionRepository $databaseSessionRepository = null ): 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(TenantScopeService::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); return new UserAccountService( $readRepository, $writeRepository, $listQueryRepository, $assignmentService, $passwordService, $settingsGateway, $scopeGateway, $directoryGateway, $auditService, $databaseSessionRepository ); } }