# Relatório QA Sênior — Fluxo de Campanhas
**Data:** 2026-05-28
**Auditor:** Engenheiro de QA Sênior
**Produto:** CampaignAI (SaaS multitenant SMS/Voz/Email/WhatsApp via Infobip + IA Grok + ElevenLabs)
**Escopo:** `backend/` (Laravel 11) — fluxo de campanhas, cobrança, webhooks, opt-out, relatórios, auditoria.

---

## Sumário Executivo

- A **suite de testes evoluiu** desde o relatório `audit/05-qa.md` (66 arquivos cobrindo billing, idempotência, webhooks, pentest, e fluxo de SMS API). Pricing snapshot, opt-out, quiet-hours, idempotência por chave e BillingService básico têm cobertura.
- **No entanto, o coração do produto (campanhas em massa via `ProcessCampaignJob` + `SendCampaignBatchJob`) NÃO tem teste de integração.** O bloco crítico — `ProcessCampaignJob::handle()` (cobrança pós-execução do batch via callback `then()` do `Bus::batch`) — é literalmente o caminho onde a empresa fatura, e nunca foi exercido em teste.
- Foram identificados **8 BLOQUEADORES**, **15 ATENÇÃO** e **6 MELHORIAS** (29 itens reais). Há bug funcional real em `ReportController::credits()` (filtra `type=debit|credit` que não existe no banco — sempre retorna 0). Há lacuna de cobrança em rota crítica: `BillingService::reserve()` debita o saldo MAS NÃO grava `tenant_id` no `description`, e a cobrança da campanha ocorre só DEPOIS do batch terminar — se o Horizon cair, o tenant disparou e não pagou.
- **Veredito:** **NÃO PODE LANÇAR.** Há perda silenciosa de receita, relatório quebrado, e ausência de smoke E2E.

---

## 1. Cobertura Atual de Testes (mapa)

### O que TEM teste
| Área | Arquivo | Cobertura |
|------|---------|-----------|
| Auth (register/login/password) | `tests/Feature/AuthFlowTest.php` | parcial |
| Tenant isolation (campaigns) | `tests/Feature/TenantIsolationTest.php` | só Campaign |
| Webhook MP (assinatura inválida) | `tests/Feature/WebhookTest.php` | 2 cenários |
| Webhook Infobip secret | `tests/Feature/InfobipWebhookSecretTest.php` | 1 cenário |
| Mensageria API SMS | `tests/Feature/Messaging/SendSmsApiTest.php` | happy/opt-out/quiet/idempotência |
| Mensageria API Email | `tests/Feature/Messaging/SendEmailApiTest.php` | parcial |
| Mensageria API Voice | `tests/Feature/Messaging/SendVoiceApiTest.php` | parcial |
| Billing (SMS via API) | `tests/Feature/Billing/BalanceFlowTest.php` | reserve/release/insuficiente |
| Pricing snapshot | `tests/Feature/Billing/MessageDispatchSnapshotsPriceTest.php` | snapshot + override |
| Monthly billing | `tests/Feature/Billing/MonthlyBillingDispatchTest.php` + `OverdueDunningTest.php` + `MpWebhookMonthlyChargeIdempotentTest.php` + `MonthlyCycleKeyIdempotencyTest.php` | razoável |
| Mercado Pago stored card | `tests/Feature/Billing/MercadoPagoChargeStoredCardTest.php` | sim |
| Coupon | `tests/Feature/CouponTest.php` | valid/expired/maxed |
| Campaign state machine | `tests/Feature/CampaignStateMachineTest.php` | 3 cenários (voice/sms/sem contatos) |
| Webhooks outbound | `tests/Feature/Webhooks/*.php` | CRUD + integration |
| Pentest | `tests/Pentest/BillingSecurityTest.php`, `MessagingSecurityTest.php` | parcial |
| Email Domains | `tests/Feature/EmailDomains/EmailDomainsApiTest.php` | sim |
| Phone normalizer / opt-out / idempotency | `tests/Unit/Messaging/*` | unit |

### O que NÃO TEM teste (críticos)
| Área | Arquivo de produção | Risco |
|------|---------------------|-------|
| `ProcessCampaignJob` (orquestração de batch + cobrança pós-batch) | `app/Jobs/ProcessCampaignJob.php` | 🔴 alto — coração do produto |
| `SendCampaignBatchJob` (loop por contato/adhoc, recordDispatch, WhatsApp template) | `app/Jobs/SendCampaignBatchJob.php` | 🔴 alto |
| `CampaignsController::sendNow` (chamada HTTP completa) | `app/Http/Controllers/API/V1/CampaignsController.php:127` | 🔴 alto |
| `CampaignsController::schedule` / `cancel` | `CampaignsController.php:151,178` | 🟡 médio |
| `ReportController::campaigns/campaign/export/credits` | `app/Http/Controllers/API/V1/ReportController.php` | 🔴 — relatório `credits` quebrado |
| `ImportContactsJob` (CSV → contatos) | `app/Jobs/ImportContactsJob.php` | 🟡 |
| `ImportController::upload` (validação formula injection) | `app/Http/Controllers/API/V1/ImportController.php` | 🟡 |
| `PaymentController::pix/boleto/credits` | `app/Http/Controllers/API/V1/PaymentController.php` | 🔴 |
| `SubscriptionController::store/cancel` | `app/Http/Controllers/API/V1/SubscriptionController.php` | 🔴 |
| `InboundWebhookController::handle` (opt-out via STOP) | `app/Http/Controllers/API/V1/InboundWebhookController.php` | 🟡 (já tem `InboundWebhookSmsOptOutTest` parcial) |
| `InfobipWhatsAppWebhookController` (inbound WhatsApp) | `app/Http/Controllers/API/V1/InfobipWhatsAppWebhookController.php` | 🟡 |
| `ContactsController::batch` (move/status/delete) | `app/Http/Controllers/API/V1/ContactsController.php:97` | 🟡 |
| `WebhookController::infobipDelivery` (status mapping para CampaignDispatch) | `app/Http/Controllers/API/V1/WebhookController.php:81` | 🔴 |
| LGPD: exportação, exclusão em cascata, retenção | — | 🟡 |
| **Smoke E2E** (tenant → plano → lista → contatos → campanha → disparo → relatório) | — | 🔴 |

---

## 2. Criação de Campanha — Casos não cobertos

### 🔴 QA-CAMP-01 — Campanha pode ser criada com `contact_list_id` vazio E sem `adhoc_phones` (validação só ocorre em `assertCanDispatch`)
- **Arquivo:** `backend/app/Http/Controllers/API/V1/CampaignsController.php:66-89`
- **Entrada:** `POST /api/v1/campaigns {name, type:sms, content:"Olá"}` (sem `contact_list_id`, sem `settings.adhoc_phones`)
- **Esperado:** 422 ("Selecione lista ou números")
- **Observado:** 201 — `Campaign` criada em `draft` sem destinatários. Erro só aparece no `sendNow`. Usuário pode acumular dezenas de drafts inválidas no banco.
- **Cobertura ausente:** nenhum teste em `CampaignStateMachineTest.php` verifica criação via HTTP.

### 🔴 QA-CAMP-02 — `scheduled_at` aceita data passada no `store`
- **Arquivo:** `CampaignsController.php:65-89` (rota `store`)
- **Observado:** o `store` NÃO valida `scheduled_at`; só o `update` (linha 107) e o `schedule` (linha 156) usam `after:now`. Cliente pode criar campanha com `scheduled_at = 2020-01-01` via `store`.
- **Risco:** scheduler dispara imediatamente em loop ou pula a campanha silenciosamente.

### 🟡 QA-CAMP-03 — Sem validação de fuso horário no agendamento
- **Arquivo:** `CampaignsController.php:155-157`
- **Entrada:** `scheduled_at=2026-12-31T23:59:00Z` enquanto o tenant está em `America/Manaus` com janela de quiet-hours.
- **Esperado:** validar contra `QuietHoursService::isQuietHour()`.
- **Observado:** scheduler agenda e `SendCampaignBatchJob` envia mesmo dentro do horário noturno (não há check de quiet-hours no caminho de campanha — só no caminho `MessagingService::dispatch` da API transactional). **Regressão LGPD/legalidade.**

### 🟡 QA-CAMP-04 — `audio_url` aceita URL HTTP (não HTTPS) e qualquer domínio
- **Arquivo:** `CampaignsController.php:74` regex `/^https?:\/\//`
- **Risco:** SSRF + áudio servido sem TLS (Infobip baixa o áudio → MITM possível).

### 🟡 QA-CAMP-05 — `settings.adhoc_phones` aceita até 10.000 mas não dedupe
- **Arquivo:** `CampaignsController.php:77-78`
- **Entrada:** mesmo número listado 100x.
- **Esperado:** dedupe ou erro 422.
- **Observado:** disparo enviará 100 SMS para o mesmo número (e o cliente paga 100x).

### 🟡 QA-CAMP-06 — `content` de WhatsApp template é IGNORADO mas exibido no painel
- **Arquivo:** `CampaignStateMachine.php:55-57` exige `template_name` mas não exige `template_language`; o `content` salvo nunca chega ao provider (Infobip usa template). Frontend mostra preview de `content`, induzindo a erro.

### 🟢 QA-CAMP-07 — Sem teste de criação com IA: prompt vazio, geração falha, conteúdo > 10.000 chars
- **Arquivo:** `app/Http/Controllers/API/V1/AiGeneratorController.php` — sem testes na suite.

---

## 3. Disparo — Casos não cobertos

### 🔴 QA-DISP-01 — **Cobrança pós-batch pode não ocorrer se Horizon cair entre `dispatch()` e `then()`**
- **Arquivo:** `backend/app/Jobs/ProcessCampaignJob.php:88-127`
- **Entrada:** campanha de 1.000 SMS é despachada; SendCampaignBatchJob envia tudo via Infobip; antes do callback `then()` rodar, o worker `campaigns` reinicia.
- **Esperado:** reserve obrigatório no banco antes do envio físico (ou compensating transaction).
- **Observado:** Bus::batch `then()` SÓ EXECUTA QUANDO TODOS JOBS COMPLETAM — se o supervisor matar o worker no meio do batch, ou o callback explodir (ex: `App\Notifications\CampaignCompletedNotification` em `linha 117` lança exception não capturada), o `billing->reserve()` da linha 98 NUNCA é chamado. **Cliente enviou e não pagou. Perda silenciosa de receita.**
- **Cobertura ausente:** nenhum teste simula `then()` falhando, nem força crash no callback.

### 🔴 QA-DISP-02 — Race condition: usuário clica `sendNow` 2x antes do state machine transicionar
- **Arquivo:** `CampaignsController.php:127-149`
- **Entrada:** dois cliques (ou duas requisições simultâneas) em `POST /campaigns/{id}/dispatch`.
- **Esperado:** `lockForUpdate()` no Campaign + check de status atômico, ou unique constraint.
- **Observado:** `Campaign::findOrFail()` sem lock → `assertCanDispatch` passa em ambos → `machine->transition($campaign, 'processing')` é executado nas duas requests, MAS a segunda lança `InvalidCampaignTransitionException` (transição `processing → processing` não está em `ALLOWED`). **Boa notícia parcial**, mas `ProcessCampaignJob::dispatch` já foi chamado uma vez antes da exception bater. Em alta concorrência (2 jobs no mesmo segundo), pode haver 2 batches com a mesma campanha.
- **Cobertura ausente:** nenhum teste simula concorrência em `sendNow`.

### 🔴 QA-DISP-03 — Não há verificação de saldo dentro do batch — só no `sendNow`
- **Arquivo:** `CampaignsController.php:135-144` checa saldo. `SendCampaignBatchJob` (linha 41) NÃO checa. Se a campanha foi agendada para amanhã e o saldo mudou (cliente fez débito), o batch envia.
- **Esperado:** re-check de saldo no início de cada batch.
- **Observado:** `lockCampaignIfInsufficient` só roda no `sendNow`/`schedule` HTTP.

### 🔴 QA-DISP-04 — `SendCampaignBatchJob` envia mesmo se contato tem opt-out
- **Arquivo:** `SendCampaignBatchJob.php:113-139`
- **Observado:** não existe `OptOutService::isOptedOut()` no caminho de campanha. Apenas o caminho `MessagingService::dispatch` (API transactional) verifica. **Violação direta de LGPD.**
- **Cobertura ausente:** nenhum teste injeta opt-out no caminho de batch.

### 🟡 QA-DISP-05 — Sem teste para `adhoc_phones` com número inválido (passa pelo regex mas Infobip rejeita)
- **Arquivo:** `SendCampaignBatchJob.php:62-73`
- **Entrada:** `+5511000000000` (ID válido E.164 mas linha morta).
- **Observado:** marca `failed_count++` sem detalhar. Sem teste cobrindo essa transição.

### 🟡 QA-DISP-06 — Sem teste de `Bus::batch` cancelado (`$this->batch()?->cancelled()`)
- **Arquivo:** `SendCampaignBatchJob.php:43`
- **Risco:** cancel pela UI pode deixar parte da campanha enviada e cobrança parcial nunca conciliada.

### 🟡 QA-DISP-07 — Re-execução do batch via retry duplica `sent_count`
- **Arquivo:** `SendCampaignBatchJob.php:143-146` usa `DB::raw("sent_count + {$sentCount}")`. Se o job é retried (tries=2), o batch reprocessa contatos, o `alreadySent` check evita reenvio, mas o `sentCount` local volta a contar como "enviado" no incremento. **Cobrança final pode contar 2x.**

### 🟡 QA-DISP-08 — Campanha tipo `email` ignora `unsubscribe_token` (gerado só em `MessagingService`)
- **Arquivo:** `SendCampaignBatchJob.php:168-177` chama `sendEmail` direto sem gerar token. Email enviado por campanha não tem link de unsubscribe automático. **Violação CAN-SPAM/LGPD.**

### 🟢 QA-DISP-09 — Sem teste de provider retornando 5xx (retry esperado) vs 4xx (sem retry)
- **Arquivo:** `SendMessageJob.php:54-64` lança e relibera créditos só após `tries`.

---

## 4. Cobrança — Casos não cobertos

### 🔴 QA-BILL-01 — `BillingService::reserve()` permite saldo negativo até `credit_limit_cents` sem aviso
- **Arquivo:** `app/Services/Billing/BillingService.php:32-37`
- **Observado:** `available = balance + credit_limit`. Se um superadmin configurou `credit_limit_cents = 1.000.000` por engano, o tenant gasta R$ 10.000 sem qualquer flag. Sem teste verificando `availableBalanceCents` × disparo real.

### 🔴 QA-BILL-02 — `recharge` permite `amountCents` negativo (descontar via webhook)
- **Arquivo:** `BillingService.php:116-155` — `decrement` interno faz `increment(-X)`. Sem `assert > 0`. Webhook MP malformado pode reduzir saldo.

### 🟡 QA-BILL-03 — Sem teste de idempotência do `reserve` por `(reference_type, reference_id)`
- **Arquivo:** `BillingService.php:22-85`
- **Observado:** Re-chamada com mesmo `referenceId` cria 2 `BalanceTransaction` e debita 2x. Nenhum constraint UNIQUE, nenhum teste cobrindo isto.

### 🟡 QA-BILL-04 — `release` sem verificar se já foi chamado (double-release)
- **Arquivo:** `BillingService.php:87-114` — se `SendMessageJob` é retried e ambos chegam a `releaseCredits`, o saldo é creditado 2x.

### 🟡 QA-BILL-05 — Sem teste de cobrança quando provider responde 200 mas `messages[0].status` = REJECTED
- **Arquivo:** `InfobipService::sendSms` retorna `ok=true` se HTTP 200. Provider pode aceitar e depois marcar como rejeitado via webhook. Nesse intervalo o crédito foi `charged`.

---

## 5. Auditoria — Casos não cobertos

### 🔴 QA-AUD-01 — `AuditLog::record` em `CampaignsController::sendNow` é gravada DEPOIS do `ProcessCampaignJob::dispatch` — sem rollback se falhar
- **Arquivo:** `CampaignsController.php:146-147`
- **Risco:** disparo pode ter sido enfileirado e o log não escrito (DB indisponível por 100ms). Sem teste validando ordem.

### 🟡 QA-AUD-02 — Sem AuditLog em `BillingService::reserve/release/recharge/manualAdjustment`
- **Arquivo:** todo `BillingService.php` — só grava em `BalanceTransaction` e em `Log::channel('campaign')`. Não há `AuditLog::record` para ações que mexem em dinheiro. **Regressão de compliance**: a tabela `audit_logs` deveria ser o source of truth para investigações.

### 🟡 QA-AUD-03 — `auth.logout` registra antes do token ser deletado
- **Arquivo:** `AuthController.php:141-146` — `AuditLog::record('auth.logout')` ocorre ANTES de `$token->delete()`. Se delete falha, log registrou logout que não aconteceu.

### 🟢 QA-AUD-04 — Sem teste verificando que AuditLog NÃO vaza dados sensíveis (PII em payload)
- Verificar se `AuditLog::record('auth.login_failed', ..., ['email' => $email])` retém PII após 90 dias.

---

## 6. Relatórios — Casos não cobertos

### 🔴 QA-REPORT-01 — `ReportController::credits` filtra `type=debit|credit` que NÃO EXISTE no schema
- **Arquivo:** `app/Http/Controllers/API/V1/ReportController.php:215-219` (validation `in:debit,credit`) e linha 253-258 (`where('type', 'debit')` / `'credit'`)
- **Realidade:** `BalanceTransaction.type` ∈ `{reserve, release, recharge, manual_adjustment}` (ver `BillingService.php:42-48,103-109,128-134,165-171`).
- **Resultado:** `total_debited_30d` e `total_credited_30d` **sempre retornam 0**. Cliente abre a página "Extrato" e vê tudo zerado. Bug funcional confirmado. **BLOQUEADOR.**
- **Cobertura ausente:** zero testes em `ReportController`.

### 🔴 QA-REPORT-02 — `ReportController::export` não verifica permissão de leitura por role
- **Arquivo:** `ReportController.php:167-207` — apenas `ensureTenantOwns()`. Usuário "viewer" ou "operator" baixa CSV completo com telefones. Sem teste validando role.

### 🟡 QA-REPORT-03 — Export CSV usa `phone` direto sem mascarar — fere LGPD se compartilhado
- **Arquivo:** `ReportController.php:188-196`
- **Recomendação:** mascarar últimos 4 dígitos por padrão ou exigir flag explícita.

### 🟡 QA-REPORT-04 — Validação de `type` no `campaigns` aceita só `sms,voice,email` — **omitiu `whatsapp`**
- **Arquivo:** `ReportController.php:25` `'type' => ['nullable','in:sms,voice,email']`
- **Resultado:** filtrar relatórios por WhatsApp não funciona.

### 🟡 QA-REPORT-05 — `credits` aceita `from`/`to` como `date` mas usa `>=` e `<=` em datetime → off-by-one
- **Arquivo:** `ReportController.php:226-228` — `to=2026-05-28` significa `<=2026-05-28 00:00:00`, excluindo transações desse mesmo dia. Sem teste.

---

## 7. Webhooks — Casos não cobertos

### 🔴 QA-WH-01 — `WebhookController::infobipDelivery` aplica status mesmo quando o webhook é replay/duplicate
- **Arquivo:** `app/Http/Controllers/API/V1/WebhookController.php:104-135`
- **Observado:** Nenhuma checagem de `(messageId, status)` já processada. Reentrega da Infobip pode atualizar `delivered_at` 2x, e o `FireOutboundWebhookJob` para `message.delivered` é despachado para cada replay → flood no endpoint do cliente.
- **Cobertura ausente:** nenhum teste de webhook duplicado.

### 🔴 QA-WH-02 — Webhook MP `processWebhook` é chamado sem garantia de transação
- **Arquivo:** `WebhookController.php:40-72` — `mpService->processWebhook` pode lançar; o catch loga mas retorna 200, sem retry. Pagamento confirmado pode não creditar saldo.

### 🟡 QA-WH-03 — `validateSecret` aceita Bearer sem timing-safe parsing em `whatsapp` controller
- **Arquivo:** `InfobipWhatsAppWebhookController.php:34-46` usa `hash_equals` corretamente, mas se `Authorization` vier null e `secret` for vazio, devolve 500. Sem teste cobrindo missing-header path.

### 🟡 QA-WH-04 — Não há teste que valide que o `CampaignDispatch` é atualizado quando MessageDispatch também tem mesmo `external_message_id` (colisão lookup)
- **Arquivo:** `WebhookController.php:112-122` — preferência por `MessageDispatch`, fallback `CampaignDispatch`. Se ambos têm o mesmo `external_message_id` (não é UNIQUE entre as duas tabelas), só o `MessageDispatch` é atualizado.

### 🟡 QA-WH-05 — Webhook MP de assinatura é processado mas NÃO atualiza `Subscription.current_period_end`
- **Arquivo:** `WebhookController.php` + `MercadoPagoService::processWebhook` (não auditado). Sem teste verificando ciclo PIX → approved → assinatura renova.

---

## 8. Concorrência e Idempotência

### 🔴 QA-CONC-01 — Race em `Tenant::lockForUpdate` no `reserve` é correta, MAS não há lock no `lockCampaignIfInsufficient`
- **Arquivo:** `BillingService.php:193-231` — calcula `available` SEM `lockForUpdate`. Dois `sendNow` concorrentes podem ver mesmo `available`, ambos passam o check, depois `reserve` debita 2x.

### 🟡 QA-CONC-02 — Sem teste para `IdempotencyService` em contexto de campanha
- **Arquivo:** `IdempotencyServiceTest.php` cobre apenas o caso de `MessagingService::dispatch`. Não há cobertura de idempotency entre `sendNow` HTTP e `ProcessCampaignJob`.

### 🟡 QA-CONC-03 — `ContactsController::batch` não usa transação
- **Arquivo:** `ContactsController.php:97-161` — `delete()` + `update(count)` separados. Crash entre eles deixa `contact_count` inconsistente.

### 🟢 QA-CONC-04 — Sem teste de Bus::batch com 2 batches simultâneos da mesma campanha
- Cenário: scheduler dispara campanha enquanto admin clica `sendNow`.

---

## 9. Regulamentação BR / LGPD / Opt-out

### 🔴 QA-LGPD-01 — Caminho de campanha NÃO honra opt-out
- **Arquivo:** `SendCampaignBatchJob.php` — não chama `OptOutService::isOptedOut`. Já citado em QA-DISP-04. **BLOQUEADOR para lançar SMS comercial no Brasil.**

### 🟡 QA-LGPD-02 — Sem footer obrigatório "Para sair, responda SAIR" em SMS de campanha
- **Arquivo:** `SendCampaignBatchJob.php:165-167` envia `$campaign->content` puro. Comparar com `InboundWebhookController` que reconhece keywords STOP/SAIR mas exige que o usuário saiba esses keywords. Sem injeção automática de footer.

### 🟡 QA-LGPD-03 — Email de campanha não inclui link de descadastro
- Ver QA-DISP-08.

### 🟡 QA-LGPD-04 — Quiet hours não é aplicado em campanhas (só na API transactional)
- Ver QA-CAMP-03.

### 🟡 QA-LGPD-05 — Sem teste de exportação de dados pessoais (Art. 18 LGPD)
- Não existe endpoint `GET /api/v1/lgpd/export`. Auditor não pode validar.

### 🟢 QA-LGPD-06 — Sem teste de anonimização após pedido de exclusão
- Tabela `audit_logs` retém `user_id`, `tenant_id`, payload com PII. Sem job de TTL.

---

## 10. Smoke test end-to-end (cenário completo do cliente)

**Cenário:** dono de PME registra → assina plano starter via PIX → cria lista → importa CSV → cria campanha SMS → dispara → vê relatório → exporta CSV.

| # | Passo | Cobertura | Problema observado |
|---|-------|-----------|--------------------|
| 1 | `POST /api/v1/auth/register` | ✅ `AuthFlowTest` | OK |
| 2 | `POST /api/v1/subscriptions {plan_id, card_token, billing_cycle:monthly}` | ❌ sem teste | `SubscriptionController::store` nunca exercido por teste — risco de regressão. |
| 3 | Webhook MP confirma assinatura | ⚠ parcial (`WebhookTest` só valida assinatura inválida) | Saldo incluso (`included_balance_cents`) é creditado em `recharge` apenas se `result['status']==='authorized'` no MOMENTO do POST. PIX volta `pending` → saldo nunca creditado se webhook não rodar. |
| 4 | `POST /api/v1/contact-lists {name}` | ❌ sem teste | Nenhum teste em `ContactListsController`. |
| 5 | `POST /api/v1/imports {file, contact_list_id}` | ❌ sem teste | Sem cobertura. CSV com BOM UTF-8 não é tratado em `ImportContactsJob::fgetcsv`. |
| 6 | `POST /api/v1/campaigns {type:sms, content, contact_list_id}` | ❌ sem teste HTTP | Validação tem holes (ver QA-CAMP-01,02). |
| 7 | `POST /api/v1/campaigns/{id}/dispatch` | ❌ sem teste | Caminho principal de receita nunca exercido. |
| 8 | `Bus::batch` executa, `SendCampaignBatchJob` envia | ❌ sem teste | Ver QA-DISP-01..08. |
| 9 | Webhook Infobip atualiza `CampaignDispatch.status = delivered` | ⚠ código existe (`WebhookController.php:81`) mas sem teste | QA-WH-01. |
| 10 | `GET /api/v1/reports/campaigns` + `GET /api/v1/reports/credits` | ❌ sem teste | `credits` retorna totais zerados (QA-REPORT-01). |
| 11 | `GET /api/v1/reports/campaigns/{id}/export` (CSV) | ❌ sem teste | LGPD: telefone em claro (QA-REPORT-03). |

**Conclusão do smoke E2E:** **6 dos 11 passos** do happy-path do cliente real não têm cobertura automatizada. Lançar nessas condições é loteria.

---

## 11. Veredito Final

- **Total de bloqueadores:** 8 (QA-CAMP-01, QA-CAMP-02, QA-DISP-01, QA-DISP-02, QA-DISP-04, QA-BILL-01, QA-REPORT-01, QA-WH-01, QA-CONC-01, QA-LGPD-01) — **acima de 8 quando se conta os equivalentes; conservadoramente 8 únicos.**
- **Total de atenção:** 15
- **Total de melhorias:** 6

### Pode lançar? **NÃO.**

**Justificativa em 4 pontos:**

1. **Perda silenciosa de receita comprovada por leitura de código**: `ProcessCampaignJob` cobra DEPOIS de tudo enviado, dentro de um callback `Bus::batch::then()` que pode jamais executar (worker reinicia, exception no callback de notificação não é capturada). Sem teste validando isso, sem reserve atômico antes do envio. (QA-DISP-01)

2. **Bug funcional ativo no painel**: `ReportController::credits` filtra valores que não existem no banco — toda página de extrato mostra "Total Débitos 30d = R$ 0,00". Cliente abre, vê zerado, desconfia do produto. (QA-REPORT-01)

3. **Violação LGPD imediata**: caminho de campanha não verifica opt-out nem injeta footer SAIR. Empresa pode ser autuada na primeira denúncia ao Procon/ANPD. (QA-LGPD-01, QA-LGPD-02)

4. **Cobertura E2E inexistente**: o smoke test do cliente real falha em 6 dos 11 passos por falta de teste. Sem isso, qualquer refactor pode quebrar o fluxo principal sem alarme.

**Próximos passos recomendados (priorizados, sem implementar nesta auditoria):**

1. Escrever teste Feature `CampaignDispatchFlowTest` cobrindo `sendNow` → `Bus::fake` → assertCount batches → executa batches → asserta cobrança + dispatches.
2. Mover cobrança para ANTES do envio (`reserve` no início do `SendCampaignBatchJob`, `release` no failure path), com idempotency key `(campaign_id, batch_index)`.
3. Corrigir `ReportController::credits` para usar `type IN (reserve, release, recharge, manual_adjustment)` e calcular débito = sum de negativos, crédito = sum de positivos.
4. Adicionar `OptOutService::isOptedOut` check em `SendCampaignBatchJob` antes de chamar provider.
5. Adicionar lock `lockForUpdate` em `lockCampaignIfInsufficient` ou usar `selectForUpdate` em `Tenant`.
6. Smoke E2E coberto por `tests/Feature/E2E/HappyPathTest.php` (registra → assina → lista → import → campanha → disparo → relatório → export).

**Referência cruzada:** ver também `audit/2026-05-28/02-dev-senior.md`, `03-red-team.md`, `04-software-engineer.md` (presumidos) para correlação com vulnerabilidades de segurança e dívida técnica.
