Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ jobs:
run: |
task composer -- install

- name: Run database migrations
run: |
task console -- --env=test doctrine:migrations:migrate --no-interaction

- name: Test suite
run: |
task test
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ See [keep a changelog] for information about writing changes to this log.
## [Unreleased]

- [PR-75](https://github.com/itk-dev/event-database-imports/pull/75) Added test infrastructure (PHPUnit 12, DAMA, Liip)
- [PR-76](https://github.com/itk-dev/event-database-imports/pull/76) Added security and admin test coverage

## [1.2.3] - 2026-03-29

Expand Down
7 changes: 5 additions & 2 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ doctrine:
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# Use the dev database for tests and rely on DAMA's transaction
# rollback (dama/doctrine-test-bundle) to isolate each test. The
# db user in the local docker stack does not have CREATE DATABASE
# privileges, so a separate _test database is impractical.
dbname_suffix: ''

when@prod:
doctrine:
Expand Down
4 changes: 4 additions & 0 deletions config/packages/test/liip_test_fixtures.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
liip_test_fixtures:
# DAMA/doctrine-test-bundle isolates each test via a rolled-back
# transaction on the existing schema; Liip must not drop/recreate it.
keep_database_and_schema: true
7 changes: 6 additions & 1 deletion config/packages/web_profiler.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ when@test:
intercept_redirects: false

framework:
profiler: { collect: false }
# `collect_serializer_data: true` is not needed in tests, but
# `collect: true` is required so MailerAssertionsTrait can read the
# message logger events via the profiler subsystem.
profiler:
collect: true
only_exceptions: false
10 changes: 10 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,13 @@ services:
hosts: ['%env(INDEXING_URL)%']

Elastic\Elasticsearch\ClientBuilder: ~

when@test:
services:
_defaults:
autowire: true
autoconfigure: true

App\Tests\Fixtures\:
resource: '../tests/Fixtures/'
tags: ['doctrine.fixture.orm']
73 changes: 73 additions & 0 deletions tests/Fixtures/TestEventFixtures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace App\Tests\Fixtures;

use App\DataFixtures\OrganizationFixtures;
use App\Entity\Event;
use App\Entity\Organization;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;

final class TestEventFixtures extends Fixture implements DependentFixtureInterface
{
public const EVENT_ORG_A_1 = 'test-event-org-a-1';
public const EVENT_ORG_A_2 = 'test-event-org-a-2';
public const EVENT_ORG_B_1 = 'test-event-org-b-1';
public const EVENT_ORPHAN = 'test-event-orphan';

public function load(ObjectManager $manager): void
{
$orgA = $this->getReference(OrganizationFixtures::AAKB, Organization::class);
$orgB = $this->getReference(OrganizationFixtures::DOKK1, Organization::class);

$this->addReference(
self::EVENT_ORG_A_1,
$this->createEvent($manager, 'Org A Event 1', $orgA),
);
$this->addReference(
self::EVENT_ORG_A_2,
$this->createEvent($manager, 'Org A Event 2', $orgA),
);
$this->addReference(
self::EVENT_ORG_B_1,
$this->createEvent($manager, 'Org B Event 1', $orgB),
);
$this->addReference(
self::EVENT_ORPHAN,
$this->createEvent($manager, 'Orphan Event', null),
);

$manager->flush();
}

public function getDependencies(): array
{
return [
OrganizationFixtures::class,
TestUserFixtures::class,
];
}

private function createEvent(ObjectManager $manager, string $title, ?Organization $organization): Event
{
$event = new Event();
$event->setTitle($title)
->setDescription('Lorem ipsum dolor sit amet.')
->setExcerpt('Lorem ipsum')
->setUrl('https://example.com/'.urlencode($title))
->setPublicAccess(true)
->setUpdatedBy('test')
->setEditable(true);

if (null !== $organization) {
$event->setOrganization($organization);
}

$manager->persist($event);

return $event;
}
}
69 changes: 65 additions & 4 deletions tests/Functional/AbstractAdminTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace App\Tests\Functional;

use App\Entity\User;
use App\Repository\UserRepository;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use Liip\TestFixturesBundle\Services\DatabaseToolCollection;
use Liip\TestFixturesBundle\Services\DatabaseTools\AbstractDatabaseTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
Expand Down Expand Up @@ -35,15 +37,74 @@ protected function loadFixtures(array $classes): void

protected function loginAs(string $email): KernelBrowser
{
$repository = static::getContainer()->get(UserRepository::class);
$user = $repository->findOneBy(['mail' => $email]);
$this->client->loginUser($this->findUser($email));

return $this->client;
}

protected function findUser(string $email): User
{
$user = static::getContainer()->get(UserRepository::class)->findOneBy(['mail' => $email]);

if (null === $user) {
throw new \RuntimeException(sprintf('User "%s" not found. Did you load TestUserFixtures?', $email));
}

$this->client->loginUser($user);
return $user;
}

/**
* Build an EasyAdmin URL via the pretty URL routes generated by the
* EasyAdmin route loader.
*
* EasyAdmin registers routes named `admin_<snake_name>_<action>` for each
* CRUD controller and redirects query-string URLs to these pretty URLs.
* Generating the pretty URL directly avoids the 302 round-trip in tests.
*
* We avoid the AdminUrlGenerator service because it depends on the current
* request context which is not available before the test client issues a request.
*
* @param class-string $crudControllerFqcn
* @param array<string, scalar|null> $extra
*/
protected function adminUrl(string $crudControllerFqcn, string $action = Action::INDEX, array $extra = []): string
{
$routeName = sprintf('admin_%s_%s', $this->routeSlugFor($crudControllerFqcn), $action);

return $this->client;
$params = [];
if (isset($extra['entityId'])) {
$params['entityId'] = $extra['entityId'];
unset($extra['entityId']);
}

$url = static::getContainer()->get('router')->generate($routeName, $params);

// Append any remaining query parameters (filters, referrer, etc.).
$filtered = array_filter(
$extra,
static fn ($value) => null !== $value,
);
if ([] !== $filtered) {
$separator = str_contains($url, '?') ? '&' : '?';
$url .= $separator.http_build_query($filtered);
}

return $url;
}

/**
* Derive the snake_case slug EasyAdmin uses in its route names from the
* CRUD controller class name, e.g. `MyEventCrudController` -> `my_event`.
*
* @param class-string $crudControllerFqcn
*/
private function routeSlugFor(string $crudControllerFqcn): string
{
$shortName = substr((string) strrchr($crudControllerFqcn, '\\'), 1);
$shortName = preg_replace('/(CrudController|Controller)$/', '', $shortName) ?? $shortName;

$snake = preg_replace('/(?<!^)([A-Z])/', '_$1', $shortName) ?? $shortName;

return strtolower($snake);
}
}
100 changes: 100 additions & 0 deletions tests/Functional/Admin/CrudSmokeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Admin;

use App\Controller\Admin\AddressCrudController;
use App\Controller\Admin\EmbedImageController;
use App\Controller\Admin\EmbedOccurrenceCrudController;
use App\Controller\Admin\FeedCrudController;
use App\Controller\Admin\FeedItemCrudController;
use App\Controller\Admin\LocationCrudController;
use App\Controller\Admin\MyEventCrudController;
use App\Controller\Admin\MyOrganizationCrudController;
use App\Controller\Admin\TagCrudController;
use App\Controller\Admin\VocabularyCrudController;
use App\DataFixtures\OrganizationFixtures;
use App\Tests\Fixtures\TestUserFixtures;
use App\Tests\Functional\AbstractAdminTestCase;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use PHPUnit\Framework\Attributes\DataProvider;

/**
* Role-gating smoke coverage for CRUD controllers whose only app-specific
* behaviour at the URL level is "which roles are allowed to see this page".
* Consolidates what were previously nine thin per-entity test classes into
* one parameterized matrix: if you add a CRUD controller with non-trivial
* app behaviour (cross-org filtering, entity-level voters beyond role
* checks, custom actions), give it its own test file alongside EventCrudTest
* and OrganizationCrudTest instead of adding a row here.
*/
final class CrudSmokeTest extends AbstractAdminTestCase
{
private const EXPECT_SUCCESS = 'success';
private const EXPECT_DENIED = 'denied';

protected function setUp(): void
{
parent::setUp();

$this->loadFixtures([
OrganizationFixtures::class,
TestUserFixtures::class,
]);
}

/**
* @param 'success'|'denied' $expected
*/
#[DataProvider('provideRoleMatrix')]
public function testRoleMatrix(string $controller, string $action, string $email, string $expected): void
{
$this->loginAs($email);
$this->client->request('GET', $this->adminUrl($controller, $action));

if (self::EXPECT_SUCCESS === $expected) {
$this->assertResponseIsSuccessful();

return;
}

$status = $this->client->getResponse()->getStatusCode();
$this->assertContains(
$status,
[302, 403],
sprintf('Expected access denied (302 or 403), got %d', $status),
);
}

/**
* @return iterable<string, array{string, string, string, 'success'|'denied'}>
*/
public static function provideRoleMatrix(): iterable
{
// Editors can browse address / location / tag index + new pages.
yield 'editor: address index' => [AddressCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'editor: address new' => [AddressCrudController::class, Action::NEW, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'editor: location index' => [LocationCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'editor: location new' => [LocationCrudController::class, Action::NEW, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'editor: tag index' => [TagCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'org editor: tag index' => [TagCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS];

// Org editors get their own "My*" screens.
yield 'org editor: my event index' => [MyEventCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS];
yield 'org editor: my event new' => [MyEventCrudController::class, Action::NEW, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS];
yield 'org editor: my organization index' => [MyOrganizationCrudController::class, Action::INDEX, TestUserFixtures::ORG_EDITOR_A_EMAIL, self::EXPECT_SUCCESS];

// Embed admin screens available to editors.
yield 'editor: embed image index' => [EmbedImageController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];
yield 'editor: embed occurrence index' => [EmbedOccurrenceCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_SUCCESS];

// Feed / feed-item / vocabulary are admin-only.
yield 'admin: feed index' => [FeedCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS];
yield 'editor denied: feed index' => [FeedCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED];
yield 'admin: feed item index' => [FeedItemCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS];
yield 'editor denied: feed item index' => [FeedItemCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED];
yield 'admin: vocabulary index' => [VocabularyCrudController::class, Action::INDEX, TestUserFixtures::ADMIN_EMAIL, self::EXPECT_SUCCESS];
yield 'editor denied: vocabulary index' => [VocabularyCrudController::class, Action::INDEX, TestUserFixtures::EDITOR_EMAIL, self::EXPECT_DENIED];
}
}
Loading
Loading