From d26d1cb11835ef2bf9f1e8d5b73bb165b07aebc9 Mon Sep 17 00:00:00 2001 From: Charles Date: Tue, 6 Jan 2026 14:29:29 +0100 Subject: [PATCH] Organization Controller Tests --- assets/controllers/organization_controller.js | 12 +- migrations/Version20260105152103.php | 32 ++ migrations/Version20260106080636.php | 32 ++ migrations/Version20260106084653.php | 32 ++ src/Controller/OrganizationController.php | 24 +- src/Entity/Organizations.php | 5 +- src/Entity/User.php | 1 - src/Form/OrganizationForm.php | 4 +- templates/organization/edit.html.twig | 3 - templates/organization/index.html.twig | 6 +- templates/organization/new.html.twig | 9 +- .../Controller/OrganizationControllerTest.php | 359 ++++++++++++++++++ tests/Functional/AbstractFunctionalTest.php | 6 +- 13 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 migrations/Version20260105152103.php create mode 100644 migrations/Version20260106080636.php create mode 100644 migrations/Version20260106084653.php create mode 100644 tests/Controller/OrganizationControllerTest.php diff --git a/assets/controllers/organization_controller.js b/assets/controllers/organization_controller.js index 063d069..6271eb8 100644 --- a/assets/controllers/organization_controller.js +++ b/assets/controllers/organization_controller.js @@ -5,9 +5,11 @@ import {eyeIconLink, TABULATOR_FR_LANG} from "../js/global.js"; export default class extends Controller { static values = {aws: String, - id: String, - activities: Boolean, - table: Boolean, + id: String, + activities: Boolean, + table: Boolean, + sadmin: Boolean, + user: Number }; static targets = ["activityList", "emptyMessage"] @@ -18,7 +20,7 @@ export default class extends Controller { this.loadActivities(); }, 60000); // Refresh every 60 seconds } - if (this.tableValue){ + if (this.tableValue && this.sadminValue) { this.table(); } @@ -31,7 +33,7 @@ export default class extends Controller { placeholder: "Aucun résultat trouvé pour cette recherche", locale: "fr", //'en' for English, 'fr' for French (en is default, no need to include it) - ajaxURL: "/organization/data", + ajaxURL: `/organization/data/${this.userValue}`, ajaxConfig: "GET", pagination: true, paginationMode: "remote", diff --git a/migrations/Version20260105152103.php b/migrations/Version20260105152103.php new file mode 100644 index 0000000..067cc16 --- /dev/null +++ b/migrations/Version20260105152103.php @@ -0,0 +1,32 @@ +addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON organizations (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_IDENTIFIER_EMAIL'); + } +} diff --git a/migrations/Version20260106080636.php b/migrations/Version20260106080636.php new file mode 100644 index 0000000..3f3a5e2 --- /dev/null +++ b/migrations/Version20260106080636.php @@ -0,0 +1,32 @@ +addSql('CREATE UNIQUE INDEX UNIQ_ORGANIZATION_EMAIL ON organizations (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_ORGANIZATION_EMAIL'); + } +} diff --git a/migrations/Version20260106084653.php b/migrations/Version20260106084653.php new file mode 100644 index 0000000..ab52dc6 --- /dev/null +++ b/migrations/Version20260106084653.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE organizations ALTER logo_url DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE organizations ALTER logo_url SET NOT NULL'); + } +} diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index 2988072..ac8c076 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -16,7 +16,10 @@ use App\Service\LoggerService; use App\Service\OrganizationsService; use App\Service\UserOrganizationService; use App\Service\UserService; +use Doctrine\DBAL\Exception\NonUniqueFieldNameException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NonUniqueResultException; use Exception; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -48,15 +51,19 @@ class OrganizationController extends AbstractController public function index(): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + if($this->userService->hasAccessTo($actingUser, true)){ + $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - $orgCount = $this->organizationsRepository->count(['isDeleted' => false]); - - return $this->render('organization/index.html.twig', [ - 'hasOrganizations' => $orgCount > 0 - ]); + return $this->render('organization/index.html.twig', [ + 'hasOrganizations' => $orgCount > 0 + ]); + } + $this->loggerService->logAccessDenied($actingUser->getId()); + throw new AccessDeniedHttpException('Access denied'); } - #[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])] + #[Route(path: '/create', name: 'create', methods: ['GET', 'POST'])] public function new(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); @@ -79,6 +86,7 @@ class OrganizationController extends AbstractController return $this->redirectToRoute('organization_index'); } catch (Exception $e) { $this->addFlash('error', 'Error creating organization: ' . $e->getMessage()); + $this->loggerService->logError('Error creating organization', ['acting_user_id' => $actingUser->getId(), 'error' => $e->getMessage()]); } } return $this->render('organization/new.html.twig', [ @@ -146,7 +154,7 @@ class OrganizationController extends AbstractController } $this->actionService->createAction("Edit Organization", $actingUser, $organization, $organization->getName()); return $this->redirectToRoute('organization_index'); - } catch (Exception $e) { + }catch (Exception $e) { $this->addFlash('error', 'Error editing organization: ' . $e->getMessage()); } } @@ -266,7 +274,7 @@ class OrganizationController extends AbstractController } // API endpoint to fetch organization data for Tabulator - #[Route(path: '/data', name: 'data', methods: ['GET'])] + #[Route(path: '/data/{id}', name: 'data', methods: ['GET'])] public function data(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_ADMIN'); diff --git a/src/Entity/Organizations.php b/src/Entity/Organizations.php index bff31ba..883cb74 100644 --- a/src/Entity/Organizations.php +++ b/src/Entity/Organizations.php @@ -4,10 +4,13 @@ namespace App\Entity; use App\Repository\OrganizationsRepository; use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: OrganizationsRepository::class)] +#[ORM\UniqueConstraint(name: 'UNIQ_ORGANIZATION_EMAIL', fields: ['email'])] +#[UniqueEntity(fields: ['email'], message: 'Une organisation avec cet email existe déjà.')] class Organizations { #[ORM\Id] @@ -24,7 +27,7 @@ class Organizations #[ORM\Column(length: 255)] private ?string $address = null; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, nullable: true)] private ?string $logo_url = null; #[ORM\Column(options: ['default' => 'CURRENT_TIMESTAMP'])] diff --git a/src/Entity/User.php b/src/Entity/User.php index ca9d110..8709389 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -9,7 +9,6 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] diff --git a/src/Form/OrganizationForm.php b/src/Form/OrganizationForm.php index 571b773..da84a2d 100644 --- a/src/Form/OrganizationForm.php +++ b/src/Form/OrganizationForm.php @@ -17,8 +17,8 @@ class OrganizationForm extends AbstractType $builder ->add('email', EmailType::class, ['required' => true, 'label' => 'Email*']) ->add('name', TextType::class, ['required' => true, 'label' => 'Nom de l\'organisation*']) - ->add('address', TextType::class, ['required' => false, 'label' => 'Adresse']) - ->add('number', TextType::class, ['required' => false, 'label' => 'Numéro de téléphone']) + ->add('address', TextType::class, ['required' => true, 'label' => 'Adresse']) + ->add('number', TextType::class, ['required' => true, 'label' => 'Numéro de téléphone']) ->add('logoUrl', FileType::class, [ 'required' => false, 'label' => 'Logo', diff --git a/templates/organization/edit.html.twig b/templates/organization/edit.html.twig index aa9c754..bd1b225 100644 --- a/templates/organization/edit.html.twig +++ b/templates/organization/edit.html.twig @@ -5,9 +5,6 @@

Modifier l'organisation

- {% if is_granted("ROLE_SUPER_ADMIN") %} -{# Supprimer#} - {% endif %}
diff --git a/templates/organization/index.html.twig b/templates/organization/index.html.twig index 19bf1fc..2d8ff2f 100644 --- a/templates/organization/index.html.twig +++ b/templates/organization/index.html.twig @@ -11,7 +11,7 @@
{% if is_granted("ROLE_SUPER_ADMIN") %} - Ajouter une organisation + Ajouter une organisation {% endif %}
@@ -21,7 +21,7 @@

Aucune organisation trouvée.

- Créer une organisation + Créer une organisation
{% else %} @@ -29,6 +29,8 @@
diff --git a/templates/organization/new.html.twig b/templates/organization/new.html.twig index 2a9050c..e159fbc 100644 --- a/templates/organization/new.html.twig +++ b/templates/organization/new.html.twig @@ -6,13 +6,20 @@
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endfor %}

Ajouter une organisation

-
+ {{ form_start(form) }} {{ form_widget(form) }} diff --git a/tests/Controller/OrganizationControllerTest.php b/tests/Controller/OrganizationControllerTest.php new file mode 100644 index 0000000..4bb9065 --- /dev/null +++ b/tests/Controller/OrganizationControllerTest.php @@ -0,0 +1,359 @@ +createUser('sAdmin@test.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // Create at least one org so 'hasOrganizations' becomes true + $this->createOrganization('Organization 1'); + $this->createOrganization('Organization 2'); + + $this->client->request('GET', '/organization/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + + self::assertSelectorExists('#tabulator-org'); + } + + #[Test] + public function test_index_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + + // 2. Act + $this->client->request('GET', '/organization/'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + + } + + #[Test] + public function test_index_no_organizations(): void + { + // 1. Arrange + $admin = $this->createUser('user@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/'); + // 3. Assert + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'Aucune organisation trouvée'); + } + + //endregion + + //region CREATE tests + #[Test] + public function test_create_super_admin_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // 4. Act: Request the page + $this->client->request('GET', '/organization/create'); + + // 5. Submit Form with the FILE object and correct field name 'logoUrl' + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'New Organization', + 'organization_form[email]' => 'unique-' . uniqid('', true) . '@test.com', + 'organization_form[address]' => '123 Test Street', + 'organization_form[number]' => '0102030405', + 'organization_form[logoUrl]' => $logo, // Pass the OBJECT, not a string + ]); + + // 6. Assert + // Check for redirect (302) + self::assertResponseRedirects('/organization/'); + + $this->client->followRedirect(); + + // Ensure we see the success state + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + self::assertSelectorExists('#tabulator-org'); + } + + #[Test] + public function test_create_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + // 2. Act + $this->client->request('GET', '/organization/create'); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_create_super_admin_invalid_data(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/create'); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => '', // Invalid: name is required + 'organization_form[email]' => 'not-an-email', // Invalid email format + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + } + + #[Test] + public function test_create_super_admin_duplicate_email(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $existingOrg = $this->createOrganization('Existing Org'); + // 2. Act + $this->client->request('GET', '/organization/create'); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'New Org', + 'organization_form[email]' => $existingOrg->getEmail(), // Duplicate email + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + self::assertSelectorTextContains('body', 'Une organisation avec cet email existe déjà.'); + } + + //endregion + + //region EDIT tests + + + #[Test] + public function test_edit_super_admin_success(): void + { + // 1. Arrange: Disable reboot to keep our AWS mock alive + $this->client->disableReboot(); + + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + + // 2. MOCK AWS Service (Crucial!) + // Your code calls $awsService->PutDocObj, so we must intercept that. + // 2. MOCK AWS Service + $awsMock = $this->createMock(AwsService::class); + $awsMock->expects($this->any()) + ->method('PutDocObj') + ->willReturn(1); // <--- FIXED: Return an integer, not a boolean + + // Inject the mock into the test container + static::getContainer()->set(AwsService::class, $awsMock); + + // 3. Create a Dummy Image File + $tempFile = tempnam(sys_get_temp_dir(), 'test_logo'); + file_put_contents($tempFile, 'fake image content'); // Create a dummy file + + $logo = new UploadedFile( + $tempFile, + 'logo.png', + 'image/png', + null, + true // 'test' mode = true + ); + + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 4. Act: Request the edit page + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + // 5. Submit Form with the FILE object and correct field name 'logoUrl' + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => 'Edited Organization', + 'organization_form[email]' => 'edited-' . uniqid('', true) . '@test.com', + 'organization_form[address]' => '456 Edited Street', + 'organization_form[number]' => '0504030201', + 'organization_form[logoUrl]' => $logo, // Pass the OBJECT, not a + ]); + // 6. Assert + // Check for redirect (302) + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + // Ensure we see the success state + self::assertSelectorTextNotContains('body', 'Aucune organisation trouvée'); + self::assertSelectorExists('#tabulator-org'); + + } + + #[Test] + public function test_edit_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@email.com'); + $this->client->loginUser($user); + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 2. Act + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_edit_super_admin_invalid_data(): void + { + // 1. Arrange + $admin = $this->createUser('admin@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // Create an organization to edit + $organization = $this->createOrganization('Org to Edit'); + // 2. Act + $this->client->request('GET', '/organization/edit/' . $organization->getId()); + $this->client->submitForm('Enregistrer', [ + 'organization_form[name]' => '', // Invalid: name is required + 'organization_form[email]' => 'not-an-email', // Invalid email format + 'organization_form[address]' => '123 Test St', + 'organization_form[number]' => '0102030405', + ]); + // 3. Assert + self::assertResponseIsSuccessful(); // Form isn't redirected + } + + #[Test] + public function test_edit_nonexistent_organization_not_found(): void + { + // 1. Arrange + $admin = $this->createUser('admin@mail.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('GET', '/organization/edit/99999'); // Assuming + // 3. Assert + self::assertResponseStatusCodeSame(302); + + self::assertResponseRedirects('/organization/'); + + } + //endregion + + + //region DELETE tests + + #[Test] + public function test_delete_super_admin_success(): void + { + // 1. Arrange + $admin = $this->createUser('admin@email.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $organization = $this->createOrganization('Org to Delete'); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + self::assertSelectorTextNotContains('body', 'Org to Delete'); + self::assertTrue($this->entityManager->getRepository(Organizations::class)->find($organization->getId())->isDeleted()); + + } + + #[Test] + public function test_delete_regular_user_forbidden(): void + { + // 1. Arrange + $user = $this->createUser('user@mail.com'); + $this->client->loginUser($user); + $organization = $this->createOrganization('Org to Delete'); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function test_delete_nonexistent_organization_not_found(): void + { + // 1. Arrange + $admin = $this->createUser('admin@user.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + // 2. Act + $this->client->request('POST', '/organization/delete/99999'); // Assuming + // 3. Assert + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function test_delete_organization_with_dependencies(): void + { + // 1. Arrange + $admin = $this->createUser('user@admin.com', ['ROLE_SUPER_ADMIN']); + $this->client->loginUser($admin); + $organization = $this->createOrganization('Org with Deps'); + $app = $this->createApp('Dependent App'); + $role = $this->createRole('ROLE_USER'); + $uoLink = $this->createUOLink($admin, $organization); + $uoaLink = $this->createUOALink($uoLink, $app, $role); + // 2. Act + $this->client->request('POST', '/organization/delete/' . $organization->getId()); + // 3. Assert + self::assertResponseRedirects('/organization/'); + $this->client->followRedirect(); + + self::assertSelectorTextContains('body', 'Aucune organisation trouvée'); + //link should be deactivated, not deleted + self::assertCount(1, $this->entityManager->getRepository(Apps::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(Roles::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UsersOrganizations::class)->findAll()); + self::assertCount(1, $this->entityManager->getRepository(UserOrganizatonApp::class)->findAll()); + self::assertTrue($this->entityManager->getRepository(Organizations::class)->find($organization->getId())->isDeleted()); + self::assertFalse($this->entityManager->getRepository(UserOrganizatonApp::class)->find($uoLink->getId())->isActive()); + self::assertFalse($this->entityManager->getRepository(UserOrganizatonApp::class)->find($uoaLink->getId())->isActive()); + self::assertSelectorNotExists('#tabulator-org'); + } + + //endregion + + +} diff --git a/tests/Functional/AbstractFunctionalTest.php b/tests/Functional/AbstractFunctionalTest.php index a0d3542..4542a3b 100644 --- a/tests/Functional/AbstractFunctionalTest.php +++ b/tests/Functional/AbstractFunctionalTest.php @@ -66,7 +66,6 @@ abstract class AbstractFunctionalTest extends WebTestCase $org->setNumber(100 + rand(1, 900)); // Example number $org->setAddress('123 ' . $name . ' St'); // Example address $org->setLogoUrl('https://example.com/org_logo.png'); - // Add other required fields if Organizations has non-nullable columns $this->entityManager->persist($org); $this->entityManager->flush(); @@ -115,4 +114,9 @@ abstract class AbstractFunctionalTest extends WebTestCase $this->entityManager->flush(); return $notification; } + + protected function countEntities(string $entityClass): int + { + return $this->entityManager->getRepository($entityClass)->count([]); + } } \ No newline at end of file