diff --git a/backend/.env b/backend/.env index 1b2b090..2c0ee93 100644 --- a/backend/.env +++ b/backend/.env @@ -53,6 +53,11 @@ MEILISEARCH_API_KEY=masterKey MAILER_DSN=smtp://mailpit:1025 ###< symfony/mailer ### +###> messenger-alerting ### +# Admin email for dead-letter queue alerts +ADMIN_ALERT_EMAIL=admin@classeo.local +###< messenger-alerting ### + ###> symfony/routing ### # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index fc3b0c1..fcb7a38 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -1,6 +1,5 @@ framework: messenger: - # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. failure_transport: failed # Three buses: Command, Query, Event (CQRS + Event-driven) @@ -26,9 +25,9 @@ framework: middleware: - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware + - App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware transports: - # https://symfony.com/doc/current/messenger.html#transport-configuration async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: @@ -39,14 +38,17 @@ framework: messages: binding_keys: ['#'] retry_strategy: - max_retries: 3 - delay: 1000 - multiplier: 2 - max_delay: 60000 + service: App\Shared\Infrastructure\Messenger\FibonacciRetryStrategy failed: dsn: 'doctrine://default?queue_name=failed' routing: - # Route your messages to the transports - # 'App\Message\YourMessage': async + # Email events → async (non-blocking API responses) + App\Administration\Domain\Event\UtilisateurInvite: async + App\Administration\Domain\Event\InvitationRenvoyee: async + App\Administration\Domain\Event\PasswordResetTokenGenerated: async + App\Administration\Domain\Event\CompteActive: async + App\Administration\Domain\Event\MotDePasseChange: async + # CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert) + # ConnexionReussie, ConnexionEchouee: sync (audit-only, no email) diff --git a/backend/config/packages/test/messenger.yaml b/backend/config/packages/test/messenger.yaml new file mode 100644 index 0000000..bc57acb --- /dev/null +++ b/backend/config/packages/test/messenger.yaml @@ -0,0 +1,5 @@ +framework: + messenger: + transports: + async: + dsn: 'sync://' diff --git a/backend/config/services.yaml b/backend/config/services.yaml index a6117eb..07be8dc 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -249,6 +249,26 @@ services: tags: - { name: monolog.processor } + # ============================================================================= + # Messenger & Async (Story 2.5b) + # ============================================================================= + + # Fibonacci retry strategy for async transport + App\Shared\Infrastructure\Messenger\FibonacciRetryStrategy: ~ + + # Dead-letter alert: sends admin email when message exhausts all retries + App\Shared\Infrastructure\Messenger\DeadLetterAlertHandler: + arguments: + $adminEmail: '%env(ADMIN_ALERT_EMAIL)%' + tags: + - { name: kernel.event_listener, event: Symfony\Component\Messenger\Event\WorkerMessageFailedEvent } + + # Messenger metrics middleware (handled/failed counters) + App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware: ~ + + # Messenger queue metrics collector (messages waiting gauge) + App\Shared\Infrastructure\Monitoring\MessengerMetricsCollector: ~ + # ============================================================================= # Test environment overrides # ============================================================================= diff --git a/backend/migrations/Version20260208100000.php b/backend/migrations/Version20260208100000.php new file mode 100644 index 0000000..4f99552 --- /dev/null +++ b/backend/migrations/Version20260208100000.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS messenger_messages ( + id BIGSERIAL PRIMARY KEY, + body TEXT NOT NULL, + headers TEXT NOT NULL, + queue_name VARCHAR(190) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ DEFAULT NULL + ) + SQL); + + $this->addSql('CREATE INDEX IF NOT EXISTS idx_messenger_messages_queue_name ON messenger_messages(queue_name)'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_messenger_messages_available_at ON messenger_messages(available_at)'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_messenger_messages_delivered_at ON messenger_messages(delivered_at)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS messenger_messages'); + } +} diff --git a/backend/src/Administration/Domain/Exception/CompteNonActivableException.php b/backend/src/Administration/Domain/Exception/CompteNonActivableException.php index a3eeae5..c9bef85 100644 --- a/backend/src/Administration/Domain/Exception/CompteNonActivableException.php +++ b/backend/src/Administration/Domain/Exception/CompteNonActivableException.php @@ -14,18 +14,16 @@ final class CompteNonActivableException extends RuntimeException { public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self { - return new self(sprintf( - 'Le compte "%s" ne peut pas être activé car son statut est "%s".', - $userId, - $statut->value, - )); + return new self(match ($statut) { + StatutCompte::ACTIF => 'Ce compte est déjà actif.', + StatutCompte::SUSPENDU => 'Ce compte est suspendu. Veuillez contacter votre établissement.', + StatutCompte::ARCHIVE => 'Ce compte est archivé. Veuillez contacter votre établissement.', + default => sprintf('Ce compte ne peut pas être activé (statut : %s).', $statut->value), + }); } public static function carConsentementManquant(UserId $userId): self { - return new self(sprintf( - 'Le compte "%s" ne peut pas être activé : consentement parental manquant.', - $userId, - )); + return new self('Ce compte ne peut pas être activé : consentement parental manquant.'); } } diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php index c884925..26484df 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php @@ -5,14 +5,19 @@ declare(strict_types=1); namespace App\Administration\Infrastructure\Console; use App\Administration\Domain\Model\ActivationToken\ActivationToken; +use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken; 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\Repository\ActivationTokenRepository; +use App\Administration\Domain\Repository\PasswordResetTokenRepository; use App\Administration\Domain\Repository\UserRepository; use App\Shared\Domain\Clock; +use App\Shared\Domain\Tenant\TenantId; use App\Shared\Infrastructure\Tenant\TenantNotFoundException; use App\Shared\Infrastructure\Tenant\TenantRegistry; +use DateTimeImmutable; use function sprintf; @@ -22,6 +27,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Messenger\MessageBusInterface; #[AsCommand( name: 'app:dev:create-test-activation-token', @@ -31,9 +37,11 @@ final class CreateTestActivationTokenCommand extends Command { public function __construct( private readonly ActivationTokenRepository $activationTokenRepository, + private readonly PasswordResetTokenRepository $passwordResetTokenRepository, private readonly UserRepository $userRepository, private readonly TenantRegistry $tenantRegistry, private readonly Clock $clock, + private readonly MessageBusInterface $eventBus, ) { parent::__construct(); } @@ -154,6 +162,11 @@ final class CreateTestActivationTokenCommand extends Command if ($user !== null) { $io->note(sprintf('User "%s" already exists, reusing existing account.', $email)); + + // Active users get a password reset token instead of activation + if ($user->statut === StatutCompte::ACTIF) { + return $this->createPasswordResetToken($user, $email, $tenantId, $tenantSubdomain, $baseUrl, $now, $io); + } } else { $dateNaissance = $isMinor ? $now->modify('-13 years') // 13 ans = mineur @@ -207,4 +220,52 @@ final class CreateTestActivationTokenCommand extends Command return Command::SUCCESS; } + + private function createPasswordResetToken( + User $user, + string $email, + TenantId $tenantId, + string $tenantSubdomain, + string $baseUrl, + DateTimeImmutable $now, + SymfonyStyle $io, + ): int { + $io->warning('Le compte est déjà actif. Création d\'un token de réinitialisation de mot de passe à la place.'); + + $resetToken = PasswordResetToken::generate( + userId: (string) $user->id, + email: $email, + tenantId: $tenantId, + createdAt: $now, + ); + + $this->passwordResetTokenRepository->save($resetToken); + + foreach ($resetToken->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + $resetUrl = sprintf('%s/reset-password/%s', $baseUrl, $resetToken->tokenValue); + + $io->success('Password reset token created successfully!'); + + $io->table( + ['Property', 'Value'], + [ + ['User ID', (string) $user->id], + ['Email', $email], + ['Role', $user->role->value], + ['Tenant', $tenantSubdomain], + ['Status', $user->statut->value], + ['Token', $resetToken->tokenValue], + ['Expires', $resetToken->expiresAt->format('Y-m-d H:i:s')], + ] + ); + + $io->writeln(''); + $io->writeln(sprintf('Reset password URL: %s', $resetUrl, $resetUrl)); + $io->writeln(''); + + return Command::SUCCESS; + } } diff --git a/backend/src/Administration/Infrastructure/Messaging/SendLockoutAlertHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendLockoutAlertHandler.php index 3264f05..86593de 100644 --- a/backend/src/Administration/Infrastructure/Messaging/SendLockoutAlertHandler.php +++ b/backend/src/Administration/Infrastructure/Messaging/SendLockoutAlertHandler.php @@ -15,7 +15,7 @@ use Twig\Environment; * * @see Story 1.4 - T4: Email alerte lockout */ -#[AsMessageHandler] +#[AsMessageHandler(bus: 'event.bus')] final readonly class SendLockoutAlertHandler { public function __construct( diff --git a/backend/src/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandler.php b/backend/src/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandler.php index eb0e6cd..1ef8c9d 100644 --- a/backend/src/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandler.php +++ b/backend/src/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandler.php @@ -32,7 +32,7 @@ final readonly class AuditAuthenticationHandler /** * T4.1: Successful login. */ - #[AsMessageHandler] + #[AsMessageHandler(bus: 'event.bus')] public function handleConnexionReussie(ConnexionReussie $event): void { $this->auditLogger->logAuthentication( @@ -50,7 +50,7 @@ final readonly class AuditAuthenticationHandler /** * T4.2: Failed login. */ - #[AsMessageHandler] + #[AsMessageHandler(bus: 'event.bus')] public function handleConnexionEchouee(ConnexionEchouee $event): void { $this->auditLogger->logAuthentication( @@ -68,7 +68,7 @@ final readonly class AuditAuthenticationHandler /** * T4.3: Account temporarily locked. */ - #[AsMessageHandler] + #[AsMessageHandler(bus: 'event.bus')] public function handleCompteBloqueTemporairement(CompteBloqueTemporairement $event): void { $this->auditLogger->logAuthentication( @@ -86,7 +86,7 @@ final readonly class AuditAuthenticationHandler /** * T4.4: Password changed (via reset or update). */ - #[AsMessageHandler] + #[AsMessageHandler(bus: 'event.bus')] public function handleMotDePasseChange(MotDePasseChange $event): void { $this->auditLogger->logAuthentication( diff --git a/backend/src/Shared/Infrastructure/Console/ReviewFailedMessagesCommand.php b/backend/src/Shared/Infrastructure/Console/ReviewFailedMessagesCommand.php new file mode 100644 index 0000000..281b91b --- /dev/null +++ b/backend/src/Shared/Infrastructure/Console/ReviewFailedMessagesCommand.php @@ -0,0 +1,222 @@ +addArgument( + 'action', + InputArgument::OPTIONAL, + 'Action: list (default), show', + 'list', + ) + ->addOption( + 'id', + null, + InputOption::VALUE_REQUIRED, + 'Message ID (required for show)', + ) + ->addOption( + 'limit', + 'l', + InputOption::VALUE_REQUIRED, + 'Maximum number of messages to display', + '20', + ) + ->setHelp(<<<'HELP' + The %command.name% command lists and inspects failed messages. + + To retry or delete a failed message, use the built-in Symfony Messenger commands: + php bin/console messenger:failed:retry {id} + php bin/console messenger:failed:remove {id} + HELP); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $action */ + $action = $input->getArgument('action'); + + return match ($action) { + 'list' => $this->listMessages($input, $io), + 'show' => $this->showMessage($input, $io), + default => $this->invalidAction($action, $io), + }; + } + + private function listMessages(InputInterface $input, SymfonyStyle $io): int + { + /** @var string $limitOption */ + $limitOption = $input->getOption('limit'); + if (!is_numeric($limitOption) || (int) $limitOption < 1) { + $io->error('L\'option --limit doit être un entier positif.'); + + return Command::FAILURE; + } + $limit = (int) $limitOption; + + $rows = $this->connection->fetchAllAssociative( + 'SELECT id, headers, created_at FROM messenger_messages WHERE queue_name = :queue ORDER BY created_at DESC LIMIT :limit', + ['queue' => 'failed', 'limit' => $limit], + ); + + if ($rows === []) { + $io->success('Aucun message en echec.'); + + return Command::SUCCESS; + } + + $tableRows = []; + foreach ($rows as $row) { + $headersRaw = is_string($row['headers']) ? $row['headers'] : ''; + $headers = $this->decodeHeaders($headersRaw); + $type = $headers['type'] ?? 'inconnu'; + + $tableRows[] = [ + $row['id'], + ClassNameHelper::short($type), + $row['created_at'], + ]; + } + + $io->table(['ID', 'Type', 'Date'], $tableRows); + $io->info(count($rows) . ' message(s) en echec.'); + $io->note('Pour rejouer ou supprimer : messenger:failed:retry {id} / messenger:failed:remove {id}'); + + return Command::SUCCESS; + } + + private function showMessage(InputInterface $input, SymfonyStyle $io): int + { + $id = $this->requireId($input, $io); + if ($id === null) { + return Command::FAILURE; + } + + $row = $this->connection->fetchAssociative( + 'SELECT * FROM messenger_messages WHERE id = :id AND queue_name = :queue', + ['id' => $id, 'queue' => 'failed'], + ); + + if ($row === false) { + $io->error("Message #{$id} introuvable dans la dead-letter queue."); + + return Command::FAILURE; + } + + $headersRaw = is_string($row['headers']) ? $row['headers'] : ''; + $headers = $this->decodeHeaders($headersRaw); + $type = $headers['type'] ?? 'inconnu'; + + $io->title("Message #{$id}"); + $io->definitionList( + ['Type' => ClassNameHelper::short($type)], + ['Date de creation' => $row['created_at']], + ['Date de livraison' => $row['delivered_at'] ?? 'N/A'], + ); + + $io->section('Headers'); + $io->text(json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?: '{}'); + + $io->note('Pour rejouer : messenger:failed:retry ' . $id); + $io->note('Pour supprimer : messenger:failed:remove ' . $id); + + return Command::SUCCESS; + } + + private function invalidAction(string $action, SymfonyStyle $io): int + { + $io->error("Action inconnue : '{$action}'. Actions valides : list, show"); + + return Command::FAILURE; + } + + private function requireId(InputInterface $input, SymfonyStyle $io): ?int + { + /** @var string|null $idOption */ + $idOption = $input->getOption('id'); + if ($idOption === null) { + $io->error("L'option --id est requise pour cette action."); + + return null; + } + + if (!is_numeric($idOption) || (int) $idOption < 1) { + $io->error("L'option --id doit être un entier positif."); + + return null; + } + + return (int) $idOption; + } + + /** + * @return array + */ + private function decodeHeaders(string $json): array + { + $decoded = json_decode($json, true); + + if (!is_array($decoded)) { + return []; + } + + $result = []; + foreach ($decoded as $key => $value) { + $result[(string) $key] = is_string($value) ? $value : ''; + } + + return $result; + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/ClassNameHelper.php b/backend/src/Shared/Infrastructure/Messenger/ClassNameHelper.php new file mode 100644 index 0000000..7104d7f --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/ClassNameHelper.php @@ -0,0 +1,18 @@ +willRetry()) { + return; + } + + $envelope = $event->getEnvelope(); + $message = $envelope->getMessage(); + $throwable = $event->getThrowable(); + + $retryCount = RedeliveryStamp::getRetryCountFromEnvelope($envelope); + $errorMessage = mb_substr($throwable->getMessage(), 0, self::MAX_ERROR_LENGTH); + + $html = $this->twig->render('emails/dead_letter_alert.html.twig', [ + 'eventType' => $message::class, + 'retryCount' => $retryCount, + 'lastError' => $errorMessage, + 'transportName' => $event->getReceiverName(), + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($this->adminEmail) + ->subject('[Classeo] Message en dead-letter : ' . ClassNameHelper::short($message::class)) + ->html($html) + ->priority(Email::PRIORITY_HIGH); + + try { + $this->mailer->send($email); + } catch (Throwable $e) { + $this->logger->error('Failed to send dead-letter alert email.', [ + 'error' => $e->getMessage(), + 'messageType' => $message::class, + ]); + } + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/FibonacciRetryStrategy.php b/backend/src/Shared/Infrastructure/Messenger/FibonacciRetryStrategy.php new file mode 100644 index 0000000..0992ccd --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/FibonacciRetryStrategy.php @@ -0,0 +1,76 @@ + Fibonacci delays in milliseconds */ + private const array FIBONACCI_DELAYS = [1000, 1000, 2000, 3000, 5000, 8000, 13000]; + + /** @var list> */ + private const array PERMANENT_EXCEPTIONS = [ + InvalidArgumentException::class, + LogicException::class, + LoaderError::class, + ]; + + #[Override] + public function isRetryable(Envelope $message, ?Throwable $throwable = null): bool + { + $retries = RedeliveryStamp::getRetryCountFromEnvelope($message); + + if ($retries >= self::MAX_RETRIES) { + return false; + } + + if ($throwable === null) { + return true; + } + + return !$this->isPermanentError($throwable); + } + + #[Override] + public function getWaitingTime(Envelope $message, ?Throwable $throwable = null): int + { + $retries = RedeliveryStamp::getRetryCountFromEnvelope($message); + + return self::FIBONACCI_DELAYS[$retries] ?? self::FIBONACCI_DELAYS[self::MAX_RETRIES - 1]; + } + + private function isPermanentError(Throwable $throwable): bool + { + foreach (self::PERMANENT_EXCEPTIONS as $permanentClass) { + if ($throwable instanceof $permanentClass) { + return true; + } + } + + $previous = $throwable->getPrevious(); + if ($previous !== null) { + return $this->isPermanentError($previous); + } + + return false; + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/MessengerMetricsMiddleware.php b/backend/src/Shared/Infrastructure/Messenger/MessengerMetricsMiddleware.php new file mode 100644 index 0000000..42a945e --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/MessengerMetricsMiddleware.php @@ -0,0 +1,74 @@ +handledTotal = $registry->getOrRegisterCounter( + self::NAMESPACE, + 'messenger_messages_handled_total', + 'Total messages handled successfully', + ['message'], + ); + + $this->failedTotal = $registry->getOrRegisterCounter( + self::NAMESPACE, + 'messenger_messages_failed_total', + 'Total messages that failed', + ['message'], + ); + } + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + $messageLabel = ClassNameHelper::short($envelope->getMessage()::class); + + try { + $envelope = $stack->next()->handle($envelope, $stack); + } catch (Throwable $e) { + $this->safeIncrement($this->failedTotal, $messageLabel); + + throw $e; + } + + /** @var HandledStamp[] $handledStamps */ + $handledStamps = $envelope->all(HandledStamp::class); + if ($handledStamps !== []) { + $this->safeIncrement($this->handledTotal, $messageLabel); + } + + return $envelope; + } + + private function safeIncrement(Counter $counter, string $label): void + { + try { + $counter->inc([$label]); + } catch (Throwable) { + // Metrics storage failure must not break message handling + } + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php b/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php index bd3e17c..90a2b4f 100644 --- a/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php +++ b/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php @@ -33,12 +33,9 @@ final readonly class HealthCheckController { $checks = $this->healthChecker->checkAll(); - $allHealthy = !in_array(false, $checks, true); - $status = $allHealthy ? 'healthy' : 'unhealthy'; - - // Return 200 for healthy (instance is operational) - // Return 503 when unhealthy (core dependencies are down) - $httpStatus = $status === 'unhealthy' ? Response::HTTP_SERVICE_UNAVAILABLE : Response::HTTP_OK; + $healthy = !in_array(false, $checks, true); + $status = $healthy ? 'healthy' : 'unhealthy'; + $httpStatus = $healthy ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE; return new JsonResponse([ 'status' => $status, diff --git a/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollector.php b/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollector.php new file mode 100644 index 0000000..c12e306 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollector.php @@ -0,0 +1,60 @@ +messagesWaiting = $this->registry->getOrRegisterGauge( + self::NAMESPACE, + 'messenger_messages_waiting', + 'Number of messages waiting in transport queue', + ['transport'], + ); + } + + public function collect(): void + { + try { + $response = $this->httpClient->request( + 'GET', + $this->rabbitmqManagementUrl . '/api/queues/%2f/messages', + [ + 'auth_basic' => [$this->rabbitmqUser, $this->rabbitmqPassword], + 'timeout' => 2, + ], + ); + + if ($response->getStatusCode() === 200) { + $data = $response->toArray(); + $messageCount = $data['messages'] ?? 0; + $this->messagesWaiting->set((float) $messageCount, ['async']); + } + } catch (Throwable) { + // If RabbitMQ management API is unavailable, skip metric update + } + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorInterface.php b/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorInterface.php new file mode 100644 index 0000000..e38c5f9 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorInterface.php @@ -0,0 +1,10 @@ +healthMetrics->collect(); + $this->messengerMetrics->collect(); $renderer = new RenderTextFormat(); $metrics = $renderer->render($this->registry->getMetricFamilySamples()); diff --git a/backend/templates/emails/dead_letter_alert.html.twig b/backend/templates/emails/dead_letter_alert.html.twig new file mode 100644 index 0000000..6916e73 --- /dev/null +++ b/backend/templates/emails/dead_letter_alert.html.twig @@ -0,0 +1,104 @@ + + + + + + Alerte Dead-Letter - Classeo + + + +
+

Message en Dead-Letter

+
+ +
+
+

Un message a atteint la dead-letter queue après avoir épuisé toutes ses tentatives de retry.

+
+ +
+ Type d'événement + {{ eventType }} +
+ +
+ Nombre de tentatives + {{ retryCount }} +
+ +
+ Transport + {{ transportName }} +
+ +
+ Dernière erreur :
+ {{ lastError }} +
+ +

+ Utilisez la commande app:messenger:review-failed pour inspecter et rejouer les messages échoués. +

+
+ + + + diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php index 547dfd6..9c644bc 100644 --- a/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php +++ b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php @@ -94,6 +94,7 @@ final class UserTest extends TestCase $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); $this->expectException(CompteNonActivableException::class); + $this->expectExceptionMessage('Ce compte est déjà actif.'); $user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy); } diff --git a/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php b/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php new file mode 100644 index 0000000..d6223b4 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php @@ -0,0 +1,199 @@ + */ + 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 $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, + ); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Console/ReviewFailedMessagesCommandTest.php b/backend/tests/Unit/Shared/Infrastructure/Console/ReviewFailedMessagesCommandTest.php new file mode 100644 index 0000000..c4278e2 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Console/ReviewFailedMessagesCommandTest.php @@ -0,0 +1,131 @@ +connection = $this->createMock(Connection::class); + $command = new ReviewFailedMessagesCommand($this->connection); + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function listShowsSuccessWhenNoFailedMessages(): void + { + $this->connection->method('fetchAllAssociative')->willReturn([]); + + $this->commandTester->execute([]); + + self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + self::assertStringContainsString('Aucun message en echec', $this->commandTester->getDisplay()); + } + + #[Test] + public function listDisplaysMessagesTable(): void + { + $this->connection->method('fetchAllAssociative')->willReturn([ + ['id' => 1, 'headers' => '{"type":"App\\\\Test\\\\FooEvent"}', 'created_at' => '2026-02-08 10:00:00'], + ['id' => 2, 'headers' => '{"type":"App\\\\Test\\\\BarEvent"}', 'created_at' => '2026-02-08 11:00:00'], + ]); + + $this->commandTester->execute([]); + + $output = $this->commandTester->getDisplay(); + self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + self::assertStringContainsString('2 message(s) en echec', $output); + self::assertStringContainsString('FooEvent', $output); + self::assertStringContainsString('messenger:failed:retry', $output); + } + + #[Test] + public function listRejectsInvalidLimit(): void + { + $this->commandTester->execute(['--limit' => '0']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('entier positif', $this->commandTester->getDisplay()); + } + + #[Test] + public function listRejectsNegativeLimit(): void + { + $this->commandTester->execute(['--limit' => '-5']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + } + + #[Test] + public function showRequiresIdOption(): void + { + $this->commandTester->execute(['action' => 'show']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('--id est requise', $this->commandTester->getDisplay()); + } + + #[Test] + public function showRejectsNonNumericId(): void + { + $this->commandTester->execute(['action' => 'show', '--id' => 'abc']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('entier positif', $this->commandTester->getDisplay()); + } + + #[Test] + public function showReturnsFailureWhenMessageNotFound(): void + { + $this->connection->method('fetchAssociative')->willReturn(false); + + $this->commandTester->execute(['action' => 'show', '--id' => '999']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('introuvable', $this->commandTester->getDisplay()); + } + + #[Test] + public function showDisplaysMessageDetails(): void + { + $this->connection->method('fetchAssociative')->willReturn([ + 'id' => 42, + 'headers' => '{"type":"App\\\\Test\\\\FooEvent"}', + 'body' => '{}', + 'queue_name' => 'failed', + 'created_at' => '2026-02-08 10:00:00', + 'delivered_at' => null, + 'available_at' => '2026-02-08 10:00:00', + ]); + + $this->commandTester->execute(['action' => 'show', '--id' => '42']); + + $output = $this->commandTester->getDisplay(); + self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + self::assertStringContainsString('Message #42', $output); + self::assertStringContainsString('FooEvent', $output); + self::assertStringContainsString('messenger:failed:retry 42', $output); + } + + #[Test] + public function invalidActionReturnsFailure(): void + { + $this->commandTester->execute(['action' => 'explode']); + + self::assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('Action inconnue', $this->commandTester->getDisplay()); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/DeadLetterAlertHandlerTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/DeadLetterAlertHandlerTest.php new file mode 100644 index 0000000..065cd37 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/DeadLetterAlertHandlerTest.php @@ -0,0 +1,124 @@ +createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $logger = $this->createMock(LoggerInterface::class); + + $twig->expects($this->once()) + ->method('render') + ->with('emails/dead_letter_alert.html.twig', $this->callback( + static fn (array $params): bool => $params['eventType'] === stdClass::class + && $params['retryCount'] === 7 + && $params['lastError'] === 'SMTP timeout' + && $params['transportName'] === 'async', + )) + ->willReturn('alert'); + + $mailer->expects($this->once()) + ->method('send') + ->with($this->callback( + static fn (Email $email): bool => $email->getTo()[0]->getAddress() === 'admin@classeo.fr' + && str_contains($email->getSubject() ?? '', 'dead-letter'), + )); + + $handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr'); + + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]); + $event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('SMTP timeout')); + + $handler($event); + } + + #[Test] + public function itDoesNotSendAlertWhenMessageWillRetry(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $logger = $this->createMock(LoggerInterface::class); + + $mailer->expects($this->never())->method('send'); + $twig->expects($this->never())->method('render'); + + $handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr'); + + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(1)]); + $event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('SMTP timeout')); + $event->setForRetry(); + + $handler($event); + } + + #[Test] + public function itLogsErrorWhenMailerFailsInsteadOfThrowing(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $logger = $this->createMock(LoggerInterface::class); + + $twig->method('render')->willReturn('alert'); + $mailer->method('send')->willThrowException(new RuntimeException('SMTP connection refused')); + + $logger->expects($this->once()) + ->method('error') + ->with('Failed to send dead-letter alert email.', $this->callback( + static fn (array $context): bool => $context['error'] === 'SMTP connection refused' + && $context['messageType'] === stdClass::class, + )); + + $handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr'); + + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]); + $event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('Original error')); + + // Should NOT throw - the mailer failure is caught and logged + $handler($event); + } + + #[Test] + public function itTruncatesLongErrorMessages(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $logger = $this->createMock(LoggerInterface::class); + + $longError = str_repeat('x', 1000); + + $twig->expects($this->once()) + ->method('render') + ->with('emails/dead_letter_alert.html.twig', $this->callback( + static fn (array $params): bool => mb_strlen($params['lastError']) <= 500, + )) + ->willReturn('alert'); + + $mailer->method('send'); + + $handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr'); + + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]); + $event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException($longError)); + + $handler($event); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/FibonacciRetryStrategyTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/FibonacciRetryStrategyTest.php new file mode 100644 index 0000000..960b7cb --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/FibonacciRetryStrategyTest.php @@ -0,0 +1,134 @@ +strategy = new FibonacciRetryStrategy(); + } + + public function testIsRetryableForTransientError(): void + { + $envelope = new Envelope(new stdClass()); + $throwable = new TransportException('SMTP timeout'); + + $this->assertTrue($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsNotRetryableForPermanentError(): void + { + $envelope = new Envelope(new stdClass()); + $throwable = new LoaderError('Template not found'); + + $this->assertFalse($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsNotRetryableForInvalidArgumentException(): void + { + $envelope = new Envelope(new stdClass()); + $throwable = new InvalidArgumentException('Invalid email'); + + $this->assertFalse($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsNotRetryableForLogicException(): void + { + $envelope = new Envelope(new stdClass()); + $throwable = new LogicException('Logic error'); + + $this->assertFalse($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsNotRetryableWhenMaxRetriesReached(): void + { + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]); + $throwable = new TransportException('SMTP timeout'); + + $this->assertFalse($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsRetryableAtRetry6(): void + { + $envelope = new Envelope(new stdClass(), [new RedeliveryStamp(6)]); + $throwable = new TransportException('SMTP timeout'); + + $this->assertTrue($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsRetryableWithNullThrowable(): void + { + $envelope = new Envelope(new stdClass()); + + $this->assertTrue($this->strategy->isRetryable($envelope, null)); + } + + /** + * @dataProvider fibonacciSequenceProvider + */ + public function testFibonacciWaitingTimeSequence(int $retryCount, int $expectedDelayMs): void + { + $envelope = new Envelope(new stdClass()); + if ($retryCount > 0) { + $envelope = $envelope->with(new RedeliveryStamp($retryCount)); + } + + $this->assertSame($expectedDelayMs, $this->strategy->getWaitingTime($envelope)); + } + + /** + * @return iterable + */ + public static function fibonacciSequenceProvider(): iterable + { + yield 'retry 0 → 1s' => [0, 1000]; + yield 'retry 1 → 1s' => [1, 1000]; + yield 'retry 2 → 2s' => [2, 2000]; + yield 'retry 3 → 3s' => [3, 3000]; + yield 'retry 4 → 5s' => [4, 5000]; + yield 'retry 5 → 8s' => [5, 8000]; + yield 'retry 6 → 13s' => [6, 13000]; + } + + public function testIsRetryableWithRuntimeException(): void + { + $envelope = new Envelope(new stdClass()); + $throwable = new RuntimeException('Connection refused'); + + $this->assertTrue($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsRetryableWithWrappedTransientError(): void + { + $envelope = new Envelope(new stdClass()); + $inner = new TransportException('SMTP timeout'); + $throwable = new RuntimeException('Wrapped', 0, $inner); + + $this->assertTrue($this->strategy->isRetryable($envelope, $throwable)); + } + + public function testIsNotRetryableWithWrappedPermanentError(): void + { + $envelope = new Envelope(new stdClass()); + $inner = new LoaderError('Template not found'); + $throwable = new RuntimeException('Wrapped', 0, $inner); + + $this->assertFalse($this->strategy->isRetryable($envelope, $throwable)); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/MessengerMetricsMiddlewareTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/MessengerMetricsMiddlewareTest.php new file mode 100644 index 0000000..eea32db --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/MessengerMetricsMiddlewareTest.php @@ -0,0 +1,165 @@ +createMock(Counter::class); + $failedCounter = $this->createMock(Counter::class); + + // Label is now the message class name, not the handler name + $handledCounter->expects($this->once()) + ->method('inc') + ->with(['stdClass']); + + $failedCounter->expects($this->never())->method('inc'); + + $registry = $this->createRegistryMock($handledCounter, $failedCounter); + $middleware = new MessengerMetricsMiddleware($registry); + + $envelope = new Envelope(new stdClass()); + $returnedEnvelope = $envelope->with(new HandledStamp('result', 'App\\Test\\TestHandler')); + + $stack = $this->createStackReturning($returnedEnvelope); + + $middleware->handle($envelope, $stack); + } + + #[Test] + public function itIncrementsFailedCounterOnException(): void + { + $handledCounter = $this->createMock(Counter::class); + $failedCounter = $this->createMock(Counter::class); + + $handledCounter->expects($this->never())->method('inc'); + $failedCounter->expects($this->once()) + ->method('inc') + ->with(['stdClass']); + + $registry = $this->createRegistryMock($handledCounter, $failedCounter); + $middleware = new MessengerMetricsMiddleware($registry); + + $envelope = new Envelope(new stdClass()); + $stack = $this->createStackThrowing(new RuntimeException('fail')); + + $this->expectException(RuntimeException::class); + $middleware->handle($envelope, $stack); + } + + #[Test] + public function itDoesNotIncrementHandledWhenNoHandledStamps(): void + { + $handledCounter = $this->createMock(Counter::class); + $failedCounter = $this->createMock(Counter::class); + + $handledCounter->expects($this->never())->method('inc'); + $failedCounter->expects($this->never())->method('inc'); + + $registry = $this->createRegistryMock($handledCounter, $failedCounter); + $middleware = new MessengerMetricsMiddleware($registry); + + // No HandledStamp = async dispatch, no handler executed yet + $envelope = new Envelope(new stdClass()); + $stack = $this->createStackReturning($envelope); + + $middleware->handle($envelope, $stack); + } + + #[Test] + public function itSurvivesPrometheusStorageFailure(): void + { + $handledCounter = $this->createMock(Counter::class); + $failedCounter = $this->createMock(Counter::class); + + // Simulate Redis/Prometheus failure + $handledCounter->method('inc')->willThrowException(new RuntimeException('Redis connection refused')); + + $registry = $this->createRegistryMock($handledCounter, $failedCounter); + $middleware = new MessengerMetricsMiddleware($registry); + + $envelope = new Envelope(new stdClass()); + $returnedEnvelope = $envelope->with(new HandledStamp('result', 'App\\Test\\TestHandler')); + $stack = $this->createStackReturning($returnedEnvelope); + + // Should NOT throw - metrics failure is swallowed + $result = $middleware->handle($envelope, $stack); + self::assertSame($returnedEnvelope, $result); + } + + private function createRegistryMock(Counter $handledCounter, Counter $failedCounter): CollectorRegistry + { + $registry = $this->createMock(CollectorRegistry::class); + $registry->method('getOrRegisterCounter') + ->willReturnCallback( + static fn (string $ns, string $name): Counter => match (true) { + str_contains($name, 'handled') => $handledCounter, + str_contains($name, 'failed') => $failedCounter, + }, + ); + + return $registry; + } + + private function createStackReturning(Envelope $envelope): StackInterface + { + return new class($envelope) implements StackInterface { + public function __construct(private readonly Envelope $envelope) + { + } + + public function next(): MiddlewareInterface + { + return new class($this->envelope) implements MiddlewareInterface { + public function __construct(private readonly Envelope $envelope) + { + } + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $this->envelope; + } + }; + } + }; + } + + private function createStackThrowing(RuntimeException $exception): StackInterface + { + return new class($exception) implements StackInterface { + public function __construct(private readonly RuntimeException $exception) + { + } + + public function next(): MiddlewareInterface + { + return new class($this->exception) implements MiddlewareInterface { + public function __construct(private readonly RuntimeException $exception) + { + } + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + throw $this->exception; + } + }; + } + }; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorTest.php new file mode 100644 index 0000000..36a885a --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorTest.php @@ -0,0 +1,86 @@ +gauge = $this->createMock(Gauge::class); + + $registry = $this->createMock(CollectorRegistry::class); + $registry->method('getOrRegisterGauge')->willReturn($this->gauge); + + return new MessengerMetricsCollector($registry, $httpClient); + } + + #[Test] + public function itSetsGaugeFromRabbitMqResponse(): void + { + $response = new MockResponse(json_encode(['messages' => 5]), [ + 'http_code' => 200, + ]); + $collector = $this->createCollector(new MockHttpClient($response)); + + $this->gauge->expects(self::once()) + ->method('set') + ->with(5.0, ['async']); + + $collector->collect(); + } + + #[Test] + public function itSkipsGaugeUpdateOnNon200Response(): void + { + $response = new MockResponse('Not Found', [ + 'http_code' => 404, + ]); + $collector = $this->createCollector(new MockHttpClient($response)); + + $this->gauge->expects(self::never())->method('set'); + + $collector->collect(); + } + + #[Test] + public function itSkipsGaugeUpdateOnNetworkError(): void + { + $response = new MockResponse('', [ + 'error' => 'Connection refused', + ]); + $collector = $this->createCollector(new MockHttpClient($response)); + + $this->gauge->expects(self::never())->method('set'); + + $collector->collect(); + } + + #[Test] + public function itDefaultsToZeroWhenMessagesKeyMissing(): void + { + $response = new MockResponse(json_encode(['consumers' => 1]), [ + 'http_code' => 200, + ]); + $collector = $this->createCollector(new MockHttpClient($response)); + + $this->gauge->expects(self::once()) + ->method('set') + ->with(0.0, ['async']); + + $collector->collect(); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php index f3d8382..f17cbaa 100644 --- a/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Unit\Shared\Infrastructure\Monitoring; use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface; +use App\Shared\Infrastructure\Monitoring\MessengerMetricsCollectorInterface; use App\Shared\Infrastructure\Monitoring\MetricsController; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -48,8 +49,9 @@ final class MetricsControllerTest extends TestCase $registry->method('getMetricFamilySamples')->willReturn([]); $healthMetrics = new HealthMetricsCollectorStub(); + $messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class); - return new MetricsController($registry, $healthMetrics, $appEnv); + return new MetricsController($registry, $healthMetrics, $messengerMetrics, $appEnv); } #[Test] @@ -86,7 +88,8 @@ final class MetricsControllerTest extends TestCase $registry->method('getMetricFamilySamples')->willReturn([$sample]); $healthMetrics = new HealthMetricsCollectorStub(); - $controller = new MetricsController($registry, $healthMetrics); + $messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class); + $controller = new MetricsController($registry, $healthMetrics, $messengerMetrics); $request = Request::create('/metrics'); $response = $controller($request); @@ -124,7 +127,8 @@ final class MetricsControllerTest extends TestCase { $registry = $this->createMock(CollectorRegistry::class); $healthMetrics = new HealthMetricsCollectorStub(); - $controller = new MetricsController($registry, $healthMetrics, 'prod'); + $messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class); + $controller = new MetricsController($registry, $healthMetrics, $messengerMetrics, 'prod'); $request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']); $this->expectException(AccessDeniedHttpException::class); @@ -162,7 +166,8 @@ final class MetricsControllerTest extends TestCase $registry->method('getMetricFamilySamples')->willReturn([]); $healthMetrics = new HealthMetricsCollectorStub(); - $controller = new MetricsController($registry, $healthMetrics); + $messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class); + $controller = new MetricsController($registry, $healthMetrics, $messengerMetrics); $request = Request::create('/metrics'); $controller($request); diff --git a/compose.yaml b/compose.yaml index 02e373f..1d8102e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -182,6 +182,45 @@ services: start_period: 10s restart: unless-stopped + # ============================================================================= + # ASYNC WORKER - Symfony Messenger + # ============================================================================= + worker: + build: + context: ./backend + dockerfile: Dockerfile + target: dev + container_name: classeo_worker + env_file: + - ./backend/.env + environment: + APP_ENV: ${APP_ENV:-dev} + DATABASE_URL: postgresql://classeo:classeo@db:5432/classeo_master?serverVersion=18&charset=utf8 + REDIS_URL: redis://redis:6379 + MESSENGER_TRANSPORT_DSN: amqp://guest:guest@rabbitmq:5672/%2f/messages + MERCURE_URL: http://mercure/.well-known/mercure + MEILISEARCH_URL: http://meilisearch:7700 + MAILER_DSN: ${MAILER_DSN:-smtp://mailpit:1025} + command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M -vv + volumes: + - ./backend:/app:cached + depends_on: + rabbitmq: + condition: service_healthy + php: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "ps aux | grep 'messenger:consume' | grep -v grep"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 256M + restart: unless-stopped + # ============================================================================= # EMAIL TESTING - Mailpit # ============================================================================= diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml index 1f0d4f1..a542e48 100644 --- a/monitoring/prometheus/alerts.yml +++ b/monitoring/prometheus/alerts.yml @@ -84,6 +84,23 @@ groups: description: "Queue has {{ $value }} messages pending" runbook_url: "https://docs.classeo.local/runbooks/rabbitmq-backlog" + # ============================================================================= + # Messenger Queue Alerts + # ============================================================================= + - name: messenger_alerts + rules: + # Messenger queue backlog > 100 messages for 5 minutes + - alert: MessengerQueueBacklog + expr: classeo_messenger_messages_waiting{transport="async"} > 100 + for: 5m + labels: + severity: warning + team: platform + annotations: + summary: "File d'attente Messenger surchargee" + description: "{{ $value }} messages en attente depuis 5 minutes" + runbook_url: "https://docs.classeo.local/runbooks/messenger-backlog" + # ============================================================================= # Security Alerts # =============================================================================