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

700 lines
29 KiB
PHP
Raw Normal View History

2026-03-06 00:44:52 +01:00
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
2026-03-06 00:44:52 +01:00
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Tenant\TenantScopeService;
2026-03-06 00:44:52 +01:00
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserDirectoryGateway;
use MintyPHP\Service\User\UserPasswordService;
use MintyPHP\Service\User\UserSettingsGateway;
2026-03-19 20:28:04 +01:00
use PHPUnit\Framework\Attributes\DataProvider;
2026-03-06 00:44:52 +01:00
use PHPUnit\Framework\TestCase;
class UserAccountServiceTest extends TestCase
{
public function testListPagedDropsTenantUserIdForGlobalAccess(): void
{
$scopeGateway = $this->createMock(TenantScopeService::class);
2026-03-06 00:44:52 +01:00
$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));
}
2026-03-19 20:28:04 +01:00
#[DataProvider('missingDeleteByUuidInputProvider')]
public function testDeleteByUuidReturnsNotFoundForMissingInput(string $uuid, ?array $resolvedUser): void
2026-03-06 00:44:52 +01:00
{
2026-03-19 20:28:04 +01:00
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
if ($uuid !== '') {
$readRepository->expects($this->once())
->method('findByUuid')
->with($uuid)
->willReturn($resolvedUser);
} else {
$readRepository->expects($this->never())->method('findByUuid');
}
2026-03-06 00:44:52 +01:00
2026-03-19 20:28:04 +01:00
$service = $this->newService($readRepository);
$result = $service->deleteByUuid($uuid);
2026-03-06 00:44:52 +01:00
$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(AuditRecorderInterface::class);
2026-03-06 00:44:52 +01:00
$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));
}
2026-03-19 20:28:04 +01:00
public function testDeleteByUuidReturnsDeleteFailedWhenRepositoryDeleteFails(): 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(false);
$auditService = $this->createMock(AuditRecorderInterface::class);
2026-03-19 20:28:04 +01:00
$auditService->expects($this->never())->method('record');
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, null, null, $auditService);
$result = $service->deleteByUuid('user-uuid', 44);
$this->assertFalse($result['ok']);
$this->assertSame(500, (int) ($result['status'] ?? 0));
$this->assertSame('delete_failed', (string) ($result['error'] ?? ''));
}
2026-03-06 00:44:52 +01:00
public function testDeleteByUuidsReturnsPermissionDeniedWhenNothingIsInScope(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->method('findByUuid')->willReturn(['id' => 51, 'uuid' => 'other-uuid']);
$scopeGateway = $this->createMock(TenantScopeService::class);
2026-03-06 00:44:52 +01:00
$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'] ?? ''));
}
2026-03-19 20:28:04 +01:00
public function testSetActiveByUuidReturnsNotFoundForMissingUser(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('findByUuid')
->with('u-missing')
->willReturn(null);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('setActive');
$service = $this->newService($readRepository, $writeRepository);
$result = $service->setActiveByUuid('u-missing', true, 42);
$this->assertFalse($result['ok']);
$this->assertSame(404, (int) ($result['status'] ?? 0));
$this->assertSame('not_found', (string) ($result['error'] ?? ''));
}
public function testSetActiveByUuidReturnsUpdateFailedWhenRepositoryRejects(): 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(false);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->never())->method('bumpAuthzVersion');
$auditService = $this->createMock(AuditRecorderInterface::class);
2026-03-19 20:28:04 +01:00
$auditService->expects($this->never())->method('record');
$service = $this->newService($readRepository, $writeRepository, null, $assignmentService, null, null, null, null, $auditService);
$result = $service->setActiveByUuid('u-33', true, 90);
$this->assertFalse($result['ok']);
$this->assertSame(500, (int) ($result['status'] ?? 0));
$this->assertSame('update_failed', (string) ($result['error'] ?? ''));
}
2026-03-06 00:44:52 +01:00
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(AuditRecorderInterface::class);
2026-03-06 00:44:52 +01:00
$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);
2026-03-06 00:44:52 +01:00
$scopeGateway->method('canAccess')->willReturn(true);
$auditService = $this->createMock(AuditRecorderInterface::class);
2026-03-06 00:44:52 +01:00
$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('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
2026-03-19 20:28:04 +01:00
$settingsGateway->method('normalizeTheme')
->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light');
2026-03-06 00:44:52 +01:00
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('create')
->with($this->callback(static function (array $data): bool {
refactor(settings): remove global appearance settings — tenant is sole source Removes the global app_theme, app_theme_user and app_primary_color settings from the admin/settings area and the underlying service/cache/API/i18n/docs layers. The tenant columns (tenants.primary_color, default_theme, allow_user_theme) become the single source of truth for appearance. Branding helpers are tenant-only and fall back to a hardcoded 'light' theme with no brand color when no tenant context is available (login, error, pre-session pages). The APP_THEME env var is removed. Scope: - UI: Appearance tab gone from /admin/settings; tenant edit form drops the global-fallback color preview. - Service: SettingsAppGateway no longer exposes setting-backed accessors for theme/user-theme/primary-color. Theme catalog utilities (listThemes, isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes 'light' with a catalog fallback (no global/env lookup). - AdminSettingsService: buildPageData, sanitization, audit snapshot, apply step and cache payload are all stripped of the three keys. Dead helper applyAppPrimaryColor + normalizePrimaryColor removed. - Cache: SettingCacheService HOT_PATH_KEYS drops the three keys. - API: /settings (GET/PUT) and /settings/public (GET) no longer expose appearance fields; OpenAPI schemas pruned accordingly. - Audit redaction list shortened. - DB: db/init/init.sql seed rows removed. No migration for existing installs — legacy rows stay inert. - i18n + docs: setting.app_theme, setting.app_theme_user, setting.app_primary_color removed from de+en locales; reference and explanation docs updated. - Tests: covering tests for the removed methods pruned; ThemeResolutionTest rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in UserRegistrar.php cleaned up to keep QG-006 green. Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored). Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions). Reviews: code, security, acceptance all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00
// New users created via register() get the default 'light' theme.
// Appearance is tenant-scoped, not globally configurable, so
// normalizeTheme(null) resolves to 'light'.
2026-03-06 00:44:52 +01:00
return (string) ($data['email'] ?? '') === 'user@example.com'
refactor(settings): remove global appearance settings — tenant is sole source Removes the global app_theme, app_theme_user and app_primary_color settings from the admin/settings area and the underlying service/cache/API/i18n/docs layers. The tenant columns (tenants.primary_color, default_theme, allow_user_theme) become the single source of truth for appearance. Branding helpers are tenant-only and fall back to a hardcoded 'light' theme with no brand color when no tenant context is available (login, error, pre-session pages). The APP_THEME env var is removed. Scope: - UI: Appearance tab gone from /admin/settings; tenant edit form drops the global-fallback color preview. - Service: SettingsAppGateway no longer exposes setting-backed accessors for theme/user-theme/primary-color. Theme catalog utilities (listThemes, isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes 'light' with a catalog fallback (no global/env lookup). - AdminSettingsService: buildPageData, sanitization, audit snapshot, apply step and cache payload are all stripped of the three keys. Dead helper applyAppPrimaryColor + normalizePrimaryColor removed. - Cache: SettingCacheService HOT_PATH_KEYS drops the three keys. - API: /settings (GET/PUT) and /settings/public (GET) no longer expose appearance fields; OpenAPI schemas pruned accordingly. - Audit redaction list shortened. - DB: db/init/init.sql seed rows removed. No migration for existing installs — legacy rows stay inert. - i18n + docs: setting.app_theme, setting.app_theme_user, setting.app_primary_color removed from de+en locales; reference and explanation docs updated. - Tests: covering tests for the removed methods pruned; ThemeResolutionTest rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in UserRegistrar.php cleaned up to keep QG-006 green. Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored). Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions). Reviews: code, security, acceptance all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00
&& (string) ($data['theme'] ?? '') === 'light'
2026-03-06 00:44:52 +01:00
&& (int) ($data['primary_tenant_id'] ?? 0) === 3
&& (int) ($data['active'] ?? 0) === 1;
}))
->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(true);
$assignmentService->expects($this->once())->method('syncDepartments')->with(77, [5])->willReturn(true);
2026-03-06 00:44:52 +01:00
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertTrue($result['ok']);
}
public function testCreateFromAdminRollsBackTransactionWhenTenantSyncFails(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
2026-03-19 20:28:04 +01:00
$settingsGateway->method('normalizeTheme')
->willReturnCallback(static fn (?string $theme): string => $theme ?? '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(AuditRecorderInterface::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('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
2026-03-19 20:28:04 +01:00
$settingsGateway->method('normalizeTheme')
->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light');
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())->method('create')->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(false);
$assignmentService->expects($this->never())->method('syncDepartments');
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())->method('beginTransaction');
$databaseSessionRepository->expects($this->never())->method('commitTransaction');
$databaseSessionRepository->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway,
null,
null,
null,
$databaseSessionRepository
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertFalse($result['ok']);
$this->assertNotSame('', trim((string) ($result['error'] ?? '')));
}
2026-03-06 00:44:52 +01:00
public function testUpdateSelfProfileReturnsErrorWhenRequestedTenantIsNotAssigned(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(15)
->willReturn([
'id' => 15,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-2')
->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(15)
->willReturn([
'tenants' => [['id' => 1, 'uuid' => 'tenant-uuid-1']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->never())->method('update');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(15, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-2',
]);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors']);
}
public function testUpdateSelfProfilePersistsDataAndTenantSwitchWhenAllowed(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->once())
->method('find')
->with(21)
->willReturn([
'id' => 21,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'profile_description' => '',
'job_title' => '',
'phone' => '',
'mobile' => '',
'short_dial' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'locale' => 'de',
'theme' => 'light',
]);
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
$directoryGateway->expects($this->once())
->method('findTenantByUuid')
->with('tenant-uuid-9')
->willReturn(['id' => 9, 'uuid' => 'tenant-uuid-9']);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())
->method('buildAssignmentsForUser')
->with(21)
->willReturn([
'tenants' => [['id' => 9, 'uuid' => 'tenant-uuid-9']],
'roles' => [],
'departments' => [],
]);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())
->method('update')
->with(21, $this->callback(static fn ($payload): bool => is_array($payload)))
->willReturn(true);
$writeRepository->expects($this->once())
->method('setCurrentTenant')
->with(21, 9)
->willReturn(true);
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
null,
null,
null,
$directoryGateway
);
$result = $service->updateSelfProfile(21, [
'first_name' => 'Max',
'last_name' => 'Mustermann',
'current_tenant_uuid' => 'tenant-uuid-9',
'locale' => 'de',
'theme' => 'light',
]);
$this->assertTrue($result['ok']);
}
private function newService(
?UserReadRepositoryInterface $readRepository = null,
?UserWriteRepositoryInterface $writeRepository = null,
?UserListQueryRepositoryInterface $listQueryRepository = null,
?UserAssignmentService $assignmentService = null,
?UserPasswordService $passwordService = null,
?UserSettingsGateway $settingsGateway = null,
?TenantScopeService $scopeGateway = null,
2026-03-06 00:44:52 +01:00
?UserDirectoryGateway $directoryGateway = null,
?AuditRecorderInterface $auditService = null,
?DatabaseSessionRepository $databaseSessionRepository = null
2026-03-06 00:44:52 +01:00
): UserAccountService {
$readRepository = $readRepository ?? $this->createMock(UserReadRepositoryInterface::class);
$writeRepository = $writeRepository ?? $this->createMock(UserWriteRepositoryInterface::class);
$listQueryRepository = $listQueryRepository ?? $this->createMock(UserListQueryRepositoryInterface::class);
if ($assignmentService === null) {
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->method('normalizeTenantIds')->willReturn([]);
$assignmentService->method('normalizeIdInput')->willReturn([]);
$assignmentService->method('buildAssignmentsForUser')->willReturn([
'tenants' => [],
'roles' => [],
'departments' => [],
]);
$assignmentService->method('syncTenants')->willReturn(true);
$assignmentService->method('syncRoles')->willReturn(true);
$assignmentService->method('syncDepartments')->willReturn(true);
}
if ($passwordService === null) {
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
}
if ($settingsGateway === null) {
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
}
if ($scopeGateway === null) {
$scopeGateway = $this->createMock(TenantScopeService::class);
2026-03-06 00:44:52 +01:00
$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(AuditRecorderInterface::class);
$databaseSessionRepository = $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class);
2026-03-06 00:44:52 +01:00
return new UserAccountService(
$readRepository,
$writeRepository,
$listQueryRepository,
$assignmentService,
$passwordService,
$settingsGateway,
$scopeGateway,
$directoryGateway,
$auditService,
$databaseSessionRepository
2026-03-06 00:44:52 +01:00
);
}
2026-03-19 20:28:04 +01:00
public static function missingDeleteByUuidInputProvider(): array
{
return [
'empty uuid' => ['', null],
'missing user' => ['missing-uuid', null],
];
}
2026-03-06 00:44:52 +01:00
}