user service test

This commit is contained in:
Charles 2025-12-09 15:22:35 +01:00
parent 0cd33e84f8
commit 55c42c81fa
9 changed files with 772 additions and 334 deletions

5
.gitignore vendored
View File

@ -15,11 +15,6 @@
.phpunit.result.cache
###< phpunit/phpunit ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/

View File

@ -36,3 +36,6 @@
``` html
<div class="card p-3">
```
php bin/console messenger:consume async -vv

View File

@ -106,7 +106,7 @@
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"phpunit/phpunit": "^11.0",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/debug-bundle": "7.2.*",

681
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false"
>
colors="true"
cacheDirectory=".phpunit.cache">
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php>
<testsuites>
@ -23,16 +18,9 @@
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions>
</extensions>
</source>
</phpunit>

View File

@ -103,7 +103,7 @@ class OrganizationController extends AbstractController
$this->entityManager->persist($organization);
$this->entityManager->flush();
$this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Created");
$this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Created");
$this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Created", $organization->getId());
$this->actionService->createAction("Create Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
} catch (Exception $e) {
@ -171,7 +171,7 @@ class OrganizationController extends AbstractController
$this->entityManager->flush();
$this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(), "Organization Edited");
if ($this->isGranted("ROLE_SUPER_ADMIN")) {
$this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(), "Organization Edited");
$this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(), "Organization Edited", $organization->getId());
}
$this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
@ -268,7 +268,7 @@ class OrganizationController extends AbstractController
// $this->userOrganizationService->deactivateAllUserOrganizationLinks($actingUser, null, $organization);
$this->entityManager->persist($organization);
$this->actionService->createAction("Deactivate Organization", $actingUser, $organization, $organization->getName());
$this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization deactivated');
$this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization deactivated', $organization->getId());
return $this->redirectToRoute('organization_index');
}
@ -289,7 +289,7 @@ class OrganizationController extends AbstractController
$organization->setIsActive(true);
$this->entityManager->persist($organization);
$this->loggerService->logOrganizationInformation($organization->getId(), $actingUser->getId(),'Organization Activated');
$this->loggerService->logSuperAdmin($actingUser->getId(), $organization->getId(), $actingUser->getId(),'Organization Activated');
$this->loggerService->logSuperAdmin($actingUser->getId(), $actingUser->getId(),'Organization Activated', $organization->getId());
$this->actionService->createAction("Activate Organization", $actingUser, $organization, $organization->getName());
return $this->redirectToRoute('organization_index');
}

View File

@ -218,7 +218,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
"Super Admin accessed user edit page",
);
@ -231,7 +230,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
"Super Admin accessed user edit page",
);
@ -295,9 +293,9 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$existingUser->getId(),
$org->getId(),
$actingUser->getId(),
"Super Admin linked user to organization",
$org->getId(),
);
}
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
@ -329,7 +327,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
"Super Admin created new user",
@ -347,9 +344,9 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$org->getId(),
$actingUser->getId(),
"Super Admin linked user to organization during creation",
$org->getId()
);
}
@ -418,7 +415,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
'Super admin deactivated user'
);
@ -441,7 +437,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
'Super admin activated user'
);
@ -577,7 +572,6 @@ class UserController extends AbstractController
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
'Super admin deleted user'
);

View File

@ -87,7 +87,7 @@ readonly class LoggerService
]));
}
public function logSuperAdmin(int $userId, ?int $orgId = null, int $actingUserId, string $message): void
public function logSuperAdmin(int $userId, int $actingUserId, string $message, ?int $orgId = null): void
{
$this->adminActionsLogger->notice($message, [
'target_user_id' => $userId,

View File

@ -0,0 +1,369 @@
<?php
namespace App\Tests\Service;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\User;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
use App\Service\LoggerService;
use App\Service\OrganizationsService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\EntityRepository;
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class UserServiceTest extends TestCase
{
private UserService $userService;
// Mocks
private MockObject|EntityManagerInterface $entityManager;
private MockObject|Security $security;
private MockObject|AwsService $awsService;
private MockObject|LoggerService $loggerService;
private MockObject|ActionService $actionService;
private MockObject|EmailService $emailService;
private MockObject|OrganizationsService $organizationsService;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->security = $this->createMock(Security::class);
$this->awsService = $this->createMock(AwsService::class);
$this->actionService = $this->createMock(ActionService::class);
$this->emailService = $this->createMock(EmailService::class);
$this->organizationsService = $this->createMock(OrganizationsService::class);
// HANDLING READONLY LOGGER SERVICE
// PHPUnit 10+ generally handles readonly classes fine.
// If your LoggerService is 'final readonly', you cannot mock it easily.
// Assuming it is just 'readonly class LoggerService':
$this->loggerService = $this->createMock(LoggerService::class);
$this->userService = new UserService(
$this->entityManager,
$this->security,
$this->awsService,
$this->loggerService,
$this->actionService,
$this->emailService,
$this->organizationsService
);
}
public function testGenerateRandomPassword(): void
{
$password = $this->userService->generateRandomPassword();
$this->assertEquals(50, strlen($password));
$this->assertMatchesRegularExpression('/[a-zA-Z0-9!@#$%^&*()_+]+/', $password);
}
public function testIsUserConnectedReturnsTrueIfTokenValid(): void
{
$userIdentifier = 'test@example.com';
// Mock the Repository for AccessToken
$repo = $this->createMock(EntityRepository::class);
// Mock a token that expires in the future
$token = $this->createMock(AccessToken::class);
$token->method('getExpiry')->willReturn(new \DateTimeImmutable('+1 hour'));
$repo->expects($this->once())
->method('findBy')
->with(['userIdentifier' => $userIdentifier, 'revoked' => false])
->willReturn([$token]);
$this->entityManager->expects($this->once())
->method('getRepository')
->with(AccessToken::class)
->willReturn($repo);
$result = $this->userService->isUserConnected($userIdentifier);
$this->assertTrue($result);
}
public function testIsUserConnectedReturnsFalseIfTokenExpired(): void
{
$userIdentifier = 'test@example.com';
$repo = $this->createMock(EntityRepository::class);
$token = $this->createMock(AccessToken::class);
$token->method('getExpiry')->willReturn(new \DateTimeImmutable('-1 hour'));
$repo->method('findBy')->willReturn([$token]);
$this->entityManager->method('getRepository')->willReturn($repo);
$result = $this->userService->isUserConnected($userIdentifier);
$this->assertFalse($result);
}
public function testGetUserByIdentifierFound(): void
{
$identifier = 'user@test.com';
$user = new User();
$user->setEmail($identifier);
$repo = $this->createMock(EntityRepository::class);
$repo->expects($this->once())
->method('findOneBy')
->with(['email' => $identifier])
->willReturn($user);
$this->entityManager->method('getRepository')->with(User::class)->willReturn($repo);
$result = $this->userService->getUserByIdentifier($identifier);
$this->assertSame($user, $result);
}
public function testGetUserByIdentifierNotFound(): void
{
$identifier = 'unknown@test.com';
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturn(null);
$this->entityManager->method('getRepository')->with(User::class)->willReturn($repo);
// Expect Logger to be called
$this->loggerService->expects($this->once())
->method('logEntityNotFound')
->with('User', ['user_identifier' => $identifier], null);
$this->expectException(EntityNotFoundException::class);
$this->expectExceptionMessage(UserService::NOT_FOUND);
$this->userService->getUserByIdentifier($identifier);
}
public function testHasAccessToReturnsTrueForSuperAdmin(): void
{
$this->security->method('isGranted')->with('ROLE_SUPER_ADMIN')->willReturn(true);
$user = new User(); // Dummy user
$this->assertTrue($this->userService->hasAccessTo($user));
}
public function testHasAccessToReturnsTrueForSelf(): void
{
$this->security->method('isGranted')->willReturn(false);
$currentUser = new User();
$currentUser->setEmail('me@test.com');
$targetUser = new User();
$targetUser->setEmail('me@test.com');
$this->security->method('getUser')->willReturn($currentUser);
// skipSelfCheck = false (default)
$this->assertTrue($this->userService->hasAccessTo($targetUser));
}
public function testHandleProfilePictureUploadsAndLogs(): void
{
$user = new User();
$user->setName('John');
$user->setSurname('Doe');
// Mock UploadedFile
$file = $this->createMock(UploadedFile::class);
$file->method('guessExtension')->willReturn('jpg');
// Expect AWS Call
$this->awsService->expects($this->once())
->method('PutDocObj')
->with(
$this->anything(), // ENV variable usually
$file,
$this->stringContains('JohnDoe_'),
'jpg',
'profile/'
);
// Expect Logger Call
$this->loggerService->expects($this->once())
->method('logAWSAction');
// Set fake ENV for test context if needed, or ignore the argument in mock
$_ENV['S3_PORTAL_BUCKET'] = 'test-bucket';
$this->userService->handleProfilePicture($user, $file);
$this->assertStringContainsString('profile/JohnDoe_', $user->getPictureUrl());
}
public function testSyncUserRolesAddsRole(): void
{
$user = new User();
$user->setRoles(['ROLE_USER']);
$this->loggerService->expects($this->once())->method('logRoleAssignment');
$this->userService->syncUserRoles($user, 'ADMIN', true);
$this->assertContains('ROLE_ADMIN', $user->getRoles());
}
public function testSyncUserRolesRemovesRole(): void
{
$user = new User();
$user->setRoles(['ROLE_USER', 'ROLE_ADMIN']);
// Mock repositories to ensure no other org gives this role
$repoUO = $this->createMock(EntityRepository::class);
$repoUO->method('findBy')->willReturn([]); // No active org links
$this->entityManager->method('getRepository')
->willReturnMap([
[UsersOrganizations::class, $repoUO]
]);
$this->userService->syncUserRoles($user, 'ADMIN', false);
$this->assertNotContains('ROLE_ADMIN', $user->getRoles());
}
public function testIsPasswordStrong(): void
{
$this->assertTrue($this->userService->isPasswordStrong('StrongP@ss1')); // Chars + Digits + Special + Length
$this->assertFalse($this->userService->isPasswordStrong('weak')); // Too short
$this->assertFalse($this->userService->isPasswordStrong('123456789')); // No letters
}
public function testCreateNewUserSuccess(): void
{
$newUser = new User();
$newUser->setName('jane');
$newUser->setSurname('doe');
$newUser->setEmail('jane@doe.com');
$actingUser = new User();
$this->setEntityId($actingUser, 99); // Give acting user an ID
$actingUser->setEmail('admin@test.com');
// When persist is called, we force an ID onto $newUser to simulate DB insertion
$this->entityManager->expects($this->exactly(2))
->method('persist')
->with($newUser)
->willReturnCallback(function ($entity) {
$this->setEntityId($entity, 123); // Simulate DB assigning ID 123
});
$this->entityManager->expects($this->exactly(2))->method('flush');
// Now expects ID 123
$this->loggerService->expects($this->once())
->method('logUserCreated')
->with(123, 99);
$this->emailService->expects($this->once())->method('sendPasswordSetupEmail');
$this->actionService->expects($this->once())->method('createAction');
$this->userService->createNewUser($newUser, $actingUser, null);
// Assertions
$this->assertEquals('Jane', $newUser->getName());
$this->assertEquals(123, $newUser->getId()); // Verify ID was "generated"
}
public function testLinkUserToOrganization(): void
{
$user = new User();
$this->setEntityId($user, 10); // Pre-set ID for existing user
$org = new Organizations();
$this->setEntityId($org, 50); // Pre-set ID for org
$actingUser = new User();
$this->setEntityId($actingUser, 99);
// Capture the UsersOrganizations entity when it is persisted to give it an ID
$this->entityManager->expects($this->exactly(2))
->method('persist')
->willReturnCallback(function ($entity) use ($user) {
if ($entity instanceof UsersOrganizations) {
// This is the UO entity link (Call 1)
$this->setEntityId($entity, 555);
} elseif ($entity instanceof User && $entity === $user) {
// This is the User entity inside generatePasswordToken (Call 2)
// The ID is already set, so we do nothing here.
}
});
$this->entityManager->expects($this->exactly(2))->method('flush');
// Now the logger will receive valid Integers instead of null
$this->loggerService->expects($this->once())
->method('logUserOrganizationLinkCreated')
->with(10, 50, 99, 555);
$this->emailService->expects($this->once())->method('sendPasswordSetupEmail');
$this->organizationsService->expects($this->once())->method('notifyOrganizationAdmins');
$result = $this->userService->linkUserToOrganization($user, $org, $actingUser);
$this->assertInstanceOf(UsersOrganizations::class, $result);
$this->assertEquals(555, $result->getId());
}
public function testIsAdminOfOrganizationReturnsTrue(): void
{
$org = new Organizations();
$currentUser = new User();
$currentUser->setEmail('admin@test.com');
// Mock Security User
$this->security->method('getUser')->willReturn($currentUser);
$this->security->method('isGranted')->with('ROLE_ADMIN')->willReturn(true);
// 1. getUserByIdentifier (internal call) mocks
$userRepo = $this->createMock(EntityRepository::class);
$userRepo->method('findOneBy')->with(['email' => 'admin@test.com'])->willReturn($currentUser);
// 2. UsersOrganizations mock
$uoRepo = $this->createMock(EntityRepository::class);
$uo = new UsersOrganizations();
$uoRepo->method('findOneBy')->willReturn($uo);
// 3. Roles mock
$rolesRepo = $this->createMock(EntityRepository::class);
$adminRole = new Roles();
$adminRole->setName('ADMIN');
$rolesRepo->method('findOneBy')->with(['name' => 'ADMIN'])->willReturn($adminRole);
// 4. UserOrganizatonApp mock (The link checking if they are admin active)
$uoaRepo = $this->createMock(EntityRepository::class);
$uoa = new UserOrganizatonApp();
$uoaRepo->method('findOneBy')->willReturn($uoa); // Returns an object, so true
// Configure EntityManager to return these repos based on class
$this->entityManager->method('getRepository')->willReturnMap([
[User::class, $userRepo],
[UsersOrganizations::class, $uoRepo],
[Roles::class, $rolesRepo],
[UserOrganizatonApp::class, $uoaRepo],
]);
$result = $this->userService->isAdminOfOrganization($org);
$this->assertTrue($result);
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new \ReflectionClass($entity);
$property = $reflection->getProperty('id');
// $property->setAccessible(true); // Required for PHP < 8.1
$property->setValue($entity, $id);
}
}