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; } }; } }; } }