# Testes — Licitações

## 1. Estratégia atual

O módulo tem quatro arquivos de teste, todos no repositório `lotep/`:

| Arquivo | Tipo | Casos | Abordagem |
|---|---|---:|---|
| `tests/Feature/V2/Modules/Publicacoes/Licitacoes/LicitacaoCmsControllerTest.php` | Feature (sem HTTP) | 11 | instancia o controller e chama actions diretamente |
| `tests/Feature/V2/Modules/Publicacoes/Licitacoes/LicitacaoPortalControllerTest.php` | Feature (sem HTTP) | 5 | instancia o controller e verifica a view |
| `tests/Unit/V2/Modules/Publicacoes/Licitacoes/LicitacaoAttachmentServiceTest.php` | Unit com banco | 20 | serviço de anexos + `Storage::fake` |
| `tests/Unit/V2/Modules/Publicacoes/Licitacoes/LicitacaoModelRegressionTest.php` | Unit com banco | 20 | model, escopos, mutators e slug |

Todos usam `Tests\TestCase`, `DatabaseTransactions` e o banco PostgreSQL de
teste. Não são testes de unidade puros: mesmo os “Unit” tocam o banco. Não há
factory; os registros são criados com `Licitacao::create([...])`.

### 1.1 Setup comum do CMS

`LicitacaoCmsControllerTest::setUp()`:

1. cria um mock de `V2AuditLogger` com `activity`, `info` e `log` tolerantes;
2. registra um singleton de `PermissionService` com esse logger;
3. cria `V2Role` com `permissions_v2 = ['licitacoes' => 'write']`;
4. cria `App\Admin\User` com `v2_role_id` e o associa ao papel Sentinel
   `operador` (cria o papel se não existir);
5. substitui `LicitacaoAttachmentService` por um mock com os métodos públicos
   tolerantes;
6. instancia o controller manualmente com o mock, o serviço mockado e
   `CmsActionLogger`.

Esse setup explica por que o teste **não** cobre o upload real: o serviço é
mockado. O `PermissionService` real, por outro lado, é usado — o que dá alguma
cobertura ao RBAC.

### 1.2 O que os testes realmente provam

- **CMS**: os métodos devolvem o nome de view esperado; `store` insere a linha
  e gera slug; `toggleStatus` alterna; `destroy` preenche `deleted_at`;
  `restore` limpa; sem permissão, `create()` lança `HttpException`; upload
  apenas devolve `RedirectResponse` (serviço mockado).
- **Portal**: a listagem devolve a view; a consulta filtra ativas (mas o teste
  de “apenas ativas” não inspeciona os dados da view, só o nome); o detalhe
  resolve por slug; inativa e inexistente lançam `ModelNotFoundException`.
- **Model**: `strip_tags` e `html_entity_decode` no objeto; fillable completo;
  UUID de 36 caracteres no formato v4; slug gerado/único/atualizado ao mudar
  número; slug manual preservado; relações instanciáveis; escopos
  `active`/`search`/`bySlug`; `public_url`; campos legados; soft delete.
- **Anexos**: MIME inválido rejeitado; magic bytes de PDF/MP3/MP4 (offset 4)/
  WAV aceitos e rejeitados; `order`; `updateTitle`; `reorder`; delete com soft
  delete; contagem por licitação; URL, display name, tamanho e ícone.

### 1.3 O que **não** é testado

| Lacuna | Por quê importa |
|---|---|
| requisições HTTP reais (`$this->get/post`) | middleware, CSRF, redirects, status codes não são exercitados |
| permissão `read` em rotas de escrita | o teste só verifica ausência total de permissão |
| `archived` com paginação/busca | não há asserts sobre conteúdo |
| `forceDelete` | nenhum teste; o risco de arquivo órfão passa despercebido |
| upload real pelo controller | serviço mockado; integração controller → serviço não é coberta |
| reorder com ids de outra licitação | o filtro silencioso não é verificado |
| createInline de órgão | sem teste de feature |
| órgãos soft-deleted em licitações | comportamento ambíguo não é fixado |
| slug ao editar número/título | o bug do `static::$oldSlug` não é pego (o teste só verifica que mudou) |
| objeto em texto puro no portal | o renderer devolve vazio e nenhum teste verifica o HTML |
| filtros `orgao`/`status` do CMS | o controller ignora, e não há teste que falhe |
| XSS no objeto | não há teste de payload `<script>` |
| anexo com magic bytes de WebP falso | a checagem só de `RIFF` não é testada |
| concorrência de slug | única parcial do banco não é exercitada |

## 2. Casos existentes (Arrange / Act / Assert)

### 2.1 CMS

| Teste | Arrange | Act | Assert |
|---|---|---|---|
| `test_index_returns_view` | login Sentinel | `index(new Request())` | view `v2-licitacoes::cms.index` |
| `test_archived_returns_view` | login | `archived()` | view `cms.archived` |
| `test_create_returns_view` | login + write | `create()` | view `cms.create` |
| `test_store_creates_licitacao` | mock de `LicitacaoRequest` com `number/title/object` | `store()` | redirect + linha no banco |
| `test_store_generates_slug` | cria via model | `fresh()` | slug `^[a-z0-9-]+$` |
| `test_toggle_status` | linha inativa | `toggleStatus()` | `is_active = true` |
| `test_toggle_status_deactivates` | linha ativa | `toggleStatus()` | `is_active = false` |
| `test_destroy_soft_deletes` | linha viva | `destroy()` | `deleted_at` não nulo |
| `test_restore_restores_deleted` | linha deletada | `restore()` | `deleted_at` nulo |
| `test_sem_permissao_aborta_403` | sem login/perfil | `create()` | `HttpException` |
| `test_upload_attachment_delegates_to_service` | `UploadedFile::fake()->create('test.pdf', 100, 'application/pdf')` | `uploadAttachment()` | `RedirectResponse` |

### 2.2 Portal

| Teste | Arrange | Act | Assert |
|---|---|---|---|
| `test_index_returns_view_with_licitacoes` | uma ativa | `index()` | view `portal.index` |
| `test_index_only_shows_active` | uma ativa, uma inativa | `index()` | view correta (não inspeciona a coleção) |
| `test_show_returns_view_by_slug` | uma ativa com AST JSON | `show($slug)` | view `portal.show` |
| `test_show_404_for_inactive` | uma inativa | `show($slug)` | `ModelNotFoundException` |
| `test_show_404_for_nonexistent_slug` | — | `show('nao-existe-abc')` | `ModelNotFoundException` |

### 2.3 Serviço de anexos

| Teste | Foco |
|---|---|
| `test_upload_creates_anexo` | persistência com título e MIME |
| `test_upload_stores_file` | `Storage::disk('public')->assertExists` |
| `test_upload_rejects_invalid_mime` | `application/x-executable` → `RuntimeException` |
| `test_upload_validates_magic_bytes_pdf` | conteúdo texto com MIME PDF → exceção |
| `test_upload_accepts_valid_mp3_magic_bytes` | frame sync `FF FB` |
| `test_upload_rejects_invalid_mp3_magic_bytes` | texto puro |
| `test_upload_accepts_valid_mp4_magic_bytes_at_offset` | `ftyp` no offset 4 |
| `test_upload_rejects_invalid_mp4_magic_bytes` | `notftyp` no offset 4 |
| `test_upload_accepts_valid_wav_magic_bytes` | `RIFF` |
| `test_upload_sets_order` | `order = 5` |
| `test_update_title` | título atualizado |
| `test_reorder` | dois anexos, ordem invertida |
| `test_delete_removes_file_and_record` | `assertSoftDeleted` |
| `test_licitacao_has_many_anexos` | dois uploads → count 2 |
| `test_anexo_url_uses_v2_storage_url` | URL não vazia |
| `test_anexo_display_name_prefers_title` | título vence |
| `test_anexo_display_name_falls_back_to_original` | fallback |
| `test_anexo_file_size_formatted` | 2048 → `2 KB` |
| `test_anexo_icon_for_pdf` | `bi-file-earmark-pdf` |
| `test_anexo_icon_for_image` | `bi-file-earmark-image` |

### 2.4 Model

| Teste | Foco |
|---|---|
| `test_strips_html_from_object_field_on_save` | `<p>Compra de <strong>materiais</strong></p>` → `Compra de materiais` |
| `test_decodes_html_entities_in_object_field` | `Aquisi&ccedil;&atilde;o` → `Aquisição` |
| `test_preserves_plain_text_in_object_field` | texto sem tags permanece |
| `test_has_complete_fillable_array` | 21 campos |
| `test_uses_uuid_as_primary_key` | `incrementing=false`, `keyType=string`, tabela |
| `test_generates_uuid_on_create` | 36 chars, formato v4 |
| `test_generates_slug_from_number_and_title` | contém `aquisicao-de-materiais` |
| `test_generates_unique_slug` | duas iguais → slugs diferentes |
| `test_updates_slug_on_number_change` | slug muda |
| `test_preserves_manual_slug` | slug manual não é sobrescrito |
| `test_belongs_to_status` | relação existe |
| `test_has_many_anexos` | relação existe |
| `test_belongs_to_many_orgaos` | relação existe |
| `test_scope_active_filters` | ativa sim, inativa não |
| `test_scope_search_by_number` | encontra por número |
| `test_scope_search_by_title` | encontra por título |
| `test_scope_by_slug` | encontra por slug |
| `test_get_public_url_attribute` | URL contém slug |
| `test_allows_mass_assignment_of_legacy_fields` | `local`, `management_unit`, `v1_id` |
| `test_soft_delete` | `find` nulo, `withTrashed` presente |

## 3. Fixtures e factories necessárias para o V7

Não existe factory no legado. No V7, criar factories por modelo (exemplo
ilustrativo):

```php
final class LicitacaoFactory extends Factory
{
    protected $model = Licitacao::class;

    public function definition(): array
    {
        return [
            'number' => '001/2026',
            'title' => fake()->sentence(4),
            'slug' => null, // a Action gera
            'object' => [
                'type' => 'doc',
                'content' => [[
                    'type' => 'paragraph',
                    'content' => [['type' => 'text', 'text' => fake()->sentence(12)]],
                ]],
            ],
            'value' => 15000.00,
            'show_value' => true,
            'is_active' => true,
            'status' => 'aberta',
        ];
    }

    public function inativa(): static
    {
        return $this->state(fn () => ['is_active' => false]);
    }

    public function semValor(): static
    {
        return $this->state(fn () => ['show_value' => false]);
    }
}
```

Fixtures de referência:

- **órgãos**: “Secretaria de Educação”, “Secretaria de Saúde” e um órgão
  inativo, para testar filtro e associação;
- **anexos**: um PDF pequeno com bytes `%PDF-1.4`, um ZIP real e um arquivo
  com extensão `.pdf` e conteúdo texto (para rejeição);
- **AST**: documento com parágrafo, título e lista; documento vazio; AST
  inválido para o teste de fallback;
- **status**: os três valores canônicos e um inválido para o CHECK;
- **perfis**: `ver`, `editar`, `aprovar`, `arquivar`, e um perfil sem acesso.

No V7, uploads não devem usar `Storage::fake('public')`: usar o contrato
`MediaIngestor` com um dublê em memória ou `Storage::fake('s3')`, conforme o
padrão dos testes de Notícias.

## 4. Casos recomendados no V7

### 4.1 Feature CMS

| Caso | Arrange | Act | Assert |
|---|---|---|---|
| visitante é redirecionado ao login | sem sessão | `GET /cms/licitacoes` | 302 para login |
| perfil `ver` não cria | papel com `licitacoes.ver` | `POST /cms/licitacoes` | 403 |
| perfil `editar` cria | papel com `editar` | `POST` válido | 302 + banco |
| slug gerado e único | duas criações com mesmo título | `POST` duas vezes | slugs `x` e `x-2` |
| slug preservado ao editar sem mudar número | criação + `PUT` só do título | `PUT` | slug inalterado |
| slug não recebe `-1` ao editar o mesmo registro | criação + `PUT` alterando número | `PUT` | nenhum sufixo artificial |
| objeto AST é persistido como jsonb | AST válido | `POST` | `object` array no banco |
| objeto inválido é rejeitado | `object` string | `POST` | erro de validação |
| valor oculto não aparece | `show_value=false` | `GET` portal | HTML sem `R$` |
| ativar exige `aprovar` | papel `editar` | `POST /publicacao/publicar` | 403 |
| arquivar exige `arquivar` | papel `editar` | `DELETE` | 403 |
| restaurar volta à lista | arquivada | `POST restore` | 302 + visível |
| force delete remove objetos do Media | anexo + anexo soft-deleted | `DELETE` definitivo | ambos os objetos removidos do dublê |
| órgão inline | nome novo | `POST` JSON | 201/200 + checkbox no HTML |

### 4.2 Feature portal

| Caso | Assert |
|---|---|
| listagem pagina | 16 ativas → 15 na página 1, 1 na página 2 |
| filtro por órgão no servidor | só licitações do órgão |
| busca por número/título/objeto | resultados corretos |
| detalhe por slug | 200, objeto renderizado |
| inativa | 404 |
| slug inexistente | 404 real com `X-Robots-Tag: noindex` |
| valor oculto | ausência de `R$` e de `offers` no JSON-LD |
| anexos ordenados | ordem de `order` |
| acessibilidade | `<h1>` único, `<caption>`, links com contexto |

### 4.3 Unit

| Caso | Foco |
|---|---|
| `SaveLicitacao` gera slug transacional | colisão + retry |
| `SaveLicitacao` sincroniza órgãos | ids removidos/adicionados |
| `ToggleLicitacaoPublicacao` | alterna e audita |
| `DeleteLicitacao` | carrega `withTrashed` e limpa mídia |
| `ReorderLicitacaoAnexos` | rejeita id de outra licitação |
| `AddLicitacaoAnexo` | allowlist de tipo e payload do Media |
| `RichTextRenderer::toText` no `object_text` | texto sem marcação |
| enum de status | valor inválido rejeitado |

## 5. Comandos

No repositório legado:

```bash
cd /home/one/projects/maxima/v7/lotep
vendor/bin/phpunit --no-coverage --filter Licitacao
vendor/bin/phpunit --no-coverage --filter 'LicitacaoModelRegressionTest|LicitacaoAttachmentServiceTest'
vendor/bin/phpunit --no-coverage
```

O último comando é o gate completo: baseline declarado de **650 testes / 2107
asserções / 9 skipped** (`V2/TEST_BASELINE.md`). Não há e2e Playwright de
Licitações em `tests-e2e/`.

No V7:

```bash
cd /home/one/projects/maxima/v7/public-v7
php artisan test --filter=Licitacao
make check          # gates do projeto (PHPStan max, cobertura, CS)
```

A cobertura de 90% e o mutation testing são gates do V7 (ADR-038/ADR-039);
os testes de Licitações devem nascer com esse patamar, não com o baseline
antigo.

## 6. Observações sobre o valor probatório dos testes atuais

- Os testes de feature chamam controllers diretamente; **não** passam por
  middleware, então o 403 testado é o do `v2_can`, não o do
  `V2PermissionMiddleware`.
- `test_index_only_shows_active` não inspeciona a coleção da view; passaria
  mesmo se a query retornasse inativas.
- `test_destroy_soft_deletes` não verifica anexos nem storage.
- `test_upload_attachment_delegates_to_service` prova apenas que o controller
  chama o mock; não prova upload, MIME, magic bytes ou persistência.
- O teste de slug não cobre edição do mesmo registro; por isso o defeito do
  `static::$oldSlug` permanece invisível.
- Não há teste de XSS; o mutator remove tags, mas o portal imprime HTML do
  renderer — a segurança depende do parser, não do mutator.
