# Licitantes Sancionados — Estratégia de Testes

> A v5 **não possui nenhum teste** para este módulo (o único teste unitário do
> legado é `v5/tests/Unit/CnpjAlphanumericValidationTest.php`; a suíte `Feature` só
> tem `ExampleTest.php`). O plano abaixo é para a **v7** e serve também como
> roteiro de validação manual do comportamento herdado.

---

## 1. Estratégia

| Camada | Ferramenta | Escopo |
|---|---|---|
| **Feature (portal)** | PHPUnit/Pest + `RefreshDatabase` | Rotas públicas `/licitantes-sancionados` e `/licitante-sancionado/{slug}`, filtros, paginação, download de anexo |
| **Feature (CMS)** | PHPUnit/Pest + `RefreshDatabase` | CRUD completo, upload de anexos, permissões (Policy), exclusão lógica |
| **Unit (model)** | PHPUnit | Geração de `slug`, `casts` de data, `SoftDeletes` |
| **Unit (request)** | PHPUnit | Regras do Form Request e mensagens |
| **Manual** | Checklist no CMS/portal | Regressão visual (templates do portal, máscaras, DataTable) |

Comandos (v7, Laravel 11/12):

```bash
# suíte completa
php artisan test

# apenas o módulo
php artisan test --filter=SanctionedBidder

# pasta específica
php artisan test tests/Feature/SanctionedBidder
php artisan test tests/Unit/SanctionedBidder

# cobertura
php artisan test --coverage --min=80
```

Configuração do `phpunit.xml` na v7 (mesma convenção já usada em `v5/phpunit.xml`):
`DB_CONNECTION=pgsql` apontando para o banco de teste, `CACHE_DRIVER=array`,
`SESSION_DRIVER=array`, `QUEUE_CONNECTION=sync`, `FILESYSTEM_DISK=public` com
`Storage::fake('public')` nos testes de upload.

## 2. Fixtures / factories

```php
// database/factories/SanctionedBidderFactory.php
namespace Database\Factories;

use App\Models\SanctionedBidder;
use Illuminate\Database\Eloquent\Factories\Factory;

class SanctionedBidderFactory extends Factory
{
    protected $model = SanctionedBidder::class;

    public function definition(): array
    {
        return [
            'year'              => 2026,
            'sanction_number'   => '002/2026',
            'company_name'      => 'Construções Ação & Cia Ltda',
            'tax_id'            => '12.345.678/0001-95',
            'sanction_starts_at' => '2026-03-01',
            'sanction_ends_at'  => '2026-12-31',
        ];
    }

    public function encerrada(): static
    {
        return $this->state(fn () => ['sanction_ends_at' => '2025-12-31']);
    }
}
```

Estado do banco v5 relevante para a migração de fixtures: a tabela
`licitantes_sancionados` está **vazia** (0 linhas) e a view
`licitantes_sancionados_view` existe com a mesma estrutura. Não há seeder na v5 —
a v7 deve incluir um seeder mínimo para o ambiente de demonstração.

Para a v7, o seeder de permissões precisa criar o resource correspondente
(na v5 ele **não existe** — ver G5):

```php
// database/seeders/SanctionedBidderPermissionsSeeder.php (esboço)
Resource::firstOrCreate(
    ['slug' => 'sanctioned-bidders'],
    ['name' => 'Licitantes Sancionados', 'description' => 'Publicação de sanções a licitantes']
);
```

## 3. Casos de teste — Model (Unit)

| # | Caso | Arrange | Act | Assert |
|---|---|---|---|---|
| U1 | Slug gerado na criação | Model novo com `company_name = 'Construções Ação & Cia Ltda'` | `SanctionedBidder::create([...])` | `slug === 'construcoes-acao-cia-ltda-1'` (sem acento, hífen, id ao final) |
| U2 | Slug **não** é recalculado no update | Registro com slug `x` | `update(['company_name' => 'Outro Nome'])` | `slug` permanece `x` (comportamento novo; na v5 mudaria) — **decisão E1/M1 a confirmar** |
| U3 | Slug é único | Dois registros com nomes iguais | `create` duas vezes | slugs distintos (id incluso) e sem `QueryException` no índice único |
| U4 | Cast de data | Registro com `sanction_starts_at = '2026-03-01'` | `$model->fresh()` | `instanceof Carbon` e `->format('d/m/Y') === '01/03/2026'` |
| U5 | Soft delete | Registro criado | `delete()` | `trashed() === true`; `SanctionedBidder::count()` não muda; `onlyTrashed()->count()` === 1 |
| U6 | Filtro `ano` | 2 registros de 2025 e 1 de 2026 | `where('year', 2026)->get()` | `count() === 1` |
| U7 | Anexos | Registro com 2 anexos | `$bidder->attachments` | `count() === 2`, ordem preservada |

## 4. Casos de teste — Request (Unit)

| # | Caso | Arrange | Act | Assert |
|---|---|---|---|---|
| R1 | `ano` obrigatório | Payload sem `year` | `Validator::make(...)` | falha em `year` |
| R2 | `ano` fora da faixa | `year = 1800` | validar | falha (`min:1900`) |
| R3 | `ano` não numérico | `year = 'abc'` | validar | falha (`integer`) |
| R4 | `numero_sancao` longo | 51 caracteres | validar | falha (`max:50`) |
| R5 | `cpf_cnpj` longo | 19 caracteres | validar | falha (`max:18`) |
| R6 | Data em formato errado | `sanction_starts_at = '2026-03-01'` | validar | falha (`date_format:d/m/Y`) |
| R7 | Data em `d/m/Y` | `01/03/2026` | validar | passa |
| R8 | Fim antes do início | início `10/03/2026`, fim `01/03/2026` | validar | falha (`after_or_equal`) — **regra nova** |
| R9 | Anexo não-PDF | arquivo `.txt` | validar | falha (`mimes:pdf`) |
| R10 | Anexo grande | PDF de 11 MB | validar | falha (`max:10240`) |
| R11 | Anexo válido | PDF de 1 MB | validar | passa |
| R12 | Anexos opcionais | sem arquivo | validar | passa (a v5 exige `arquivo` nullable → passa; a mensagem `arquivo.required` da v5 é **inalcançável** — ver G3) |
| R13 | Descrição longa | 256 caracteres | validar | falha (`max:255`) |

## 5. Casos de teste — Portal (Feature)

| # | Caso | Arrange | Act | Assert |
|---|---|---|---|---|
| P1 | Listagem pública | 3 registros | `GET /licitantes-sancionados` | 200; sees os nomes; título "Licitantes Sancionados" |
| P2 | Paginação | 20 registros | `GET /licitantes-sancionados` | 15 itens na 1ª página; link para a 2ª; `assertSee('de 20 registros')` |
| P3 | Filtro por ano | registros de 2025 e 2026 | `GET ?ano=2026` | apenas os de 2026 |
| P4 | Filtro por busca (palavra única) | registro "CONSTRUTORA X LTDA" | `GET ?busca=construtora` | encontra o registro |
| P5 | Filtro por busca (multi-palavra) | registro "JOAO DA SILVA LTDA" | `GET ?busca=joao%20da%20silva` | **encontra** o registro (na v5 **não** encontra — bug A1; este teste deve falhar na v5 e passar na v7) |
| P6 | Filtro por busca (acentuação) | registro "AÇÃO LTDA" | `GET ?busca=acao` | encontra o registro |
| P7 | Filtro por data inicial | registro com início 10/03/2026 | `GET ?dt_inicio=01/03/2026&dt_fim=31/03/2026` | encontra o registro (filtro é por `inicio_sancao`) |
| P8 | Filtro por data exclui | mesmo registro | `GET ?dt_inicio=01/04/2026` | não encontra |
| P9 | Registro sem registros | base vazia | `GET /licitantes-sancionados` | 200; "Nenhuma informação encontrada!" |
| P10 | Ficha por slug | registro com slug | `GET /licitante-sancionado/{slug}` | 200; mostra ano, número, nome, CPF/CNPJ, datas |
| P11 | Ficha com slug inexistente | — | `GET /licitante-sancionado/inexistente` | 404 |
| P12 | Ficha de registro excluído | registro com `deleted_at` | `GET /licitante-sancionado/{slug}` | 404 (soft delete respeitado) |
| P13 | Download de anexo | registro com 1 anexo | `GET /storage/docs/xxx.pdf` | 200 e conteúdo do PDF (valida `storage:link`) |
| P14 | Descrição aparece no link | anexo com descrição "Processo 123/2026" | `GET` da ficha | exibe "Processo 123/2026" como rótulo |
| P15 | Filtros preservados na paginação | — | `GET ?ano=2026` e seguir o link da página 2 | a query string mantém `ano=2026` |
| P16 | Busca por CPF/CNPJ | registro com `12.345.678/0001-95` | `GET ?busca=12.345.678` | encontra o registro |

## 6. Casos de teste — CMS (Feature)

| # | Caso | Arrange | Act | Assert |
|---|---|---|---|---|
| C1 | Criar sem anexo | usuário `super-admin` | `POST /cms/licitantes-sancionados` com payload válido | 302 para `licitante-sancionado.index`; registro criado; `arquivo` NULL |
| C2 | Criar com 2 anexos | idem | `POST` com `arquivo[]` × 2 e `arquivo_desc[]` × 2 | `arquivo` contém 2 itens; `path` começa com `docs/`; cada `description` corresponde |
| C3 | Criar inválido | idem | `POST` sem `numero_sancao` | 302 de volta; erros no sumário; nenhum registro criado |
| C4 | Editar mantendo anexos | registro com 1 anexo | `PUT` com 1 novo anexo | `arquivo` passa a ter **2** itens (soma, não substitui) |
| C5 | Editar sem novo anexo | registro com 1 anexo | `PUT` sem `arquivo` | `arquivo` mantém 1 item |
| C6 | Remover anexo | registro com 2 anexos | `DELETE /cms/licitantes-sancionados/{id}/arquivo/0` | 200 `{"success": ...}`; `arquivo` com 1 item; **arquivo físico ainda existe** (comportamento da v5 — a v7 deve removê-lo: M5) |
| C7 | Remover anexo inexistente | registro com 1 anexo | `DELETE .../arquivo/9` | 404 `{"erro": "Arquivo não encontrado."}` |
| C8 | Remover o último anexo | registro com 1 anexo | `DELETE .../arquivo/0` | `arquivo` fica NULL |
| C9 | Excluir registro | registro existe | `DELETE /cms/licitantes-sancionados/excluir/{id}` | 200 `{"success": ...}`; `deleted_at` preenchido; some do portal |
| C10 | Excluir inexistente | — | `DELETE .../excluir/999999` | 500 `{"erro": ...}` (o `findOrFail` lança) |
| C11 | DataTable do CMS | 2 registros | `GET /cms/datatable/licitante-sancionado` | 200; JSON com `recordsTotal = 2`; contém `value`, `ano`, `nome_razao_social` |
| C12 | DataTable exclui deletados | 1 ativo + 1 deletado | `GET /cms/datatable/licitante-sancionado` | `recordsTotal = 1` (filtro `deleted_at IS NULL` da view) |
| C13 | Usuário sem permissão | papel sem `sanctioned-bidders.index` | `GET /cms/licitantes-sancionados` | 403 (v7) / redirect com warning (v5) |
| C14 |.visitante não autenticado no CMS | — | `GET /cms/licitantes-sancionados` | redirect para login |
| C15 | Botão "Cadastrar" só para autorizado | papel sem `create` | `GET /cms/licitantes-sancionados` | não vê o botão |
| C16 | Botão excluir só para autorizado | papel sem `delete` | `GET /cms/datatable/licitante-sancionado` | `delete = false` na linha |
| C17 | Log de atividade | usuário autenticado | `POST` de criação | 1 entrada no log `created` (v7; **não existe** na v5 — A8) |
| C18 | Slug visível no payload | — | `GET /api/v1/licitantes-sancionados` (v7) | `slug` presente e único |

## 7. Cenários manuais de regressão (v5 → v7)

| # | Cenário | Resultado esperado |
|---|---|---|
| M1 | Ativar cada template do portal (`configurations.layout` = `modelo1`..`modelo6`) e abrir `/licitantes-sancionados` | A página renderiza em todos; os **filtros de data** só aparecem em 4 de 6 (comportamento da v5 a ser **corrigido**) |
| M2 | Trocar o layout para `default` ou `newsbit` e abrir a rota | Na v5: erro *View not found*; na v7: página renderiza (view única, fora do eixo de template) |
| M3 | Baixar um anexo pelo portal | O `public/storage` precisa existir; o nome exibido é a descrição ou o nome do arquivo |
| M4 | Maskar CPF/CNPJ no formulário | Alterna entre `000.000.000-009` e `AA.AAA.AAA/AAAA-00` conforme o usuário digita |
| M5 | Exportar o DataTable do CMS em XLSX/PDF/CSV/TXT | O arquivo sai com o nome "Prefeitura de {cidade} - {data}" e **todos** os registros (não só a página) |
| M6 | Renomear um sancionado e reabrir a URL antiga | Na v5 a URL antiga deixa de funcionar (slug recalculado); na v7 o slug é estável |
| M7 | Inserir manualmente um registro com `slug` NULL | Na v5 a rota do portal não o encontra; na v7 a regra de negócio gera o slug |

## 8. Dados de teste para o ambiente de homologação

| Registro | `ano` | `numero_sancao` | `nome_razao_social` | `cpf_cnpj` | início | fim | Anexo |
|---|---|---|---|---|---|---|---|
| 1 | 2026 | `002/2026` | `Construções Ação & Cia Ltda` | `12.345.678/0001-95` | 01/03/2026 | 31/12/2026 | 2 PDFs |
| 2 | 2025 | `117/2025` | `JOÃO DA SILVA LTDA` | `12.345.678/0001-95` | 15/08/2025 | 14/02/2026 | 1 PDF |
| 3 | 2024 | `088/2024` | `Fornecedor de Serviços Eireli` | `98.765.432/0001-10` | 10/12/2024 | 09/12/2025 | sem anexo |
| 4 | 2026 | `003/2026` | `Distribuidora do Norte S/A` | `11.222.333/0001-44` | 01/01/2026 | 30/06/2026 | 1 PDF, sem descrição |

O registro 2 existe para exercitar a busca multi-palavra (P5) e o registro 4 para
exercitar o rótulo do link de download (queda para `basename`).

## 9. Cobertura mínima esperada

| Arquivo | Cobertura mínima |
|---|---|
| `SanctionedBidder` (model) | 100 % das linhas de `booted()`/casts/scope |
| `SanctionedBidderPolicy` | 100 % (uma asserção por método) |
| `StoreSanctionedBidderRequest` / `UpdateSanctionedBidderRequest` | 100 % das regras |
| `SanctionedBidderController` (CMS) | ≥ 90 % das linhas |
| `SanctionedBidderController` (portal) | ≥ 90 % das linhas (filtros inclusos) |
| `SanctionedBidderResource` | 100 % |

## 10. Observação sobre a v5

Não é possível executar esta suíte na v5 sem escrever os testes do zero, e o
`phpunit.xml` da v5 (`v5/phpunit.xml`) está configurado para `bootstrap="vendor/autoload.php"`
com `APP_ENV=testing` e `CACHE_DRIVER/SESSION_DRIVER=array` — mas o `DB_CONNECTION`
não é sobrescrito, de modo que a suíte rodaria contra o banco de desenvolvimento.
Na v5, os testes devem ser executados com `DB_DATABASE` apontando para um banco
dedicado. Na v7, use `RefreshDatabase` com a conexão de teste configurada no
`phpunit.xml`.
