From 9ccad77bf0536a10a2e2d19fddfc43e7ef7abaa4 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 8 Feb 2026 21:38:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Messaging=20asynchrone=20fiable=20avec?= =?UTF-8?q?=20retry,=20dead-letter=20et=20m=C3=A9triques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/.env | 5 + backend/config/packages/messenger.yaml | 18 +- backend/config/packages/test/messenger.yaml | 5 + backend/config/services.yaml | 20 ++ backend/migrations/Version20260208100000.php | 47 ++++ .../Exception/CompteNonActivableException.php | 16 +- .../CreateTestActivationTokenCommand.php | 61 +++++ .../Messaging/SendLockoutAlertHandler.php | 2 +- .../Handler/AuditAuthenticationHandler.php | 8 +- .../Console/ReviewFailedMessagesCommand.php | 222 ++++++++++++++++++ .../Messenger/ClassNameHelper.php | 18 ++ .../Messenger/DeadLetterAlertHandler.php | 71 ++++++ .../Messenger/FibonacciRetryStrategy.php | 76 ++++++ .../Messenger/MessengerMetricsMiddleware.php | 74 ++++++ .../Monitoring/HealthCheckController.php | 9 +- .../Monitoring/MessengerMetricsCollector.php | 60 +++++ .../MessengerMetricsCollectorInterface.php | 10 + .../Monitoring/MetricsController.php | 4 +- .../emails/dead_letter_alert.html.twig | 104 ++++++++ .../Domain/Model/User/UserTest.php | 1 + .../CreateTestActivationTokenCommandTest.php | 199 ++++++++++++++++ .../ReviewFailedMessagesCommandTest.php | 131 +++++++++++ .../Messenger/DeadLetterAlertHandlerTest.php | 124 ++++++++++ .../Messenger/FibonacciRetryStrategyTest.php | 134 +++++++++++ .../MessengerMetricsMiddlewareTest.php | 165 +++++++++++++ .../MessengerMetricsCollectorTest.php | 86 +++++++ .../Monitoring/MetricsControllerTest.php | 13 +- compose.yaml | 39 +++ monitoring/prometheus/alerts.yml | 17 ++ 29 files changed, 1706 insertions(+), 33 deletions(-) create mode 100644 backend/config/packages/test/messenger.yaml create mode 100644 backend/migrations/Version20260208100000.php create mode 100644 backend/src/Shared/Infrastructure/Console/ReviewFailedMessagesCommand.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/ClassNameHelper.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/DeadLetterAlertHandler.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/FibonacciRetryStrategy.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/MessengerMetricsMiddleware.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollector.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorInterface.php create mode 100644 backend/templates/emails/dead_letter_alert.html.twig create mode 100644 backend/tests/Unit/Administration/Infrastructure/Console/CreateTestActivationTokenCommandTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Console/ReviewFailedMessagesCommandTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/DeadLetterAlertHandlerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/FibonacciRetryStrategyTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/MessengerMetricsMiddlewareTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/MessengerMetricsCollectorTest.php 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 # =============================================================================