# Ouvidoria — Testes

> **Slug:** `Ouvidoria` · **Versão analisada:** `lotep` (Laravel 5.5.50 / PHP 7.4)
>
> Estratégia, casos existentes, casos que faltam e as regressões obrigatórias. Os IDs de bug
> citados aqui são os de `08-GAPS-E-DIVIDAS.md`.

---

## 1. Estratégia

| Tipo | Ferramenta | Escopo | Onde roda |
|---|---|---|---|
| Feature (controller) | PHPUnit + `Tests\TestCase` + `DatabaseTransactions` | actions do CMS e do portal, banco real | `tests/Feature/V2/Modules/Servicos/Ouvidoria/` |
| Unit (importação) | PHPUnit + `LegacyOwnTableImporterTestCase` | `OuvidoriaLegacyImporter` (map/load/dryRun) | `tests/Unit/V2/Modules/Infraestrutura/MigracaoLegada/` |
| Feature (HTTP/RBAC) | PHPUnit + `DatabaseTransactions` | matriz de acesso por permissão | `tests/Feature/V2/Rbac/CommonUserModuleAccessE2ETest.php` |
| Feature (migração) | PHPUnit | endpoints do painel de importação contam destino | `tests/Feature/V2/Modules/Infraestrutura/MigracaoLegada/LegacyNovosImportadoresEndpointsTest.php` |

**Justificativa:** o módulo não tem camada de domínio isolável; o valor está no comportamento do
controller e no SQL. Por isso a maior parte é Feature. O importador é testado em Unit porque o
`map()` é puro e o `load()` é transacional.

**Ambiente:** `DatabaseTransactions` (não `RefreshDatabase`) nos testes atuais; banco PostgreSQL
de teste; `Sentinel` real para o CMS; `PermissionService` com `V2AuditLogger` *mockado*;
`view()->prependNamespace('v2-ouvidoria', [__DIR__.'/stubs'])` para substituir as views reais
por stubs (`tests/Feature/V2/Modules/Servicos/Ouvidoria/stubs/`).

---

## 2. Cobertura atual

### 2.1 `tests/Feature/V2/Modules/Servicos/Ouvidoria/OuvidoriaCmsControllerTest.php` (14 casos)

| # | Caso | O que valida |
|---|---|---|
| 1 | `test_index_retorna_view` | `index()` devolve `v2-ouvidoria::cms.requests.index` com permissão de leitura |
| 2 | `test_index_filtra_por_busca` | busca por `Joao` retorna 1 de 2 registros |
| 3 | `test_show_retorna_view_com_model` | `show()` devolve a view e o model correto |
| 4 | `test_update_status_altera_status` | `status 1 → 2` persiste e devolve 302 |
| 5 | `test_update_status_para_tramitando` | `status 2 → 1` persiste |
| 6 | `test_sem_permissao_read_aborta_403` | `v2_can` falso → `HttpException` |
| 7 | `test_sem_permissao_write_aborta_403` | `updateStatus` sem permissão → exceção |
| 8 | `test_portal_store_cria_manifestacao` | cria com nome/e-mail/telefone/descrição |
| 9 | `test_portal_store_com_relacionamentos` | persiste as quatro FKs |
| 10 | `test_portal_store_anonimo` | injeta `is_anonymous` e valida protocolo `OUV-` |
| 11 | `test_portal_store_validation_descricao_obrigatoria` | `ValidationException` sem descrição |
| 12 | `test_portal_store_validation_email_invalido` | `ValidationException` com e-mail inválido |
| 13 | `test_show_inexistente_aborta_404` | `ModelNotFoundException` |
| 14 | `test_update_status_inexistente_aborta_404` | `ModelNotFoundException` |

Os testes chamam os controllers **diretamente** (`new OmbudsmanRequestCmsController(...)`,
`new OmbudsmanPortalController()`), não via HTTP. Consequência: CSRF, throttle, middleware de
permissão e renderização real das views **não** são exercitados.

### 2.2 `tests/Unit/V2/Modules/Infraestrutura/MigracaoLegada/OuvidoriaLegacyImporterTest.php` (7 casos)

| # | Caso | O que valida |
|---|---|---|
| 1 | `test_key_e_counts` | `key() === 'ouvidoria'` e contagens de origem/destino |
| 2 | `test_map_campo_a_campo` | protocolo, PII, status por nome, IP em hash, taxonomias e respostas |
| 3 | `test_map_invalido_lanca_invalid_argument_exception` | linha sem `id`/`protocol` é rejeitada |
| 4 | `test_load_persiste_no_destino` | grava solicitação, dicionários e resposta |
| 5 | `test_load_segunda_vez_devolve_null` | idempotência por `v1_id` |
| 6 | `test_dry_run_nao_grava_no_destino` | `dryRun()` não escreve |
| 7 | `test_arquivo_fisico_ausente_nao_impede_migracao` | anexo ausente não aborta |

### 2.3 Cobertura indireta

| Arquivo | O que cobre |
|---|---|
| `tests/Feature/V2/Rbac/CommonUserModuleAccessE2ETest.php:67` | HTTP real em `v2.ouvidoria.requests.index` com perfil canônico (`Ouvidoria`), slug legado (`ouvidoria`) e sem permissão (302) |
| `tests/Feature/V2/Modules/Infraestrutura/MigracaoLegada/LegacyNovosImportadoresEndpointsTest.php:74` | painel de migração reconhece `v2_ombudsman_requests` como destino da chave `ouvidoria` |
| `tests/Unit/V2/Modules/Institucional/BasicPage/Controllers/BasicPageCmsControllerTest.php:128` | apenas *mocka* a rota `v2.ouvidoria.portal.index` para montar o menu; não testa o módulo |

**Lacunas evidentes:** não há teste para `answer()`, throttle, CSRF, mass assignment, consentimento,
busca composta, protocolo duplicado, dicionário inativo, módulo desativado, anonimato com PII,
respostas múltiplas nem renderização real das views.

---

## 3. Fixtures e factories

Não há factories no módulo. Os testes montam registros à mão:

| Fixture | Como é criada | Campos obrigatórios |
|---|---|---|
| Manifestação | `OmbudsmanRequest::create([...])` no helper `createRequest()` | `id`, `description`; o restante é opcional |
| Dicionários | `OmbudsmanManifestationType::create([...])` e equivalentes | `id`, `title`, `is_active` |
| Papel V2 | `V2Role::create(['permissions_v2' => ['Ouvidoria' => 'write']])` | `name`, `slug`, `permissions_v2` |
| Usuário | `new App\Admin\User([...])` + `v2_role_id` + role Sentinel `operador` | `name`, `email`, `password` |
| UUID | helper `generateUuid()` (v4 manual) | — |
| Views | `tests/Feature/V2/Modules/Servicos/Ouvidoria/stubs/cms/requests/show.blade.php` e `tests/Feature/V2/Modules/Servicos/Ouvidoria/stubs/portal/index.blade.php` | recebem as variáveis mínimas |

No V7, criar `SolicitacaoFactory`, `TipoFactory`, `RespostaFactory` e usar
`CoreTestCase`/`RefreshDatabase` do `tests/TestCase.php`.

---

## 4. Casos de teste

### 4.1 Fluxo principal

| # | Cenário | Arrange | Act | Assert | Prioridade |
|---|---|---|---|---|---|
| 1 | Portal cria com os quatro dicionários | dicionários ativos | `POST /ouvidoria` | 302 + linha com FKs + protocolo `OUV-\d{8}-\w{8}` | alta |
| 2 | Portal cria sem dados pessoais | só descrição | `POST /ouvidoria` | 302 + `name/email/phone` nulos | alta |
| 3 | CMS lista | 2 manifestações | `GET /cms/.../requests` | 200 + 2 linhas + paginação 20 | alta |
| 4 | CMS busca por protocolo | 2 registros | `search=OUV-...` | 1 linha | alta |
| 5 | CMS busca por e-mail | 2 registros | `search=maria@` | 1 linha | média |
| 6 | CMS altera status para 3 | `status=3` | `POST .../status` | linha com `status=3` + auditoria | alta |
| 7 | CMS responde e escolhe desfecho | `answer` + `desfecho=indeferida` | `POST .../resposta` | resposta criada + `status=3` + `respondido_em` | alta |
| 8 | Acompanhamento por protocolo | protocolo existente | `POST /ouvidoria/acompanhar` | status/datas/resposta, sem PII | alta |

### 4.2 Validação

| # | Cenário | Dado inválido | Esperado | Prioridade |
|---|---|---|---|---|
| 1 | Descrição obrigatória | `description=''` | `ValidationException` / 422 | alta |
| 2 | E-mail inválido | `email='nao-e-email'` | erro no campo | alta |
| 3 | Tipo inexistente | UUID aleatório | erro em `tipo_id` | alta |
| 4 | Tipo inativo | id ativo=false | erro em `tipo_id` | alta |
| 5 | Endereço acima de 500 | 501 caracteres | erro de validação, **não** 500 de banco | média |
| 6 | Consentimento ausente | sem `aceite_lgpd` | erro (salvo anônimo) | alta |
| 7 | Honeypot preenchido | `website='x'` | rejeição silenciosa/422 | média |
| 8 | Time-trap | envio em < 3 s | rejeição | média |
| 9 | Status fora do enum | `status=99` | erro 422 | alta |
| 10 | Status ausente | POST sem `status` | erro 422 (não reset para 1) | alta |
| 11 | Protocolo malformado na consulta | `protocolo=ABC` | erro de formato | média |

### 4.3 Autorização

| # | Cenário | Usuário | Rota | Esperado | Prioridade |
|---|---|---|---|---|---|
| 1 | Sem sessão no CMS | anônimo | `GET /cms/ouvidoria` | redirect login | alta |
| 2 | Sem permissão | perfil vazio | `GET /cms/ouvidoria` | 302 dashboard | alta |
| 3 | Apenas leitura | `ouvidoria.ver` | `GET /cms/ouvidoria` | 200 | alta |
| 4 | Apenas leitura tenta responder | `ouvidoria.ver` | `POST .../resposta` | 403 | alta |
| 5 | Edição | `ouvidoria.editar` | `POST .../status` | 302 + persistência | alta |
| 6 | Arquivar | `ouvidoria.arquivar` | `POST .../arquivar` | 302 | média |
| 7 | Portal público | anônimo | `POST /ouvidoria` | aceito (com throttle) | alta |
| 8 | Módulo desativado | qualquer | `GET /ouvidoria` e `/cms/ouvidoria` | 404 nas **duas** superfícies | alta |

### 4.4 Casos de borda

| # | Cenário | Entrada | Esperado |
|---|---|---|---|
| 1 | Busca `"0"` | `search=0` | não devolver tudo por engano |
| 2 | Curinga do usuário | `search=%` | buscar literal `%`, não tudo |
| 3 | Busca + status | `search=Joao&status=3` | resultado respeita **os dois** filtros |
| 4 | Registro soft-deleted que casa na busca | `deleted_at` preenchido + nome | não aparece |
| 5 | Protocolo duplicado | criar dois com mesmo prefixo de UUID | retry ou erro tratado, sem 500 |
| 6 | Anônimo com PII | `anonimo=1` + nome/e-mail | PII anulada antes do INSERT |
| 7 | Resposta duplicada | duas respostas | ambas persistidas e ordenadas por `created_at` |
| 8 | Reabrir respondida | `status=aberta` após resposta | permitido/negado conforme a política escolhida |
| 9 | Dicionário excluído | soft delete com manifestações | título continua legível ou vira “—”, sem erro |
| 10 | Paginação com filtros | página 2 + search + sort | query string preservada |

### 4.5 Regressão (bugs conhecidos)

| # | Bug | Teste que falha antes da correção | Referência |
|---|---|---|---|
| 1 | Mass assignment de `status`/`protocol`/`is_anonymous` | `POST /ouvidoria` com `status=3&protocol=X&is_anonymous=1`; esperar `status=1`, protocolo gerado e `is_anonymous=false` | G-01 |
| 2 | `updateStatus` sem validação | `POST .../status` com `status=99` e sem `status`; esperar 422 nos dois | G-02 |
| 3 | Resposta força status 2 | responder com `desfecho=indeferida`; esperar `status=3` | G-03 |
| 4 | `scopeSearch` sem grupo | `search=Joao&status=3`; esperar apenas o registro do status 3 | G-04 |
| 5 | Consentimento descartado | `POST` com `aceite_lgpd=1`; esperar `consentimento` persistido com data/versão | G-05 |
| 6 | IP cru | `POST`; esperar `ip_hash` de 64 chars e nenhuma coluna de IP cru | G-06 |
| 7 | Protocolo gerado no model/colisão | dois `create()` no mesmo dia com UUIDs de mesmo prefixo; esperar segundo protocolo ou erro tratado | G-07 |
| 8 | Escrita sem transação | simular falha no update de status; esperar rollback da resposta | G-08 |
| 9 | Portal ignora módulo desativado | desativar módulo; esperar 404 no `GET` e no `POST` público | G-09 |
| 10 | N+1 na lista | contar queries ao listar 10 registros; esperar constante | G-10 |

---

## 5. Como rodar

```bash
# suíte do módulo (Feature)
vendor/bin/phpunit tests/Feature/V2/Modules/Servicos/Ouvidoria/OuvidoriaCmsControllerTest.php

# um caso
vendor/bin/phpunit --filter=test_portal_store_cria_manifestacao

# importação legada
vendor/bin/phpunit tests/Unit/V2/Modules/Infraestrutura/MigracaoLegada/OuvidoriaLegacyImporterTest.php

# RBAC HTTP (inclui a Ouvidoria)
vendor/bin/phpunit tests/Feature/V2/Rbac/CommonUserModuleAccessE2ETest.php
```

O projeto não usa `php artisan test` (recurso posterior ao Laravel 5.5); o runner é o PHPUnit do
`vendor/bin`. O `TestCase` usa `DatabaseTransactions`, portanto o banco de teste precisa estar
migrado.

---

## 6. Lacunas e prioridades

| Prioridade | Lacuna | Ação |
|---|---|---|
| alta | `answer()` não tem teste | cobrir resposta simples, múltiplas e transação |
| alta | Portal não é testado via HTTP | usar `$this->post('/ouvidoria/store')` e asserts de sessão/redirect |
| alta | Throttle não é testado | 11 POSTs no mesmo IP → 429 no 11º |
| alta | Mass assignment não é testado | caso de regressão 1 |
| alta | Consentimento LGPD não é testado | caso de regressão 5 |
| alta | Módulo desativado no portal | caso de regressão 9 |
| média | Views reais nunca renderizadas | remover stubs e assertar conteúdo/escape |
| média | Busca composta | casos 3 e 4 de borda |
| média | Protocolo duplicado | caso 5 de borda |
| baixa | Acessibilidade automatizada | axe/DOM no V7 (ADR-025) |

No V7, a meta de cobertura é a de ADR-038 (90% com rigor de não regressão): todo bug crítico
listado em `08-GAPS-E-DIVIDAS.md` precisa de um teste que falhe antes e passe depois.
