Les événements métier (emails d'invitation, reset password, activation) bloquaient la réponse API en étant traités de manière synchrone. Ce commit route ces événements vers un transport AMQP asynchrone avec un worker dédié, garantissant des réponses API rapides et une gestion robuste des échecs. Le retry utilise une stratégie Fibonacci (1s, 1s, 2s, 3s, 5s, 8s, 13s) qui offre un bon compromis entre réactivité et protection des services externes. Les messages qui épuisent leurs tentatives arrivent dans une dead-letter queue Doctrine avec alerte email à l'admin. La commande console CreateTestActivationTokenCommand détecte désormais les comptes déjà actifs et génère un token de réinitialisation de mot de passe au lieu d'un token d'activation, évitant une erreur bloquante lors de la ré-invitation par un admin.
200 lines
7.3 KiB
PHP
200 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Console;
|
|
|
|
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\StatutCompte;
|
|
use App\Administration\Domain\Model\User\User;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Console\CreateTestActivationTokenCommand;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
use Symfony\Component\Messenger\Envelope;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
|
|
final class CreateTestActivationTokenCommandTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string SUBDOMAIN = 'ecole-alpha';
|
|
|
|
private InMemoryActivationTokenRepository $activationTokenRepository;
|
|
private InMemoryPasswordResetTokenRepository $passwordResetTokenRepository;
|
|
private InMemoryUserRepository $userRepository;
|
|
private InMemoryTenantRegistry $tenantRegistry;
|
|
private Clock $clock;
|
|
/** @var list<object> */
|
|
private array $dispatchedEvents = [];
|
|
private MessageBusInterface $eventBus;
|
|
private CommandTester $commandTester;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->activationTokenRepository = new InMemoryActivationTokenRepository();
|
|
$this->passwordResetTokenRepository = new InMemoryPasswordResetTokenRepository();
|
|
$this->userRepository = new InMemoryUserRepository();
|
|
$this->clock = new class implements Clock {
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-02-08 10:00:00');
|
|
}
|
|
};
|
|
|
|
$tenantId = InfraTenantId::fromString(self::TENANT_ID);
|
|
$this->tenantRegistry = new InMemoryTenantRegistry([
|
|
new TenantConfig($tenantId, self::SUBDOMAIN, 'postgresql://localhost/test'),
|
|
]);
|
|
|
|
$dispatchedEvents = &$this->dispatchedEvents;
|
|
$this->eventBus = new class($dispatchedEvents) implements MessageBusInterface {
|
|
/** @param list<object> $events */
|
|
public function __construct(private array &$events)
|
|
{
|
|
}
|
|
|
|
public function dispatch(object $message, array $stamps = []): Envelope
|
|
{
|
|
$this->events[] = $message;
|
|
|
|
return new Envelope($message);
|
|
}
|
|
};
|
|
|
|
$command = new CreateTestActivationTokenCommand(
|
|
$this->activationTokenRepository,
|
|
$this->passwordResetTokenRepository,
|
|
$this->userRepository,
|
|
$this->tenantRegistry,
|
|
$this->clock,
|
|
$this->eventBus,
|
|
);
|
|
|
|
$this->commandTester = new CommandTester($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesActivationTokenForNewUser(): void
|
|
{
|
|
$this->commandTester->execute([
|
|
'--email' => 'new@test.com',
|
|
'--role' => 'PARENT',
|
|
'--tenant' => self::SUBDOMAIN,
|
|
]);
|
|
|
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
|
|
|
$output = $this->commandTester->getDisplay();
|
|
self::assertStringContainsString('Test activation token created successfully', $output);
|
|
self::assertStringContainsString('Activation URL', $output);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesActivationTokenForExistingPendingUser(): void
|
|
{
|
|
$user = User::creer(
|
|
email: new Email('pending@test.com'),
|
|
role: Role::PARENT,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: 'École Test',
|
|
dateNaissance: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
$this->userRepository->save($user);
|
|
|
|
$this->commandTester->execute([
|
|
'--email' => 'pending@test.com',
|
|
'--role' => 'PARENT',
|
|
'--tenant' => self::SUBDOMAIN,
|
|
]);
|
|
|
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
|
|
|
$output = $this->commandTester->getDisplay();
|
|
self::assertStringContainsString('already exists', $output);
|
|
self::assertStringContainsString('Activation URL', $output);
|
|
self::assertStringNotContainsString('Reset password URL', $output);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesPasswordResetTokenForActiveUser(): void
|
|
{
|
|
$user = $this->createActiveUser('active@test.com');
|
|
$this->userRepository->save($user);
|
|
|
|
$this->commandTester->execute([
|
|
'--email' => 'active@test.com',
|
|
'--role' => 'PARENT',
|
|
'--tenant' => self::SUBDOMAIN,
|
|
]);
|
|
|
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
|
|
|
$output = $this->commandTester->getDisplay();
|
|
self::assertStringContainsString('déjà actif', $output);
|
|
self::assertStringContainsString('Password reset token created successfully', $output);
|
|
self::assertStringContainsString('Reset password URL', $output);
|
|
self::assertStringNotContainsString('Activation URL', $output);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDispatchesPasswordResetEventForActiveUser(): void
|
|
{
|
|
$user = $this->createActiveUser('active@test.com');
|
|
$this->userRepository->save($user);
|
|
|
|
$this->commandTester->execute([
|
|
'--email' => 'active@test.com',
|
|
'--role' => 'PARENT',
|
|
'--tenant' => self::SUBDOMAIN,
|
|
]);
|
|
|
|
self::assertCount(1, $this->dispatchedEvents);
|
|
self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]);
|
|
self::assertSame('active@test.com', $this->dispatchedEvents[0]->email);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNotDispatchEventsForNewUser(): void
|
|
{
|
|
$this->commandTester->execute([
|
|
'--email' => 'new@test.com',
|
|
'--role' => 'PARENT',
|
|
'--tenant' => self::SUBDOMAIN,
|
|
]);
|
|
|
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
|
self::assertCount(0, $this->dispatchedEvents);
|
|
}
|
|
|
|
private function createActiveUser(string $email): User
|
|
{
|
|
return User::reconstitute(
|
|
id: UserId::generate(),
|
|
email: new Email($email),
|
|
role: Role::PARENT,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: 'École Test',
|
|
statut: StatutCompte::ACTIF,
|
|
dateNaissance: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
hashedPassword: '$argon2id$hashed',
|
|
activatedAt: new DateTimeImmutable('2026-01-16 10:00:00'),
|
|
consentementParental: null,
|
|
);
|
|
}
|
|
}
|