feat: Messaging asynchrone fiable avec retry, dead-letter et métriques
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.
This commit is contained in:
@@ -53,6 +53,11 @@ MEILISEARCH_API_KEY=masterKey
|
|||||||
MAILER_DSN=smtp://mailpit:1025
|
MAILER_DSN=smtp://mailpit:1025
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
|
###> messenger-alerting ###
|
||||||
|
# Admin email for dead-letter queue alerts
|
||||||
|
ADMIN_ALERT_EMAIL=admin@classeo.local
|
||||||
|
###< messenger-alerting ###
|
||||||
|
|
||||||
###> symfony/routing ###
|
###> symfony/routing ###
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# 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
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
framework:
|
framework:
|
||||||
messenger:
|
messenger:
|
||||||
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
|
|
||||||
failure_transport: failed
|
failure_transport: failed
|
||||||
|
|
||||||
# Three buses: Command, Query, Event (CQRS + Event-driven)
|
# Three buses: Command, Query, Event (CQRS + Event-driven)
|
||||||
@@ -26,9 +25,9 @@ framework:
|
|||||||
middleware:
|
middleware:
|
||||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||||
|
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware
|
||||||
|
|
||||||
transports:
|
transports:
|
||||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
|
||||||
async:
|
async:
|
||||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||||
options:
|
options:
|
||||||
@@ -39,14 +38,17 @@ framework:
|
|||||||
messages:
|
messages:
|
||||||
binding_keys: ['#']
|
binding_keys: ['#']
|
||||||
retry_strategy:
|
retry_strategy:
|
||||||
max_retries: 3
|
service: App\Shared\Infrastructure\Messenger\FibonacciRetryStrategy
|
||||||
delay: 1000
|
|
||||||
multiplier: 2
|
|
||||||
max_delay: 60000
|
|
||||||
|
|
||||||
failed:
|
failed:
|
||||||
dsn: 'doctrine://default?queue_name=failed'
|
dsn: 'doctrine://default?queue_name=failed'
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
# Route your messages to the transports
|
# Email events → async (non-blocking API responses)
|
||||||
# 'App\Message\YourMessage': async
|
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)
|
||||||
|
|||||||
5
backend/config/packages/test/messenger.yaml
Normal file
5
backend/config/packages/test/messenger.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
transports:
|
||||||
|
async:
|
||||||
|
dsn: 'sync://'
|
||||||
@@ -249,6 +249,26 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: monolog.processor }
|
- { 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
|
# Test environment overrides
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
47
backend/migrations/Version20260208100000.php
Normal file
47
backend/migrations/Version20260208100000.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the messenger_messages table for the Doctrine failed transport.
|
||||||
|
*
|
||||||
|
* Symfony Messenger uses this table to persist messages that have exhausted
|
||||||
|
* all retries, enabling later inspection and replay.
|
||||||
|
* The queue_name column discriminates between transports (e.g. 'failed').
|
||||||
|
*/
|
||||||
|
final class Version20260208100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create messenger_messages table for dead-letter queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,18 +14,16 @@ final class CompteNonActivableException extends RuntimeException
|
|||||||
{
|
{
|
||||||
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
|
||||||
{
|
{
|
||||||
return new self(sprintf(
|
return new self(match ($statut) {
|
||||||
'Le compte "%s" ne peut pas être activé car son statut est "%s".',
|
StatutCompte::ACTIF => 'Ce compte est déjà actif.',
|
||||||
$userId,
|
StatutCompte::SUSPENDU => 'Ce compte est suspendu. Veuillez contacter votre établissement.',
|
||||||
$statut->value,
|
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
|
public static function carConsentementManquant(UserId $userId): self
|
||||||
{
|
{
|
||||||
return new self(sprintf(
|
return new self('Ce compte ne peut pas être activé : consentement parental manquant.');
|
||||||
'Le compte "%s" ne peut pas être activé : consentement parental manquant.',
|
|
||||||
$userId,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Infrastructure\Console;
|
namespace App\Administration\Infrastructure\Console;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
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\Email;
|
||||||
use App\Administration\Domain\Model\User\Role;
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
use App\Administration\Domain\Model\User\User;
|
use App\Administration\Domain\Model\User\User;
|
||||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||||
|
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
|
||||||
use App\Administration\Domain\Repository\UserRepository;
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -22,6 +27,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:dev:create-test-activation-token',
|
name: 'app:dev:create-test-activation-token',
|
||||||
@@ -31,9 +37,11 @@ final class CreateTestActivationTokenCommand extends Command
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActivationTokenRepository $activationTokenRepository,
|
private readonly ActivationTokenRepository $activationTokenRepository,
|
||||||
|
private readonly PasswordResetTokenRepository $passwordResetTokenRepository,
|
||||||
private readonly UserRepository $userRepository,
|
private readonly UserRepository $userRepository,
|
||||||
private readonly TenantRegistry $tenantRegistry,
|
private readonly TenantRegistry $tenantRegistry,
|
||||||
private readonly Clock $clock,
|
private readonly Clock $clock,
|
||||||
|
private readonly MessageBusInterface $eventBus,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -154,6 +162,11 @@ final class CreateTestActivationTokenCommand extends Command
|
|||||||
|
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
$io->note(sprintf('User "%s" already exists, reusing existing account.', $email));
|
$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 {
|
} else {
|
||||||
$dateNaissance = $isMinor
|
$dateNaissance = $isMinor
|
||||||
? $now->modify('-13 years') // 13 ans = mineur
|
? $now->modify('-13 years') // 13 ans = mineur
|
||||||
@@ -207,4 +220,52 @@ final class CreateTestActivationTokenCommand extends Command
|
|||||||
|
|
||||||
return Command::SUCCESS;
|
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('<info>Reset password URL:</info> <href=%s>%s</>', $resetUrl, $resetUrl));
|
||||||
|
$io->writeln('');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Twig\Environment;
|
|||||||
*
|
*
|
||||||
* @see Story 1.4 - T4: Email alerte lockout
|
* @see Story 1.4 - T4: Email alerte lockout
|
||||||
*/
|
*/
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
final readonly class SendLockoutAlertHandler
|
final readonly class SendLockoutAlertHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final readonly class AuditAuthenticationHandler
|
|||||||
/**
|
/**
|
||||||
* T4.1: Successful login.
|
* T4.1: Successful login.
|
||||||
*/
|
*/
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
public function handleConnexionReussie(ConnexionReussie $event): void
|
public function handleConnexionReussie(ConnexionReussie $event): void
|
||||||
{
|
{
|
||||||
$this->auditLogger->logAuthentication(
|
$this->auditLogger->logAuthentication(
|
||||||
@@ -50,7 +50,7 @@ final readonly class AuditAuthenticationHandler
|
|||||||
/**
|
/**
|
||||||
* T4.2: Failed login.
|
* T4.2: Failed login.
|
||||||
*/
|
*/
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
||||||
{
|
{
|
||||||
$this->auditLogger->logAuthentication(
|
$this->auditLogger->logAuthentication(
|
||||||
@@ -68,7 +68,7 @@ final readonly class AuditAuthenticationHandler
|
|||||||
/**
|
/**
|
||||||
* T4.3: Account temporarily locked.
|
* T4.3: Account temporarily locked.
|
||||||
*/
|
*/
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
public function handleCompteBloqueTemporairement(CompteBloqueTemporairement $event): void
|
public function handleCompteBloqueTemporairement(CompteBloqueTemporairement $event): void
|
||||||
{
|
{
|
||||||
$this->auditLogger->logAuthentication(
|
$this->auditLogger->logAuthentication(
|
||||||
@@ -86,7 +86,7 @@ final readonly class AuditAuthenticationHandler
|
|||||||
/**
|
/**
|
||||||
* T4.4: Password changed (via reset or update).
|
* T4.4: Password changed (via reset or update).
|
||||||
*/
|
*/
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
public function handleMotDePasseChange(MotDePasseChange $event): void
|
public function handleMotDePasseChange(MotDePasseChange $event): void
|
||||||
{
|
{
|
||||||
$this->auditLogger->logAuthentication(
|
$this->auditLogger->logAuthentication(
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Messenger\ClassNameHelper;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function is_array;
|
||||||
|
use function is_numeric;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
use const JSON_PRETTY_PRINT;
|
||||||
|
use const JSON_UNESCAPED_UNICODE;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews and inspects failed messages in the dead-letter queue.
|
||||||
|
*
|
||||||
|
* Provides list and show subcommands for failed messages
|
||||||
|
* stored in the messenger_messages table (queue_name = 'failed').
|
||||||
|
*
|
||||||
|
* For retry/delete operations, use the built-in Symfony commands:
|
||||||
|
* - messenger:failed:retry
|
||||||
|
* - messenger:failed:remove
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:messenger:review-failed',
|
||||||
|
description: 'List or inspect failed messages in the dead-letter queue',
|
||||||
|
)]
|
||||||
|
final class ReviewFailedMessagesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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 <info>%command.name%</info> command lists and inspects failed messages.
|
||||||
|
|
||||||
|
To retry or delete a failed message, use the built-in Symfony Messenger commands:
|
||||||
|
<info>php bin/console messenger:failed:retry {id}</info>
|
||||||
|
<info>php bin/console messenger:failed:remove {id}</info>
|
||||||
|
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<string, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the short (unqualified) class name from a FQCN.
|
||||||
|
*/
|
||||||
|
final class ClassNameHelper
|
||||||
|
{
|
||||||
|
public static function short(string $fqcn): string
|
||||||
|
{
|
||||||
|
$parts = explode('\\', $fqcn);
|
||||||
|
|
||||||
|
return end($parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
|
||||||
|
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Throwable;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an admin alert email when a message exhausts all retries
|
||||||
|
* and lands in the dead-letter queue.
|
||||||
|
*
|
||||||
|
* The alert contains event type, attempt count, timestamps, and last error
|
||||||
|
* but never exposes PII (no user emails, no tokens).
|
||||||
|
*/
|
||||||
|
final readonly class DeadLetterAlertHandler
|
||||||
|
{
|
||||||
|
private const int MAX_ERROR_LENGTH = 500;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
private Environment $twig,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $adminEmail,
|
||||||
|
private string $fromEmail = 'noreply@classeo.fr',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(WorkerMessageFailedEvent $event): void
|
||||||
|
{
|
||||||
|
if ($event->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Retry\RetryStrategyInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
|
||||||
|
use Throwable;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fibonacci-based retry strategy for Symfony Messenger.
|
||||||
|
*
|
||||||
|
* Delays: 1s, 1s, 2s, 3s, 5s, 8s, 13s (~33s total).
|
||||||
|
* Transient errors (SMTP timeout, network) trigger retries.
|
||||||
|
* Permanent errors (invalid template, bad argument) reject immediately.
|
||||||
|
*/
|
||||||
|
final readonly class FibonacciRetryStrategy implements RetryStrategyInterface
|
||||||
|
{
|
||||||
|
private const int MAX_RETRIES = 7;
|
||||||
|
|
||||||
|
/** @var list<int> Fibonacci delays in milliseconds */
|
||||||
|
private const array FIBONACCI_DELAYS = [1000, 1000, 2000, 3000, 5000, 8000, 13000];
|
||||||
|
|
||||||
|
/** @var list<class-string<Throwable>> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use Prometheus\CollectorRegistry;
|
||||||
|
use Prometheus\Counter;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||||
|
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that counts handled and failed messages for Prometheus.
|
||||||
|
*
|
||||||
|
* Increments messenger_messages_handled_total on success and
|
||||||
|
* messenger_messages_failed_total on failure, labeled by message type.
|
||||||
|
*/
|
||||||
|
final class MessengerMetricsMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private const string NAMESPACE = 'classeo';
|
||||||
|
|
||||||
|
private Counter $handledTotal;
|
||||||
|
private Counter $failedTotal;
|
||||||
|
|
||||||
|
public function __construct(CollectorRegistry $registry)
|
||||||
|
{
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,12 +33,9 @@ final readonly class HealthCheckController
|
|||||||
{
|
{
|
||||||
$checks = $this->healthChecker->checkAll();
|
$checks = $this->healthChecker->checkAll();
|
||||||
|
|
||||||
$allHealthy = !in_array(false, $checks, true);
|
$healthy = !in_array(false, $checks, true);
|
||||||
$status = $allHealthy ? 'healthy' : 'unhealthy';
|
$status = $healthy ? 'healthy' : 'unhealthy';
|
||||||
|
$httpStatus = $healthy ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE;
|
||||||
// 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;
|
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Monitoring;
|
||||||
|
|
||||||
|
use Prometheus\CollectorRegistry;
|
||||||
|
use Prometheus\Gauge;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects Messenger queue metrics for Prometheus.
|
||||||
|
*
|
||||||
|
* Exposes a gauge for the number of messages waiting in the async transport.
|
||||||
|
* Uses the RabbitMQ management API to query queue depth.
|
||||||
|
*/
|
||||||
|
final class MessengerMetricsCollector implements MessengerMetricsCollectorInterface
|
||||||
|
{
|
||||||
|
private const string NAMESPACE = 'classeo';
|
||||||
|
|
||||||
|
private Gauge $messagesWaiting;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CollectorRegistry $registry,
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly string $rabbitmqManagementUrl = 'http://rabbitmq:15672',
|
||||||
|
private readonly string $rabbitmqUser = 'guest',
|
||||||
|
private readonly string $rabbitmqPassword = 'guest',
|
||||||
|
) {
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Monitoring;
|
||||||
|
|
||||||
|
interface MessengerMetricsCollectorInterface
|
||||||
|
{
|
||||||
|
public function collect(): void;
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ final readonly class MetricsController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private CollectorRegistry $registry,
|
private CollectorRegistry $registry,
|
||||||
private HealthMetricsCollectorInterface $healthMetrics,
|
private HealthMetricsCollectorInterface $healthMetrics,
|
||||||
|
private MessengerMetricsCollectorInterface $messengerMetrics,
|
||||||
private string $appEnv = 'dev',
|
private string $appEnv = 'dev',
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -50,8 +51,9 @@ final readonly class MetricsController
|
|||||||
throw new AccessDeniedHttpException('Metrics endpoint is restricted to internal networks.');
|
throw new AccessDeniedHttpException('Metrics endpoint is restricted to internal networks.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect fresh health metrics before rendering
|
// Collect fresh metrics before rendering
|
||||||
$this->healthMetrics->collect();
|
$this->healthMetrics->collect();
|
||||||
|
$this->messengerMetrics->collect();
|
||||||
|
|
||||||
$renderer = new RenderTextFormat();
|
$renderer = new RenderTextFormat();
|
||||||
$metrics = $renderer->render($this->registry->getMetricFamilySamples());
|
$metrics = $renderer->render($this->registry->getMetricFamilySamples());
|
||||||
|
|||||||
104
backend/templates/emails/dead_letter_alert.html.twig
Normal file
104
backend/templates/emails/dead_letter_alert.html.twig
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Alerte Dead-Letter - Classeo</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 2px solid #dc2626;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #dc2626;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
.alert-box {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.detail-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 160px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Message en Dead-Letter</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="alert-box">
|
||||||
|
<p>Un message a atteint la dead-letter queue après avoir épuisé toutes ses tentatives de retry.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Type d'événement</span>
|
||||||
|
<span>{{ eventType }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Nombre de tentatives</span>
|
||||||
|
<span>{{ retryCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Transport</span>
|
||||||
|
<span>{{ transportName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-box">
|
||||||
|
<strong>Dernière erreur :</strong><br>
|
||||||
|
{{ lastError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 24px;">
|
||||||
|
Utilisez la commande <code>app:messenger:review-failed</code> pour inspecter et rejouer les messages échoués.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Classeo - Alerte Système Automatique</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -94,6 +94,7 @@ final class UserTest extends TestCase
|
|||||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||||
|
|
||||||
$this->expectException(CompteNonActivableException::class);
|
$this->expectException(CompteNonActivableException::class);
|
||||||
|
$this->expectExceptionMessage('Ce compte est déjà actif.');
|
||||||
|
|
||||||
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
|
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Console\CreateTestActivationTokenCommand;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
final class CreateTestActivationTokenCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
|
||||||
|
private InMemoryActivationTokenRepository $activationTokenRepository;
|
||||||
|
private InMemoryPasswordResetTokenRepository $passwordResetTokenRepository;
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private InMemoryTenantRegistry $tenantRegistry;
|
||||||
|
private Clock $clock;
|
||||||
|
/** @var list<object> */
|
||||||
|
private array $dispatchedEvents = [];
|
||||||
|
private MessageBusInterface $eventBus;
|
||||||
|
private CommandTester $commandTester;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->activationTokenRepository = new InMemoryActivationTokenRepository();
|
||||||
|
$this->passwordResetTokenRepository = new InMemoryPasswordResetTokenRepository();
|
||||||
|
$this->userRepository = new InMemoryUserRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-08 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$tenantId = InfraTenantId::fromString(self::TENANT_ID);
|
||||||
|
$this->tenantRegistry = new InMemoryTenantRegistry([
|
||||||
|
new TenantConfig($tenantId, self::SUBDOMAIN, 'postgresql://localhost/test'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatchedEvents = &$this->dispatchedEvents;
|
||||||
|
$this->eventBus = new class($dispatchedEvents) implements MessageBusInterface {
|
||||||
|
/** @param list<object> $events */
|
||||||
|
public function __construct(private array &$events)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(object $message, array $stamps = []): Envelope
|
||||||
|
{
|
||||||
|
$this->events[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$command = new CreateTestActivationTokenCommand(
|
||||||
|
$this->activationTokenRepository,
|
||||||
|
$this->passwordResetTokenRepository,
|
||||||
|
$this->userRepository,
|
||||||
|
$this->tenantRegistry,
|
||||||
|
$this->clock,
|
||||||
|
$this->eventBus,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->commandTester = new CommandTester($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesActivationTokenForNewUser(): void
|
||||||
|
{
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--email' => 'new@test.com',
|
||||||
|
'--role' => 'PARENT',
|
||||||
|
'--tenant' => self::SUBDOMAIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||||
|
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
self::assertStringContainsString('Test activation token created successfully', $output);
|
||||||
|
self::assertStringContainsString('Activation URL', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesActivationTokenForExistingPendingUser(): void
|
||||||
|
{
|
||||||
|
$user = User::creer(
|
||||||
|
email: new Email('pending@test.com'),
|
||||||
|
role: Role::PARENT,
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolName: 'École Test',
|
||||||
|
dateNaissance: null,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--email' => 'pending@test.com',
|
||||||
|
'--role' => 'PARENT',
|
||||||
|
'--tenant' => self::SUBDOMAIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||||
|
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
self::assertStringContainsString('already exists', $output);
|
||||||
|
self::assertStringContainsString('Activation URL', $output);
|
||||||
|
self::assertStringNotContainsString('Reset password URL', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesPasswordResetTokenForActiveUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createActiveUser('active@test.com');
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--email' => 'active@test.com',
|
||||||
|
'--role' => 'PARENT',
|
||||||
|
'--tenant' => self::SUBDOMAIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||||
|
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
self::assertStringContainsString('déjà actif', $output);
|
||||||
|
self::assertStringContainsString('Password reset token created successfully', $output);
|
||||||
|
self::assertStringContainsString('Reset password URL', $output);
|
||||||
|
self::assertStringNotContainsString('Activation URL', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDispatchesPasswordResetEventForActiveUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createActiveUser('active@test.com');
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--email' => 'active@test.com',
|
||||||
|
'--role' => 'PARENT',
|
||||||
|
'--tenant' => self::SUBDOMAIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertCount(1, $this->dispatchedEvents);
|
||||||
|
self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]);
|
||||||
|
self::assertSame('active@test.com', $this->dispatchedEvents[0]->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDoesNotDispatchEventsForNewUser(): void
|
||||||
|
{
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'--email' => 'new@test.com',
|
||||||
|
'--role' => 'PARENT',
|
||||||
|
'--tenant' => self::SUBDOMAIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||||
|
self::assertCount(0, $this->dispatchedEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createActiveUser(string $email): User
|
||||||
|
{
|
||||||
|
return User::reconstitute(
|
||||||
|
id: UserId::generate(),
|
||||||
|
email: new Email($email),
|
||||||
|
role: Role::PARENT,
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolName: 'École Test',
|
||||||
|
statut: StatutCompte::ACTIF,
|
||||||
|
dateNaissance: null,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
hashedPassword: '$argon2id$hashed',
|
||||||
|
activatedAt: new DateTimeImmutable('2026-01-16 10:00:00'),
|
||||||
|
consentementParental: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Console\ReviewFailedMessagesCommand;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
final class ReviewFailedMessagesCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private Connection&MockObject $connection;
|
||||||
|
private CommandTester $commandTester;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Messenger\DeadLetterAlertHandler;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use stdClass;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
|
||||||
|
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
final class DeadLetterAlertHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function itSendsAlertEmailWhenMessageWillNotRetry(): void
|
||||||
|
{
|
||||||
|
$mailer = $this->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('<html>alert</html>');
|
||||||
|
|
||||||
|
$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('<html>alert</html>');
|
||||||
|
$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('<html>alert</html>');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Messenger\FibonacciRetryStrategy;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
use stdClass;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportException;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
|
||||||
|
final class FibonacciRetryStrategyTest extends TestCase
|
||||||
|
{
|
||||||
|
private FibonacciRetryStrategy $strategy;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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<string, array{int, int}>
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prometheus\CollectorRegistry;
|
||||||
|
use Prometheus\Counter;
|
||||||
|
use RuntimeException;
|
||||||
|
use stdClass;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||||
|
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||||
|
|
||||||
|
final class MessengerMetricsMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function itIncrementsHandledCounterOnSuccess(): void
|
||||||
|
{
|
||||||
|
$handledCounter = $this->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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Monitoring\MessengerMetricsCollector;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Prometheus\CollectorRegistry;
|
||||||
|
use Prometheus\Gauge;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
#[CoversClass(MessengerMetricsCollector::class)]
|
||||||
|
final class MessengerMetricsCollectorTest extends TestCase
|
||||||
|
{
|
||||||
|
private Gauge $gauge;
|
||||||
|
|
||||||
|
private function createCollector(MockHttpClient $httpClient): MessengerMetricsCollector
|
||||||
|
{
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
|
||||||
|
|
||||||
use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface;
|
use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface;
|
||||||
|
use App\Shared\Infrastructure\Monitoring\MessengerMetricsCollectorInterface;
|
||||||
use App\Shared\Infrastructure\Monitoring\MetricsController;
|
use App\Shared\Infrastructure\Monitoring\MetricsController;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
@@ -48,8 +49,9 @@ final class MetricsControllerTest extends TestCase
|
|||||||
$registry->method('getMetricFamilySamples')->willReturn([]);
|
$registry->method('getMetricFamilySamples')->willReturn([]);
|
||||||
|
|
||||||
$healthMetrics = new HealthMetricsCollectorStub();
|
$healthMetrics = new HealthMetricsCollectorStub();
|
||||||
|
$messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class);
|
||||||
|
|
||||||
return new MetricsController($registry, $healthMetrics, $appEnv);
|
return new MetricsController($registry, $healthMetrics, $messengerMetrics, $appEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -86,7 +88,8 @@ final class MetricsControllerTest extends TestCase
|
|||||||
$registry->method('getMetricFamilySamples')->willReturn([$sample]);
|
$registry->method('getMetricFamilySamples')->willReturn([$sample]);
|
||||||
|
|
||||||
$healthMetrics = new HealthMetricsCollectorStub();
|
$healthMetrics = new HealthMetricsCollectorStub();
|
||||||
$controller = new MetricsController($registry, $healthMetrics);
|
$messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class);
|
||||||
|
$controller = new MetricsController($registry, $healthMetrics, $messengerMetrics);
|
||||||
$request = Request::create('/metrics');
|
$request = Request::create('/metrics');
|
||||||
|
|
||||||
$response = $controller($request);
|
$response = $controller($request);
|
||||||
@@ -124,7 +127,8 @@ final class MetricsControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$registry = $this->createMock(CollectorRegistry::class);
|
$registry = $this->createMock(CollectorRegistry::class);
|
||||||
$healthMetrics = new HealthMetricsCollectorStub();
|
$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']);
|
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -162,7 +166,8 @@ final class MetricsControllerTest extends TestCase
|
|||||||
$registry->method('getMetricFamilySamples')->willReturn([]);
|
$registry->method('getMetricFamilySamples')->willReturn([]);
|
||||||
|
|
||||||
$healthMetrics = new HealthMetricsCollectorStub();
|
$healthMetrics = new HealthMetricsCollectorStub();
|
||||||
$controller = new MetricsController($registry, $healthMetrics);
|
$messengerMetrics = $this->createMock(MessengerMetricsCollectorInterface::class);
|
||||||
|
$controller = new MetricsController($registry, $healthMetrics, $messengerMetrics);
|
||||||
$request = Request::create('/metrics');
|
$request = Request::create('/metrics');
|
||||||
|
|
||||||
$controller($request);
|
$controller($request);
|
||||||
|
|||||||
39
compose.yaml
39
compose.yaml
@@ -182,6 +182,45 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
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
|
# EMAIL TESTING - Mailpit
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ groups:
|
|||||||
description: "Queue has {{ $value }} messages pending"
|
description: "Queue has {{ $value }} messages pending"
|
||||||
runbook_url: "https://docs.classeo.local/runbooks/rabbitmq-backlog"
|
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
|
# Security Alerts
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user