- |
+ |
|
@@ -10,25 +10,17 @@
Olá, espero que esteja bem.
Com grande satisfação, informamos que sua participação no
- {{ $opp_name }}
- alcançou a próxima etapa!.
+ {{ $opportunity_name }} alcançou a próxima etapa!
- Agora, você pode acompanhar todas as novidades e requisitos dessa nova fase diretamente
- no seu painel no Mapa Cultural do Ceará. Para sua comodidade, também disponibilizamos
- um atalho que o(a) levará diretamente à sua inscrição:
-
-
- Acessar Inscrição.
-
-
-
-
- Parabéns pelo avanço {{$agent_name}} !
-
-
-
+ Agora, você pode acompanhar todas as novidades e requisitos dessa nova fase
+ diretamente no seu painel no Mapa Cultural do Ceará.
+ Para sua comodidade, disponibilizamos um atalho para sua inscrição:
+
+ Acessar Inscrição
+ .
+ Parabéns pelo avanço, {{ $agent_name }}!
|
diff --git a/tests/Feature/ImportRegistrationCommandTest.php b/tests/Feature/ImportRegistrationCommandTest.php
new file mode 100644
index 0000000..d358927
--- /dev/null
+++ b/tests/Feature/ImportRegistrationCommandTest.php
@@ -0,0 +1,323 @@
+ (string) Str::uuid(),
+ 'event_type' => 'registration.import_requested',
+ 'schema_version' => 1,
+ 'occurred_at' => now()->toIso8601String(),
+ 'registration' => 200,
+ 'opportunity_id' => 100,
+ 'opportunity_name' => 'Oportunidade de teste',
+ 'number' => 'ON-123456',
+ 'agent_name' => 'Agente de teste',
+ 'agent_email' => 'agente@example.com',
+ ];
+ }
+
+ public function test_sucesso_e_duplicata_recebem_ack(): void
+ {
+ Mail::fake();
+ $data = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message1 = $this->message($data, true);
+ $message2 = $this->message($data, true);
+
+ $command = $this->commandUnderTest();
+ $command->accept($message1);
+ $command->accept($message2);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ }
+
+ public function test_falha_no_ack_nao_tenta_nack_da_mensagem_ja_processada(): void
+ {
+ Mail::fake();
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = Mockery::mock(AMQPMessage::class);
+ $message->shouldReceive('getBody')->andReturn($body);
+ $message->shouldReceive('has')->with('application_headers')->once()->andReturn(false);
+ $message->shouldReceive('ack')->once()
+ ->andThrow(new RuntimeException('Falha de confirmação AMQP'));
+ $message->shouldNotReceive('nack');
+
+ try {
+ $this->commandUnderTest()->accept($message);
+ $this->fail('A falha no ACK deveria ser propagada.');
+ } catch (RuntimeException $e) {
+ $this->assertSame('Falha de confirmação AMQP', $e->getMessage());
+ }
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'sent',
+ ]);
+ }
+
+ public function test_json_invalido_recebe_nack_sem_requeue(): void
+ {
+ Mail::fake();
+ $message = $this->message('{"json":', false);
+
+ $this->commandUnderTest()->accept($message);
+
+ Mail::assertNothingSent();
+ }
+
+ public function test_dados_invalidos_recebem_nack_sem_requeue(): void
+ {
+ Mail::fake();
+ $data = $this->payload();
+ $data['agent_email'] = 'email-invalido';
+ $message = $this->message(json_encode($data, JSON_THROW_ON_ERROR), false);
+
+ $this->commandUnderTest()->accept($message);
+
+ Mail::assertNothingSent();
+ }
+
+ public function test_smtp_incerto_com_unknown_persistido_recebe_ack(): void
+ {
+ $this->simulateSmtpTimeout();
+ $message = $this->message(json_encode($this->payload(), JSON_THROW_ON_ERROR), true);
+
+ $this->commandUnderTest()->accept($message);
+
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'registration_id' => 200,
+ 'opportunity_id' => 100,
+ 'status' => 'unknown',
+ ]);
+ }
+
+ public function test_falha_ao_gravar_unknown_recebe_nack_e_nao_ack(): void
+ {
+ $this->simulateSmtpTimeout();
+
+ // Simula uma falha de persistência apenas na transição para unknown.
+ ImportRegistrationEmailProcess::updating(function (ImportRegistrationEmailProcess $process): void {
+ if ($process->status === 'unknown') {
+ throw new RuntimeException('Erro ao persistir unknown');
+ }
+ });
+
+ $message = $this->message(json_encode($this->payload(), JSON_THROW_ON_ERROR), false);
+
+ $this->commandUnderTest()->accept($message);
+
+ // A reserva continuou em sending: nunca reenviar automaticamente.
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'registration_id' => 200,
+ 'opportunity_id' => 100,
+ 'status' => 'sending',
+ ]);
+ }
+
+ public function test_erro_antes_smtp_publica_primeiro_retry_e_so_depois_da_ack(): void
+ {
+ config()->set('rabbitmq.retry.enabled', true);
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = $this->message($body, true);
+ $processor = $this->retryableProcessor();
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldReceive('publish')->once()->with($body, 1);
+
+ $this->commandUnderTest($processor, $publisher)->accept($message);
+ }
+
+ public function test_falha_na_preparacao_e_retry_posterior_enviam_email_uma_unica_vez(): void
+ {
+ config()->set('rabbitmq.retry.enabled', true);
+ Mail::fake();
+
+ // O problema foi temporário: falha na primeira renderização e passa na segunda.
+ $falharUmaVez = true;
+ View::composer('emails.import-registration', function () use (&$falharUmaVez): void {
+ if ($falharUmaVez) {
+ $falharUmaVez = false;
+ throw new RuntimeException('Falha transitória no template');
+ }
+ });
+
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldReceive('publish')->once()->with($body, 1);
+ $command = $this->commandUnderTest(null, $publisher);
+
+ $command->accept($this->message($body, true));
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'failed',
+ 'attempts' => 1,
+ ]);
+
+ // Simula entrega depois do TTL da fila de retry 1.
+ $command->accept($this->message($body, true, 1));
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'sent',
+ 'attempts' => 2,
+ ]);
+ }
+
+ public function test_segundo_retry_avanca_para_terceira_fila(): void
+ {
+ config()->set('rabbitmq.retry.enabled', true);
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = $this->message($body, true, 2);
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldReceive('publish')->once()->with($body, 3);
+
+ $this->commandUnderTest($this->retryableProcessor(), $publisher)->accept($message);
+ }
+
+ public function test_depois_da_terceira_tentativa_de_retry_vai_para_dlq(): void
+ {
+ config()->set('rabbitmq.retry.enabled', true);
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = $this->message($body, false, 3);
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldNotReceive('publish');
+
+ $this->commandUnderTest($this->retryableProcessor(), $publisher)->accept($message);
+ }
+
+ public function test_retry_desativado_preserva_comportamento_anterior_dlq(): void
+ {
+ config()->set('rabbitmq.retry.enabled', false);
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = $this->message($body, false);
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldNotReceive('publish');
+
+ $this->commandUnderTest($this->retryableProcessor(), $publisher)->accept($message);
+ }
+
+ public function test_falha_na_publicacao_nao_confirma_nem_rejeita_a_mensagem_original(): void
+ {
+ config()->set('rabbitmq.retry.enabled', true);
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = Mockery::mock(AMQPMessage::class);
+ $message->shouldReceive('getBody')->andReturn($body);
+ $message->shouldReceive('has')->once()->with('application_headers')->andReturn(false);
+ $message->shouldNotReceive('ack');
+ $message->shouldNotReceive('nack');
+
+ $publisher = Mockery::mock(ImportRegistrationRetryPublisher::class);
+ $publisher->shouldReceive('publish')->once()->with($body, 1)
+ ->andThrow(new RuntimeException('Broker indisponível para retry'));
+
+ try {
+ $this->commandUnderTest($this->retryableProcessor(), $publisher)->accept($message);
+ $this->fail('A falha de publicação deveria ser propagada.');
+ } catch (RuntimeException $e) {
+ $this->assertSame('Broker indisponível para retry', $e->getMessage());
+ }
+ }
+
+ public function test_cabecalho_retry_malformado_vai_direto_para_dlq(): void
+ {
+ $body = json_encode($this->payload(), JSON_THROW_ON_ERROR);
+ $message = Mockery::mock(AMQPMessage::class);
+ $message->shouldReceive('getBody')->andReturn($body);
+ $message->shouldReceive('has')->once()->with('application_headers')->andReturn(true);
+ $message->shouldReceive('get')->once()->with('application_headers')
+ ->andReturn(new AMQPTable(['x-import-registration-retry-count' => 'invalido']));
+ $message->shouldReceive('nack')->once()->with(false, false);
+ $message->shouldNotReceive('ack');
+
+ $processor = Mockery::mock(ImportRegistrationProcessor::class);
+ $processor->shouldNotReceive('process');
+ $this->commandUnderTest($processor)->accept($message);
+ }
+
+ private function retryableProcessor(): ImportRegistrationProcessor
+ {
+ $processor = Mockery::mock(ImportRegistrationProcessor::class);
+ $processor->shouldReceive('process')->once()
+ ->andThrow(new RetryableImportRegistrationException('Falha segura antes do SMTP'));
+ return $processor;
+ }
+
+ /**
+ * Mock do AMQPMessage: valida apenas a resposta ACK/NACK, sem conectar ao broker.
+ */
+ private function message(string $body, bool $shouldAck, int $retryCount = 0): AMQPMessage
+ {
+ $message = Mockery::mock(AMQPMessage::class);
+ $message->shouldReceive('getBody')->andReturn($body);
+ $message->shouldReceive('has')->once()->with('application_headers')
+ ->andReturn($retryCount > 0);
+ if ($retryCount > 0) {
+ $message->shouldReceive('get')->once()->with('application_headers')
+ ->andReturn(new AMQPTable(['x-import-registration-retry-count' => $retryCount]));
+ }
+
+ if ($shouldAck) {
+ $message->shouldReceive('ack')->once();
+ $message->shouldNotReceive('nack');
+ } else {
+ $message->shouldReceive('nack')->once()->with(false, false);
+ $message->shouldNotReceive('ack');
+ }
+
+ return $message;
+ }
+
+ private function commandUnderTest(
+ ?ImportRegistrationProcessor $processor = null,
+ ?ImportRegistrationRetryPublisher $publisher = null
+ ): ImportRegistrationCommand {
+ // O RabbitMQService possui conexão no construtor; o mock evita I/O.
+ $rabbit = Mockery::mock(RabbitMQService::class);
+ $processor = $processor ?? app(ImportRegistrationProcessor::class);
+ $publisher = $publisher ?? Mockery::mock(ImportRegistrationRetryPublisher::class);
+
+ return new class($rabbit, $processor, $publisher) extends ImportRegistrationCommand {
+ public function accept(AMQPMessage $message): void
+ {
+ $this->processMessage($message);
+ }
+ };
+ }
+
+ private function simulateSmtpTimeout(): void
+ {
+ // Renderizacao do mailable precisa do mailer real; somente o envio e simulado.
+ $realMailer = app('mailer');
+ Mail::shouldReceive('mailer')->andReturn($realMailer);
+
+ Mail::shouldReceive('to')->once()->with('agente@example.com')->andReturnSelf();
+ Mail::shouldReceive('send')->once()->with(Mockery::type(ImporteRegistrationMail::class))
+ ->andThrow(new RuntimeException('Timeout SMTP'));
+ }
+}
diff --git a/tests/Feature/ImportRegistrationProcessorTest.php b/tests/Feature/ImportRegistrationProcessorTest.php
new file mode 100644
index 0000000..d01eea9
--- /dev/null
+++ b/tests/Feature/ImportRegistrationProcessorTest.php
@@ -0,0 +1,302 @@
+ (string) Str::uuid(),
+ 'event_type' => 'registration.import_requested',
+ 'schema_version' => 1,
+ 'occurred_at' => now()->toIso8601String(),
+ 'registration' => 200,
+ 'opportunity_id' => 100,
+ 'opportunity_name' => 'Oportunidade de teste',
+ 'number' => 'ON-123456',
+ 'agent_name' => 'Agente de teste',
+ 'agent_email' => 'agente@example.com',
+ ], $changes);
+ }
+
+ public function test_primeira_mensagem_envia_uma_vez_e_registra_sucesso(): void
+ {
+ Mail::fake();
+ $data = $this->payload();
+
+ app(ImportRegistrationProcessor::class)->process($data);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'sent',
+ 'attempts' => 1,
+ 'message_id' => $data['message_id'],
+ ]);
+ $this->assertSame(1, EmailDispatch::query()->count());
+ $this->assertSame('sent', EmailDispatch::query()->firstOrFail()->status);
+ }
+
+ public function test_mensagem_legada_e_canonica_da_mesma_inscricao_geram_um_unico_envio(): void
+ {
+ Mail::fake();
+ $canonical = $this->payload();
+ $legacy = [
+ 'registration' => $canonical['registration'],
+ 'opp_id' => $canonical['opportunity_id'],
+ 'opp_name' => $canonical['opportunity_name'],
+ 'number' => $canonical['number'],
+ 'agent_name' => $canonical['agent_name'],
+ 'agent_email' => $canonical['agent_email'],
+ ];
+ $processor = app(ImportRegistrationProcessor::class);
+
+ $processor->process($legacy);
+ $processor->process($canonical);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $dispatch = EmailDispatch::query()->sole();
+ $this->assertSame($canonical['opportunity_id'], $dispatch->meta['opportunity_id']);
+ $this->assertSame($canonical['opportunity_name'], $dispatch->meta['opportunity_name']);
+ }
+
+ public function test_mesmo_message_id_nao_dispara_segundo_email(): void
+ {
+ Mail::fake();
+ $data = $this->payload();
+ $processor = app(ImportRegistrationProcessor::class);
+
+ $processor->process($data);
+ $processor->process($data);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertSame(1, EmailDispatch::query()->count());
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'registration_id' => 200,
+ 'opportunity_id' => 100,
+ 'attempts' => 1,
+ ]);
+ }
+
+ public function test_uuids_diferentes_da_mesma_inscricao_nao_duplicam_email(): void
+ {
+ Mail::fake();
+ $processor = app(ImportRegistrationProcessor::class);
+ $first = $this->payload();
+ $second = $this->payload(['message_id' => (string) Str::uuid()]);
+
+ $processor->process($first);
+ $processor->process($second);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertSame(1, EmailDispatch::query()->count());
+ $this->assertSame(1, ImportRegistrationEmailProcess::query()->count());
+ $this->assertSame($first['message_id'], ImportRegistrationEmailProcess::query()->firstOrFail()->message_id);
+ }
+
+ public function test_status_sending_bloqueia_reenvio(): void
+ {
+ Mail::fake();
+ $this->seedProcess('sending');
+
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+
+ Mail::assertNothingSent();
+ $this->assertSame(0, EmailDispatch::query()->count());
+ }
+
+ public function test_status_unknown_bloqueia_reenvio(): void
+ {
+ Mail::fake();
+ $this->seedProcess('unknown');
+
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+
+ Mail::assertNothingSent();
+ $this->assertSame(0, EmailDispatch::query()->count());
+ }
+
+ public function test_status_sent_bloqueia_reenvio(): void
+ {
+ Mail::fake();
+ $this->seedProcess('sent');
+
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+
+ Mail::assertNothingSent();
+ }
+
+ public function test_status_failed_permite_nova_tentativa_se_envio_anterior_nao_comecou(): void
+ {
+ Mail::fake();
+ $this->seedProcess('failed', 1);
+
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'sent',
+ 'attempts' => 2,
+ ]);
+ }
+
+ public function test_falha_antes_do_smtp_marca_failed_e_nao_envia(): void
+ {
+ Mail::fake();
+ // Dispara erro na renderização do mailable, antes de Mail::send().
+ View::composer('emails.import-registration', function (): void {
+ throw new RuntimeException('Falha de preparação do template');
+ });
+
+ try {
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+ $this->fail('Era esperada uma falha de renderização.');
+ } catch (RetryableImportRegistrationException $e) {
+ $this->assertSame('Falha de preparação do template', $e->getPrevious()->getMessage());
+ }
+
+ Mail::assertNothingSent();
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'failed',
+ 'attempts' => 1,
+ ]);
+ }
+
+ public function test_falha_do_smtp_marca_unknown_e_nunca_reenvia_automaticamente(): void
+ {
+ $data = $this->payload();
+ $this->simulateSmtpTimeout();
+
+ try {
+ app(ImportRegistrationProcessor::class)->process($data);
+ $this->fail('Era esperado resultado desconhecido.');
+ } catch (EmailDeliveryOutcomeUnknownException $e) {
+ $this->assertNotNull($e->getPrevious());
+ }
+
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'unknown',
+ 'attempts' => 1,
+ ]);
+ $this->assertSame('unknown', EmailDispatch::query()->firstOrFail()->status);
+
+ // Não misturar Mail::fake() com Mail::shouldReceive() no mesmo teste.
+ // O mock de send() exige exatamente uma chamada (once).
+ app(ImportRegistrationProcessor::class)->process($data);
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'unknown',
+ 'attempts' => 1,
+ ]);
+ $this->assertSame(1, EmailDispatch::query()->count());
+ }
+
+ public function test_falha_ao_salvar_failed_nao_autoriza_retry_automatico(): void
+ {
+ Mail::fake();
+ View::composer('emails.import-registration', function (): void {
+ throw new RuntimeException('Erro ao preparar template');
+ });
+
+ ImportRegistrationEmailProcess::updating(function (ImportRegistrationEmailProcess $process): void {
+ if ($process->status === 'failed') {
+ throw new RuntimeException('Banco indisponível para marcar failed');
+ }
+ });
+
+ try {
+ app(ImportRegistrationProcessor::class)->process($this->payload());
+ $this->fail('Era esperada falha de persistência.');
+ } catch (RuntimeException $e) {
+ $this->assertSame('Banco indisponível para marcar failed', $e->getMessage());
+ $this->assertNotInstanceOf(RetryableImportRegistrationException::class, $e);
+ }
+
+ Mail::assertNothingSent();
+ $this->assertDatabaseHas('import_registration_email_processes', [
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => 'sending',
+ 'attempts' => 1,
+ ]);
+ }
+
+ public function test_message_id_invalido_e_rejeitado(): void
+ {
+ Mail::fake();
+
+ try {
+ app(ImportRegistrationProcessor::class)->process(
+ $this->payload(['message_id' => 'nao-e-uuid'])
+ );
+ $this->fail('A mensagem deveria ser inválida.');
+ } catch (InvalidRegistrationMessageException $e) {
+ $this->assertNotEmpty($e->getMessage());
+ }
+
+ Mail::assertNothingSent();
+ $this->assertSame(0, ImportRegistrationEmailProcess::query()->count());
+ }
+
+ public function test_formato_legado_sem_message_id_continua_aceito(): void
+ {
+ Mail::fake();
+ $data = $this->payload();
+ unset($data['message_id'], $data['event_type'], $data['schema_version']);
+
+ app(ImportRegistrationProcessor::class)->process($data);
+
+ Mail::assertSent(ImporteRegistrationMail::class, 1);
+ $this->assertNotNull(ImportRegistrationEmailProcess::query()->firstOrFail()->message_id);
+ }
+
+ private function seedProcess(string $status, int $attempts = 1): void
+ {
+ ImportRegistrationEmailProcess::query()->create([
+ 'opportunity_id' => 100,
+ 'registration_id' => 200,
+ 'status' => $status,
+ 'attempts' => $attempts,
+ 'message_id' => (string) Str::uuid(),
+ 'agent_email' => 'agente@example.com',
+ 'started_at' => now(),
+ ]);
+ }
+
+ private function simulateSmtpTimeout(): void
+ {
+ // Renderizacao do mailable precisa do mailer real; somente o envio e simulado.
+ $realMailer = app('mailer');
+ Mail::shouldReceive('mailer')->andReturn($realMailer);
+
+ // Evita transporte externo e simula erro após iniciar a tentativa.
+ Mail::shouldReceive('to')->once()->with('agente@example.com')->andReturnSelf();
+ Mail::shouldReceive('send')->once()->with(\Mockery::type(ImporteRegistrationMail::class))
+ ->andThrow(new RuntimeException('Timeout SMTP'));
+ }
+}
diff --git a/tests/Integration/ImportRegistrationConcurrencyTest.php b/tests/Integration/ImportRegistrationConcurrencyTest.php
new file mode 100644
index 0000000..bacc952
--- /dev/null
+++ b/tests/Integration/ImportRegistrationConcurrencyTest.php
@@ -0,0 +1,172 @@
+markTestSkipped('Teste de concorrência exige ativação explícita.');
+ }
+ if (!function_exists('pcntl_fork') || !function_exists('pcntl_waitpid')) {
+ $this->markTestSkipped('Extensão pcntl indisponível.');
+ }
+
+ $connection = DB::connection();
+ if (!in_array($connection->getDriverName(), ['pgsql', 'mysql'], true)) {
+ $this->markTestSkipped('É necessário PostgreSQL ou MySQL.');
+ }
+ $databaseName = strtolower((string) $connection->getDatabaseName());
+ if (strpos($databaseName, 'test') === false) {
+ $this->markTestSkipped('Segurança: o nome do banco precisa conter "test".');
+ }
+
+ // IDs isolados: não precisam existir nas tabelas do sistema de origem.
+ $this->registrationId = random_int(1800000000, 1999999999);
+ $this->opportunityId = random_int(1800000000, 1999999999);
+ $startSignal = sys_get_temp_dir() . '/import-registration-start-' . bin2hex(random_bytes(8));
+ $children = [];
+ $statuses = [];
+
+ try {
+ for ($i = 0; $i < 2; $i++) {
+ $payload = $this->payload();
+ $pid = pcntl_fork();
+ if ($pid === -1) {
+ throw new \RuntimeException('Falha ao executar fork para teste de concorrência.');
+ }
+
+ if ($pid === 0) {
+ // Não compartilhar conexões PDO herdadas do processo pai.
+ DB::purge(DB::getDefaultConnection());
+ Mail::fake();
+
+ $deadline = microtime(true) + 15;
+ while (!file_exists($startSignal) && microtime(true) < $deadline) {
+ usleep(10000);
+ }
+ if (!file_exists($startSignal)) {
+ fwrite(STDERR, "Timeout aguardando sincronização\n");
+ exit(2);
+ }
+
+ try {
+ app(ImportRegistrationProcessor::class)->process($payload);
+ exit(0);
+ } catch (\Throwable $e) {
+ fwrite(STDERR, 'Falha no filho: ' . $e->getMessage() . "\n");
+ exit(1);
+ }
+ }
+
+ $children[] = $pid;
+ }
+
+ // Libera os dois processos aproximadamente ao mesmo tempo.
+ touch($startSignal);
+ $deadline = microtime(true) + 30;
+ while (count($statuses) < count($children) && microtime(true) < $deadline) {
+ foreach ($children as $pid) {
+ if (array_key_exists($pid, $statuses)) {
+ continue;
+ }
+ $result = pcntl_waitpid($pid, $status, WNOHANG);
+ if ($result === $pid) {
+ $statuses[$pid] = $status;
+ }
+ }
+ usleep(20000);
+ }
+
+ foreach ($children as $pid) {
+ $this->assertArrayHasKey($pid, $statuses, 'Processo filho excedeu o timeout.');
+ $this->assertTrue(pcntl_wifexited($statuses[$pid]));
+ $this->assertSame(0, pcntl_wexitstatus($statuses[$pid]));
+ }
+
+ $process = ImportRegistrationEmailProcess::query()
+ ->where('opportunity_id', $this->opportunityId)
+ ->where('registration_id', $this->registrationId)
+ ->firstOrFail();
+
+ $this->assertSame('sent', $process->status);
+ $this->assertSame(1, $process->attempts);
+ $this->assertSame(1, ImportRegistrationEmailProcess::query()
+ ->where('opportunity_id', $this->opportunityId)
+ ->where('registration_id', $this->registrationId)
+ ->count());
+ $this->assertCount(1, $this->relatedDispatches());
+ } finally {
+ if (file_exists($startSignal)) {
+ unlink($startSignal);
+ }
+ foreach ($children as $pid) {
+ if (!array_key_exists($pid, $statuses)) {
+ if (function_exists('posix_kill')) {
+ posix_kill($pid, SIGKILL);
+ }
+ pcntl_waitpid($pid, $status, WNOHANG);
+ }
+ }
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ if ($this->registrationId !== null && $this->opportunityId !== null) {
+ $ids = $this->relatedDispatches()->pluck('id')->all();
+ if ($ids !== []) {
+ DB::table('email_dispatches')->whereIn('id', $ids)->delete();
+ }
+ ImportRegistrationEmailProcess::query()
+ ->where('opportunity_id', $this->opportunityId)
+ ->where('registration_id', $this->registrationId)
+ ->delete();
+ }
+ parent::tearDown();
+ }
+
+ private function relatedDispatches(): \Illuminate\Support\Collection
+ {
+ // Evita assumir que o campo meta é JSON nativo ou texto no banco.
+ return EmailDispatch::query()->get()->filter(function (EmailDispatch $dispatch): bool {
+ $meta = $dispatch->meta;
+ return is_array($meta)
+ && ($meta['opportunity_id'] ?? null) == $this->opportunityId
+ && ($meta['registration'] ?? null) == $this->registrationId;
+ });
+ }
+
+ private function payload(): array
+ {
+ return [
+ 'message_id' => (string) Str::uuid(),
+ 'event_type' => 'registration.import_requested',
+ 'schema_version' => 1,
+ 'occurred_at' => now()->toIso8601String(),
+ 'registration' => $this->registrationId,
+ 'opportunity_id' => $this->opportunityId,
+ 'opportunity_name' => 'Oportunidade concorrente de teste',
+ 'number' => 'ON-CONCORRENCIA',
+ 'agent_name' => 'Teste de concorrência',
+ 'agent_email' => 'concorrencia@example.com',
+ ];
+ }
+}
diff --git a/tests/Unit/Console/Commands/ImportRegistrationCommandTest.php b/tests/Unit/Console/Commands/ImportRegistrationCommandTest.php
deleted file mode 100644
index 75b89bd..0000000
--- a/tests/Unit/Console/Commands/ImportRegistrationCommandTest.php
+++ /dev/null
@@ -1,382 +0,0 @@
-byDefault();
- Log::shouldReceive('error')->byDefault();
- }
-
- // Limpa os mocks do Mockery após cada teste
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-
- /**
- * Para criar um mock do AMQPMessage com ack
- */
- protected function createMockAMQPMessage(string $body, bool $shouldAck = true): AMQPMessage
- {
- $message = Mockery::mock(AMQPMessage::class);
- $message->shouldReceive('getBody')->andReturn($body);
-
- if ($shouldAck) {
- $message->shouldReceive('ack')->once()->andReturnNull();
- }
-
- return $message;
- }
-
- /**
- * Teste: Processa mensagem válida do RabbitMQ e dispara evento ImportRegistrationEvent
- */
- public function test_processes_valid_rabbitmq_message_and_dispatches_event()
- {
- // Arrange: Mock do serviço RabbitMQ
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- // Dados no formato especificado pelo usuário
- $messageBody = json_encode([
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ]
- ]);
-
- $msgMock = $this->createMockAMQPMessage($messageBody);
-
- // Act: Processa a mensagem
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert: Verifica se o evento foi disparado
- Event::assertDispatched(ImportRegistrationEvent::class, function ($event) {
- return is_array($event->registrations) &&
- count($event->registrations) === 1 &&
- $event->registrations[0]['registration'] === 154069287 &&
- $event->registrations[0]['opp_name'] === 'Teste de importe';
- });
- }
-
- /**
- * Teste: Processa múltiplas inscrições e dispara evento com todos os dados
- */
- public function test_processes_multiple_registrations_and_dispatches_event()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- $messageBody = json_encode([
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@gmail.com',
- ],
- [
- 'registration' => 154069288,
- 'opp_id' => 7302,
- 'opp_name' => 'Segunda oportunidade',
- 'number' => 'on-154026964',
- 'agent_name' => 'JOAO DA SILVA',
- 'agent_email' => 'joao.silva@example.com',
- ]
- ]);
-
- $msgMock = $this->createMockAMQPMessage($messageBody);
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert
- Event::assertDispatched(ImportRegistrationEvent::class, function ($event) {
- return is_array($event->registrations) &&
- count($event->registrations) === 2 &&
- $event->registrations[0]['registration'] === 154069287 &&
- $event->registrations[1]['registration'] === 154069288;
- });
- }
-
- /**
- * Teste: Verifica se a mensagem é confirmada (ack) após processamento bem-sucedido
- */
- public function test_acknowledges_message_after_successful_processing()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- $messageBody = json_encode([
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@gmail.com',
- ]
- ]);
-
- // Configurando expectativa explícita do ack
- $msgMock = $this->createMockAMQPMessage($messageBody, true);
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert: O mock do Mockery irá falhar se ack() não for chamado exatamente uma vez
- $this->assertTrue(true);
- }
-
- /**
- * Teste: Loga informação ao receber mensagem
- */
- public function test_logs_info_when_receiving_message()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- $messageBody = json_encode([
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@gmail.com',
- ]
- ]);
-
- $msgMock = $this->createMockAMQPMessage($messageBody);
-
- // Expectativa explícita de log
- Log::shouldReceive('info')
- ->once()
- ->with('Mensagem recebida: ' . $messageBody);
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert implícito via Mockery
- $this->assertTrue(true);
- }
-
- /**
- * Teste: Loga erro quando JSON é inválido (não lança exceção, apenas retorna)
- */
- public function test_logs_error_when_json_is_invalid()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- // Mensagem JSON inválida
- $invalidJson = 'invalid json format {[';
- $msgMock = Mockery::mock(AMQPMessage::class);
- $msgMock->shouldReceive('getBody')->andReturn($invalidJson);
- // Não deve chamar ack quando há erro
- $msgMock->shouldNotReceive('ack');
-
- // Expectativa de log
- Log::shouldReceive('info')
- ->once()
- ->with('Mensagem recebida: ' . $invalidJson);
-
- Log::shouldReceive('error')
- ->once()
- ->with('Formato de mensagem inválido');
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert: Evento não deve ser disparado
- Event::assertNotDispatched(ImportRegistrationEvent::class);
- }
-
- /**
- * Teste: Captura exceção e envia para Sentry quando ocorre erro durante processamento
- */
- public function test_captures_processing_exception_with_sentry()
- {
- // Arrange: Simula falha no processamento
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- // Mock do evento para lançar exceção
- Event::shouldReceive('dispatch')
- ->andThrow(new \Exception('Erro ao processar evento'));
-
- $messageBody = json_encode([
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@gmail.com',
- ]
- ]);
-
- $msgMock = Mockery::mock(AMQPMessage::class);
- $msgMock->shouldReceive('getBody')->andReturn($messageBody);
- // Não deve chamar ack quando há exceção
- $msgMock->shouldNotReceive('ack');
-
- Log::shouldReceive('info')
- ->once()
- ->with('Mensagem recebida: ' . $messageBody);
-
- Log::shouldReceive('error')
- ->once()
- ->with('Erro ao processar a mensagem');
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
-
- // Não deve lançar exceção - deve ser capturada internamente
- $method->invoke($command, $msgMock);
-
- // Assert
- $this->assertTrue(true);
- }
-
- /**
- * Teste: Não processa mensagem quando o formato não é array
- */
- public function test_does_not_process_invalid_message_format()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $this->app->instance(RabbitMQService::class, $queueService);
-
- // JSON válido mas não é array
- $messageBody = json_encode('string simples');
-
- $msgMock = Mockery::mock(AMQPMessage::class);
- $msgMock->shouldReceive('getBody')->andReturn($messageBody);
- // Não deve chamar ack
- $msgMock->shouldNotReceive('ack');
-
- Log::shouldReceive('info')
- ->once()
- ->with('Mensagem recebida: ' . $messageBody);
-
- Log::shouldReceive('error')
- ->once()
- ->with('Formato de mensagem inválido');
-
- // Act
- $command = new ImportRegistrationCommand($queueService);
- $reflection = new \ReflectionClass($command);
- $method = $reflection->getMethod('processMessage');
- $method->setAccessible(true);
- $method->invoke($command, $msgMock);
-
- // Assert: Evento não deve ser disparado
- Event::assertNotDispatched(ImportRegistrationEvent::class);
- }
-
- /**
- * Teste: Verifica configuração do comando
- */
- public function test_command_signature_and_description()
- {
- // Arrange
- $queueService = Mockery::mock(RabbitMQService::class);
- $command = new ImportRegistrationCommand($queueService);
-
- // Act
- $reflection = new \ReflectionClass($command);
- $signatureProperty = $reflection->getProperty('signature');
- $signatureProperty->setAccessible(true);
- $signature = $signatureProperty->getValue($command);
-
- $descriptionProperty = $reflection->getProperty('description');
- $descriptionProperty->setAccessible(true);
- $description = $descriptionProperty->getValue($command);
-
- // Assert
- $this->assertEquals('rabbitmq:import-registration-command', $signature);
- $this->assertEquals('Comando para consumir a fila des imports de inscrição', $description);
- }
-
- /**
- * Teste: Handle chama o método consume com parâmetros corretos
- */
- public function test_handle_calls_consume_with_correct_parameters()
- {
- // Arrange
- config([
- 'rabbitmq.queues.queue_import_registration' => 'test_queue',
- 'rabbitmq.exchange_default' => 'test_exchange',
- 'rabbitmq.routing.module_import_registration_draft' => 'test_routing_key'
- ]);
-
- $queueService = Mockery::mock(RabbitMQService::class);
- $queueService->shouldReceive('consume')
- ->once()
- ->with('test_queue', 'test_exchange', 'test_routing_key', Mockery::type('Closure'))
- ->andReturn(null);
-
- $this->app->instance(RabbitMQService::class, $queueService);
-
- $command = new ImportRegistrationCommand($queueService);
-
- // Act
- $command->handle();
-
- // Assert: Mockery irá verificar se consume foi chamado corretamente
- $this->assertTrue(true);
- }
-}
diff --git a/tests/Unit/ImportRegistrationRetryPublisherTest.php b/tests/Unit/ImportRegistrationRetryPublisherTest.php
new file mode 100644
index 0000000..d510895
--- /dev/null
+++ b/tests/Unit/ImportRegistrationRetryPublisherTest.php
@@ -0,0 +1,144 @@
+set('rabbitmq.retry.exchange', 'exchange_notification_retry');
+ config()->set('rabbitmq.retry.routes', [1 => 'module_import_registration_draft_retry_1']);
+
+ $ackCallback = null;
+ $channel = Mockery::mock(AMQPChannel::class);
+ $connection = Mockery::mock(AMQPStreamConnection::class);
+ $connection->shouldReceive('channel')->once()->andReturn($channel);
+ $connection->shouldReceive('close')->once();
+ $channel->shouldReceive('close')->once();
+ $channel->shouldReceive('set_ack_handler')->once()->withArgs(function ($cb) use (&$ackCallback): bool {
+ $ackCallback = $cb;
+ return true;
+ });
+ $channel->shouldReceive('set_nack_handler')->once();
+ $channel->shouldReceive('set_return_listener')->once();
+ $channel->shouldReceive('confirm_select')->once();
+ $channel->shouldReceive('basic_publish')->once()->withArgs(function (
+ AMQPMessage $msg,
+ $exchange,
+ $route,
+ $mandatory
+ ): bool {
+ $this->assertSame('{"registration":200}', $msg->getBody());
+ $this->assertSame('exchange_notification_retry', $exchange);
+ $this->assertSame('module_import_registration_draft_retry_1', $route);
+ $this->assertTrue($mandatory);
+ $this->assertSame(AMQPMessage::DELIVERY_MODE_PERSISTENT, $msg->get('delivery_mode'));
+ $this->assertSame('application/json', $msg->get('content_type'));
+ $headers = $msg->get('application_headers');
+ $this->assertInstanceOf(AMQPTable::class, $headers);
+ $this->assertSame(1, $headers->getNativeData()['x-import-registration-retry-count']);
+ return true;
+ });
+ $channel->shouldReceive('wait_for_pending_acks_returns')->once()->with(10)
+ ->andReturnUsing(function () use (&$ackCallback): void {
+ $ackCallback(new AMQPMessage('ok'));
+ });
+
+ $this->makePublisher($connection)->publish('{"registration":200}', 1);
+ }
+
+ public function test_sem_confirmacao_nao_autoriza_ack_da_mensagem_original(): void
+ {
+ config()->set('rabbitmq.retry.exchange', 'exchange_notification_retry');
+ config()->set('rabbitmq.retry.routes', [1 => 'module_import_registration_draft_retry_1']);
+
+ $channel = Mockery::mock(AMQPChannel::class);
+ $connection = Mockery::mock(AMQPStreamConnection::class);
+ $connection->shouldReceive('channel')->once()->andReturn($channel);
+ $connection->shouldReceive('close')->once();
+ $channel->shouldReceive('close')->once();
+ $channel->shouldReceive('set_ack_handler')->once();
+ $channel->shouldReceive('set_nack_handler')->once();
+ $channel->shouldReceive('set_return_listener')->once();
+ $channel->shouldReceive('confirm_select')->once();
+ $channel->shouldReceive('basic_publish')->once();
+ $channel->shouldReceive('wait_for_pending_acks_returns')->once()->with(10);
+
+ $this->expectException(RuntimeException::class);
+ $this->makePublisher($connection)->publish('corpo', 1);
+ }
+
+ public function test_mandatory_sem_rota_e_rejeitado_mesmo_com_confirmacao_do_broker(): void
+ {
+ config()->set('rabbitmq.retry.exchange', 'exchange_notification_retry');
+ config()->set('rabbitmq.retry.routes', [1 => 'module_import_registration_draft_retry_1']);
+
+ $ack = null;
+ $returned = null;
+ $channel = Mockery::mock(AMQPChannel::class);
+ $connection = Mockery::mock(AMQPStreamConnection::class);
+ $connection->shouldReceive('channel')->once()->andReturn($channel);
+ $connection->shouldReceive('close')->once();
+ $channel->shouldReceive('close')->once();
+ $channel->shouldReceive('set_ack_handler')->once()->withArgs(function ($cb) use (&$ack): bool {
+ $ack = $cb;
+ return true;
+ });
+ $channel->shouldReceive('set_return_listener')->once()->withArgs(function ($cb) use (&$returned): bool {
+ $returned = $cb;
+ return true;
+ });
+ $channel->shouldReceive('set_nack_handler')->once();
+ $channel->shouldReceive('confirm_select')->once();
+ $channel->shouldReceive('basic_publish')->once();
+ $channel->shouldReceive('wait_for_pending_acks_returns')->once()->with(10)
+ ->andReturnUsing(function () use (&$ack, &$returned): void {
+ $returned(
+ 312,
+ 'NO_ROUTE',
+ 'exchange_notification_retry',
+ 'module_import_registration_draft_retry_1',
+ new AMQPMessage('corpo')
+ );
+ $ack(new AMQPMessage('corpo'));
+ });
+
+ $this->expectException(RuntimeException::class);
+ $this->makePublisher($connection)->publish('corpo', 1);
+ }
+
+ public function test_rejeita_rota_nao_configurada_sem_conectar_ao_broker(): void
+ {
+ config()->set('rabbitmq.retry.routes', [1 => 'primeiro_retry']);
+ $this->expectException(RuntimeException::class);
+
+ $publisher = new class extends ImportRegistrationRetryPublisher {
+ protected function openConnection(): AMQPStreamConnection
+ {
+ throw new RuntimeException('Não deveria tentar conectar');
+ }
+ };
+ $publisher->publish('corpo', 3);
+ }
+
+ private function makePublisher(AMQPStreamConnection $connection): ImportRegistrationRetryPublisher
+ {
+ return new class($connection) extends ImportRegistrationRetryPublisher {
+ public function __construct(private AMQPStreamConnection $connectionMock) {}
+
+ protected function openConnection(): AMQPStreamConnection
+ {
+ return $this->connectionMock;
+ }
+ };
+ }
+}
diff --git a/tests/Unit/Listeners/ImportRegistrationListenerTest.php b/tests/Unit/Listeners/ImportRegistrationListenerTest.php
deleted file mode 100644
index de3a345..0000000
--- a/tests/Unit/Listeners/ImportRegistrationListenerTest.php
+++ /dev/null
@@ -1,348 +0,0 @@
-byDefault();
- Log::shouldReceive('error')->byDefault();
- }
-
- /**
- * Teste: Envia email com sucesso para uma inscrição válida
- */
- public function test_sends_email_successfully_for_valid_registration()
- {
- // Arrange
- $registrationData = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ];
-
- $event = new ImportRegistrationEvent([$registrationData]);
- $listener = new ImportRegistrationListener();
-
- Log::shouldReceive('info')
- ->once()
- ->with('Enviando e-mail para: liliuchoa616@mail.com');
-
- Log::shouldReceive('info')
- ->once()
- ->with('Email enviado e auditado para: liliuchoa616@mail.com');
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica se o email foi enviado
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) use ($registrationData) {
- return $mail->hasTo('liliuchoa616@mail.com') &&
- $mail->content()->with['registration'] === 154069287 &&
- $mail->content()->with['opp_name'] === 'Teste de importe' &&
- $mail->content()->with['agent_name'] === 'MARIA LILIANA UCHOA DO NASCIMENTO';
- });
-
- // Assert: Verifica se foi criado o registro de auditoria
- $this->assertDatabaseHas('email_dispatches', [
- 'to' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'mailable_type' => ImporteRegistrationMail::class,
- ]);
- }
-
- /**
- * Teste: Envia múltiplos emails para múltiplas inscrições
- */
- public function test_sends_multiple_emails_for_multiple_registrations()
- {
- // Arrange
- $registrations = [
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ],
- [
- 'registration' => 154069288,
- 'opp_id' => 7302,
- 'opp_name' => 'Segunda oportunidade',
- 'number' => 'on-154026964',
- 'agent_name' => 'JOAO DA SILVA',
- 'agent_email' => 'joao.silva@example.com',
- ]
- ];
-
- $event = new ImportRegistrationEvent($registrations);
- $listener = new ImportRegistrationListener();
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica se ambos os emails foram enviados
- Mail::assertSent(ImporteRegistrationMail::class, 2);
-
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) {
- return $mail->hasTo('liliuchoa616@mail.com');
- });
-
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) {
- return $mail->hasTo('joao.silva@example.com');
- });
-
- // Assert: Verifica se ambos os registros de auditoria foram criados
- $this->assertDatabaseCount('email_dispatches', 2);
- }
-
- /**
- * Teste: Cria registro de auditoria no EmailDispatch ao enviar email
- */
- public function test_creates_email_dispatch_audit_record()
- {
- // Arrange
- $registrationData = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ];
-
- $event = new ImportRegistrationEvent([$registrationData]);
- $listener = new ImportRegistrationListener();
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica o registro de auditoria
- $this->assertDatabaseHas('email_dispatches', [
- 'to' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'subject' => 'Convite para uma nova fase',
- 'mailable_type' => ImporteRegistrationMail::class,
- ]);
-
- // Verifica os metadados salvos
- $emailDispatch = EmailDispatch::first();
- $this->assertEquals(154069287, $emailDispatch->meta['registration']);
- $this->assertEquals(7301, $emailDispatch->meta['opp_id']);
- $this->assertEquals('Teste de importe', $emailDispatch->meta['opp_name']);
- $this->assertEquals('on-154026963', $emailDispatch->meta['number']);
- $this->assertEquals('MARIA LILIANA UCHOA DO NASCIMENTO', $emailDispatch->meta['agent_name']);
- $this->assertEquals('liliuchoa616@mail.com', $emailDispatch->meta['agent_email']);
- $this->assertNotNull($emailDispatch->dispatched_at);
- }
-
- /**
- * Teste: Não envia email quando faltam campos obrigatórios
- */
- public function test_does_not_send_email_when_required_fields_are_missing()
- {
- // Arrange: Inscrição sem campo 'agent_email'
- $invalidRegistration = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- // 'agent_email' está faltando
- ];
-
- $event = new ImportRegistrationEvent([$invalidRegistration]);
- $listener = new ImportRegistrationListener();
-
- Log::shouldReceive('error')
- ->once()
- ->with('Chaves obrigatórias ausentes na inscrição: ' . json_encode($invalidRegistration));
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica que nenhum email foi enviado
- Mail::assertNotSent(ImporteRegistrationMail::class);
-
- // Assert: Verifica que nenhum registro de auditoria foi criado
- $this->assertDatabaseCount('email_dispatches', 0);
- }
-
- /**
- * Teste: Processa apenas inscrições válidas quando há mix de válidas e inválidas
- */
- public function test_processes_only_valid_registrations_in_mixed_batch()
- {
- // Arrange
- $registrations = [
- [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ],
- [
- // Inscrição inválida - faltando 'agent_email'
- 'registration' => 154069288,
- 'opp_id' => 7302,
- 'opp_name' => 'Segunda oportunidade',
- 'number' => 'on-154026964',
- 'agent_name' => 'JOAO DA SILVA',
- ],
- [
- 'registration' => 154069289,
- 'opp_id' => 7303,
- 'opp_name' => 'Terceira oportunidade',
- 'number' => 'on-154026965',
- 'agent_name' => 'PEDRO SANTOS',
- 'agent_email' => 'pedro.santos@example.com',
- ]
- ];
-
- $event = new ImportRegistrationEvent($registrations);
- $listener = new ImportRegistrationListener();
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica que apenas 2 emails foram enviados (os válidos)
- Mail::assertSent(ImporteRegistrationMail::class, 2);
-
- // Assert: Verifica que apenas 2 registros de auditoria foram criados
- $this->assertDatabaseCount('email_dispatches', 2);
-
- // Assert: Verifica que os emails corretos foram enviados
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) {
- return $mail->hasTo('liliuchoa616@mail.com');
- });
-
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) {
- return $mail->hasTo('pedro.santos@example.com');
- });
- }
-
- /**
- * Teste: Valida todos os campos obrigatórios
- */
- public function test_validates_all_required_fields()
- {
- // Arrange: Testa cada campo obrigatório faltando
- $requiredFields = ['registration', 'opp_id', 'opp_name', 'number', 'agent_name', 'agent_email'];
-
- foreach ($requiredFields as $missingField) {
- Mail::fake(); // Reseta o fake para cada iteração
-
- $incompleteRegistration = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ];
-
- // Remove o campo que está sendo testado
- unset($incompleteRegistration[$missingField]);
-
- $event = new ImportRegistrationEvent([$incompleteRegistration]);
- $listener = new ImportRegistrationListener();
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica que nenhum email foi enviado quando qualquer campo obrigatório está faltando
- Mail::assertNotSent(ImporteRegistrationMail::class,
- "Email não deveria ser enviado quando o campo '{$missingField}' está faltando"
- );
- }
- }
-
- /**
- * Teste: Verifica o conteúdo do email enviado
- */
- public function test_email_contains_correct_data()
- {
- // Arrange
- $registrationData = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ];
-
- $event = new ImportRegistrationEvent([$registrationData]);
- $listener = new ImportRegistrationListener();
-
- // Act
- $listener->handle($event);
-
- // Assert: Verifica todos os campos do email
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) use ($registrationData) {
- $content = $mail->content();
- return $content->with['registration'] === 154069287 &&
- $content->with['opp_id'] === 7301 &&
- $content->with['opp_name'] === 'Teste de importe' &&
- $content->with['number'] === 'on-154026963' &&
- $content->with['agent_name'] === 'MARIA LILIANA UCHOA DO NASCIMENTO' &&
- $content->with['agent_email'] === 'liliuchoa616@mail.com';
- });
-
- // Assert: Verifica o envelope (assunto)
- Mail::assertSent(ImporteRegistrationMail::class, function ($mail) {
- return $mail->envelope()->subject === 'Convite para uma nova fase';
- });
- }
-
- /**
- * Teste: Loga as informações corretas durante o envio
- */
- public function test_logs_correct_information_during_sending()
- {
- // Arrange
- $registrationData = [
- 'registration' => 154069287,
- 'opp_id' => 7301,
- 'opp_name' => 'Teste de importe',
- 'number' => 'on-154026963',
- 'agent_name' => 'MARIA LILIANA UCHOA DO NASCIMENTO',
- 'agent_email' => 'liliuchoa616@mail.com',
- ];
-
- $event = new ImportRegistrationEvent([$registrationData]);
- $listener = new ImportRegistrationListener();
-
- Log::shouldReceive('info')
- ->once()
- ->with('Enviando e-mail para: liliuchoa616@mail.com');
-
- Log::shouldReceive('info')
- ->once()
- ->with('Email enviado e auditado para: liliuchoa616@mail.com');
-
- // Act
- $listener->handle($event);
-
- // Assert implícito via Mockery
- $this->assertTrue(true);
- }
-}
diff --git a/tests/Unit/RabbitMQServiceTest.php b/tests/Unit/RabbitMQServiceTest.php
new file mode 100644
index 0000000..fc31424
--- /dev/null
+++ b/tests/Unit/RabbitMQServiceTest.php
@@ -0,0 +1,69 @@
+ false, 'rabbitmq.prefetch_count' => 25]);
+ $channel = Mockery::mock(AMQPChannel::class);
+ $channel->shouldReceive('exchange_declare')->once();
+ $channel->shouldReceive('queue_declare')->once();
+ $channel->shouldReceive('queue_bind')->once();
+ $channel->shouldReceive('basic_qos')->once()->with(0, 25, false);
+ $channel->shouldReceive('basic_consume')->once();
+ $channel->shouldReceive('is_consuming')->once()->andReturnFalse();
+ $channel->shouldReceive('close')->once();
+
+ $connection = Mockery::mock(AMQPStreamConnection::class);
+ $connection->shouldReceive('channel')->once()->andReturn($channel);
+ $connection->shouldReceive('close')->once();
+
+ $service = new class($connection) extends RabbitMQService
+ {
+ public int $connectionAttempts = 0;
+
+ public function __construct(private AMQPStreamConnection $connectionMock) {}
+
+ protected function openConnection(): AMQPStreamConnection
+ {
+ $this->connectionAttempts++;
+
+ return $this->connectionMock;
+ }
+ };
+
+ $service->consume('queue', 'exchange', 'route', static function (): void {});
+ $this->assertSame(0, $service->connectionAttempts);
+
+ config(['rabbitmq.enabled' => true]);
+
+ $service->consume('queue', 'exchange', 'route', static function (): void {});
+ $this->assertSame(1, $service->connectionAttempts);
+ }
+
+ public function test_falha_de_conexao_so_e_propagada_ao_iniciar_consumo(): void
+ {
+ config(['rabbitmq.enabled' => true]);
+
+ $service = new class extends RabbitMQService
+ {
+ protected function openConnection(): AMQPStreamConnection
+ {
+ throw new RuntimeException('Broker indisponível');
+ }
+ };
+
+ $this->expectExceptionMessage('Falha ao conectar ao RabbitMQ.');
+
+ $service->consume('queue', 'exchange', 'route', static function (): void {});
+ }
+}
|