# Concursos e PSS — Estratégia de Testes

> A v5 **não tem** nenhum teste para este módulo: `v5/tests/Feature/` contém
> apenas `ExampleTest.php` e `v5/tests/Unit/` está vazio. O plano abaixo é para
> a **v7** (PHP 8.x + Laravel 11/12), usando Pest ou PHPUnit + `RefreshDatabase`.

---

## 1. Ferramentas

| Item | v5 (legado) | v7 (proposto) |
|---|---|---|
| Runner | PHPUnit 6 (`v5/phpunit.xml`) | PHPUnit 11 / Pest 3 |
| Banco de teste | `v5/.env.testing` (não versionado) | SQLite em memória ou PostgreSQL de teste |
| Bootstrap | `v5/tests/TestCase.php` + `CreatesApplication.php` | `Tests\TestCase` padrão do Laravel 11 |
| Ambiente | — | `APP_ENV=testing`, `DB_CONNECTION=pgsql` |

> O módulo usa `whereRaw` com `translate()` e `ilike`, funções do **PostgreSQL**.
> Para que os testes de busca sejam fieis, o banco de teste precisa ser
> **PostgreSQL** — SQLite não implementa `translate()`/`ILIKE`.

## 2. Estratégia

| Camada | Tipo | Cobertura-alvo |
|---|---|---|
| **Unit** | Model | Geração de slug, casts, serialização de `arquivo`. |
| **Feature (CMS)** | HTTP | CRUD completo, validação, permissões, upload/remoção de anexos, resposta do DataTable. |
| **Feature (Portal)** | HTTP | Filtros, paginação, detalhe por slug, 4 exportações. |
| **Integração** | HTTP + banco | Slug único, soft delete, ordenação, view SQL. |

## 3. Fixtures

### 3.1 Factory

```php
// v7/database/factories/ConcursoPssFactory.php
class ConcursoPssFactory extends Factory
{
    protected $model = ConcursoPss::class;

    public function definition(): array
    {
        $tipo = $this->faker->randomElement(TipoConcurso::values());
        $ano  = $this->faker->numberBetween(2015, (int) date('Y'));

        return [
            'tipo' => $tipo,
            'numero' => str_pad((string) $this->faker->numberBetween(1, 999), 3, '0', STR_PAD_LEFT),
            'ano' => $ano,
            'descricao' => 'Processo seletivo para o cargo de '.$this->faker->jobTitle(),
            'situacao' => SituacaoConcurso::InscricoesAbertas->value,
            'vagas_ofertadas' => $this->faker->numberBetween(1, 100),
            'vagas_preenchidas' => null,
            'validade' => $this->faker->optional()->date(),
            'arquivo' => null,
        ];
    }

    public function comArquivos(int $quantidade = 2): static
    {
        return $this->afterCreating(function (ConcursoPss $concurso) use ($quantidade) {
            $arquivos = [];
            for ($i = 0; $i < $quantidade; $i++) {
                $arquivos[] = [
                    'uuid' => (string) Str::uuid(),
                    'path' => "docs/{$concurso->id}-{$i}.pdf",
                    'description' => "Documento {$i}",
                ];
            }
            $concurso->update(['arquivo' => $arquivos]);
        });
    }

    public function doAno(int $ano): static { return $this->state(fn () => ['ano' => $ano]); }
    public function doTipo(TipoConcurso $tipo): static { return $this->state(fn () => ['tipo' => $tipo->value]); }
}
```

### 3.2 Seed de ambiente

```php
// v7/database/seeders/ConcursoPssSeeder.php
ConcursoPss::factory()->count(25)->create();
ConcursoPss::factory()->doAno(2024)->count(8)->create();
ConcursoPss::factory()->doTipo(TipoConcurso::ProcessoSeletivo)->count(6)->create();
```

O seed da v7 **deve** popular `modulos` (slug `concursos-pss`) e `resources`
(slug `concurso-pss`, com `modulo_id`) — ausentes da v6.

### 3.3 Usuário de teste

```php
$user = User::factory()->create()->assignRole('administrador');
// papel com as chaves: concurso-pss.index / .create / .edit / .delete
```

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

| # | Caso | Arrange / Act / Assert |
|---|---|---|
| U1 | Slug gerado no create | Given `ConcursoPss::factory()->create(['tipo'=>'Concurso Público','numero'=>'001','ano'=>2026])` → When lido → Then `slug === 'concurso-publico-001-2026-{id}'`. |
| U2 | Slug recalculado no update | Given um certame → When `update(['ano' => 2027])` → Then o slug contém `2027`. |
| U3 | Slug imutável (decisão v7) | Given um certame publicado → When `update(['numero' => '002'])` → Then o slug **permanece** e o antigo redireciona (301). |
| U4 | Cast de `validade` | Given `validade = '2026-12-31'` → Then `$model->validade` é `Carbon` e `->toDateString() === '2026-12-31'`. |
| U5 | Cast de `arquivo` | Given `arquivo = [['path'=>'docs/a.pdf','description'=>'Edital']]` → Then `$model->arquivo` é `array`. |
| U6 | Soft delete | Given um certame → When `delete()` → Then `deleted_at` não é nulo e `ConcursoPss::find($id)` retorna `null`. |
| U7 | Slug único | Given dois certames com mesmo tipo/número/ano → Then o segundo recebe slug distinto (id entra na composição). |

## 5. Casos de teste — Feature CMS

| # | Caso | Arrange / Act / Assert |
|---|---|---|
| C1 | Listagem exige login | Given visitante anônimo → When `GET /cms/concursos-pss` → Then redirect para `dashboard.index`. |
| C2 | Listagem sem permissão | Given usuário sem `concurso-pss.index` → When `GET /cms/concursos-pss` → Then redirect com flash de autorização. |
| C3 | Listagem com permissão | Given usuário com permissão + 3 certames → When `GET /cms/concursos-pss` → Then status 200 e o HTML contém `tabela_concurso-pss`. |
| C4 | JSON do DataTable | Given 3 certames → When `GET /cms/datatable/concurso-pss` → Then JSON com `data[0].tipo`, `data[0].edit`, `data[0].delete` e `data[0].value`. |
| C5 | Soft-deletes fora do DataTable | Given 2 ativos + 1 deletado → When `GET /cms/datatable/concurso-pss` → Then `count(data) === 2`. |
| C6 | Criar válido | Given payload completo → When `POST /cms/concursos-pss` → Then redirect para `concurso-pss.index`, flash de sucesso e registro no banco com slug preenchido. |
| C7 | Criar sem `tipo` | Given payload sem `tipo` → Then `session('errors')->has('tipo')` e **nenhum** registro criado. |
| C8 | Criar sem `situacao` | Given payload sem `situacao` → Then erro em `situacao`. |
| C9 | `ano` fora da faixa | Given `ano = 1800` → Then erro em `ano`; `ano = 2200` → erro. |
| C10 | `vagas_preenchidas > vagas_ofertadas` | Given 10 ofertadas e 20 preenchidas → Then erro (regra nova da v7). |
| C11 | `validade` com formato inválido | Given `validade = '2026-12-31'` (formato ISO) → Then erro em `validade`; Given `'31/12/2026'` → Then aceita e gravada como `2026-12-31`. |
| C12 | Upload de 2 PDFs | Given 2 arquivos PDF válidos + `arquivo_desc` → Then `arquivo` é JSON com 2 itens, `path` inicia com `docs/`, `description` preservada e os arquivos existem no disco. |
| C13 | Upload de arquivo não-PDF | Given `nota.txt` → Then erro em `arquivo.0` e nada gravado. |
| C14 | Upload acima de 10 MB | Given PDF de 11 MB → Then erro em `arquivo.0`. |
| C15 | Update preserva anexos | Given certame com 2 anexos → When `PUT` sem novos arquivos → Then `arquivo` continua com 2 itens. |
| C16 | Update soma anexos | Given certame com 2 anexos → When `PUT` com 1 novo → Then `arquivo` com 3 itens (novo ao final). |
| C17 | Remoção de anexo | Given 3 anexos → When `DELETE /cms/concursos-pss/{id}/arquivo/1` → Then restam 2, com índices reindexados. |
| C18 | Remoção de anexo inexistente | When `DELETE .../arquivo/9` → Then 404 com `{"erro": ...}`. |
| C19 | Remoção de anexo apaga o arquivo do disco | Given 1 anexo → When DELETE → Then o arquivo não existe mais no storage (correção do legado). |
| C20 | Excluir | Given certame → When `DELETE /cms/concursos-pss/{id}` → Then 200 `{"success":...}`, `deleted_at` preenchido e ausente no portal. |
| C21 | Excluir inexistente | When `DELETE /cms/concursos-pss/999999` → Then 500 `{"erro":...}` (comportamento legado a decidir). |
| C22 | Botão "Cadastrar" condicional | Given usuário sem `concurso-pss.create` → Then o HTML **não** contém `route('concurso-pss.create')`. |
| C23 | Auditoria | Given usuário autenticado → When cria/edita → Then existe registro em `activity_log` com `log_name = 'concurso-pss'`. |

## 6. Casos de teste — Feature Portal

| # | Caso | Arrange / Act / Assert |
|---|---|---|
| P1 | Listagem sem filtro | Given 20 certames → When `GET /concursos-pss` → Then 200, 15 itens na tabela e paginação com `total = 20`. |
| P2 | Filtro por ano | Given certames de 2025 e 2026 → When `?ano=2026` → Then só os de 2026. |
| P3 | Filtro por tipo | Given tipos variados → When `?tipo=Concurso Público` → Then só esse tipo. |
| P4 | Busca sem acento | Given descrição "Processo seletivo para Saúde" → When `?busca=saude` → Then encontra o registro. |
| P5 | Busca sem acento na maiúscula | When `?busca=SAUDE` → Then encontra. |
| P6 | Busca por fragmento | Given "Edital 001/2026" → When `?busca=edital` → Then encontra. |
| P7 | Busca sem resultado | When `?busca=zzzzz` → Then 200 com "Nenhuma informação encontrada!". |
| P8 | Busca não casa em `numero` | Given `numero = '123'` e descrição sem "123" → When `?busca=123` → Then **não** encontra (comportamento legado a confirmar com o negócio). |
| P9 | Paginação | Given 20 → When `?page=2` → Then itens 16–20 e links com a query string preservada. |
| P10 | Ordem | Given anos 2024 e 2026 → Then 2026 aparece antes. |
| P11 | Detalhe por slug | Given certame → When `GET /concurso-pss/{slug}` → Then 200 com tipo, número, ano, descrição e situação. |
| P12 | Detalhe inexistente | When `GET /concurso-pss/nao-existe` → Then 404. |
| P13 | Detalhe mostra anexos | Given 1 anexo → Then o HTML contém o link de download. |
| P14 | Detalhe oculta bloco de vagas | Given ambas as vagas nulas → Then o HTML **não** contém "Vagas Ofertadas". |
| P15 | Exportação XLSX | Given 3 certames → When `?type=xls` → Then status 200, `Content-Type` de planilha e o arquivo contém 3 linhas. |
| P16 | Exportação PDF | When `?type=pdf` → Then 200 e `%PDF` no início do corpo. |
| P17 | Exportação CSV | When `?type=csv` → Then 200 e CSV com cabeçalho. |
| P18 | Exportação TXT | When `?type=txt` → Then 200 e `Content-Type: text/plain` (no legado: `plain/txt`). |
| P19 | Exportação respeita filtro | Given 10 certames, 3 de 2026 → When `?ano=2026&type=csv` → Then o arquivo tem só os 3. |
| P20 | Exportação não pagina | Given 20 certames → When `?type=csv` → Then o arquivo tem as 20 linhas. |
| P21 | Soft-deletes invisíveis | Given 1 deletado → Then não aparece na listagem nem no detalhe. |
| P22 | Página em manutenção | Given `configurations.pagina_em_manutencao = 1` e visitante anônimo → Then retorna a view de manutenção. |

## 7. Testes de view (Blade)

| # | Caso | Assert |
|---|---|---|
| V1 | CMS index | Renderiza `@include` do JS `assets/js/custom/config/concursos-pss/datatable.js`. |
| V2 | Formulário create | Contém os campos `tipo`, `numero`, `ano`, `situacao`, `vagas_ofertadas`, `vagas_preenchidas`, `validade`, `descricao`. |
| V3 | Formulário edit | Mostra os valores do registro (`$concurso->tipo` etc.). |
| V4 | Tabela do portal | 8 cabeçalhos e o link `route('concursos-pss.show', $slug)`. |
| V5 | Export view (PDF) | Com `$pdf` definido, contém o cabeçalho com o nome da prefeitura. |
| V6 | Export view (XLSX/CSV) | Sem `$pdf`, **também** contém cabeçalho (correção do bug legado). |

## 8. Como rodar

```bash
# Laravel 11/12 (v7)
php artisan test --filter=ConcursoPss

# Só os testes de portal
php artisan test --filter=ConcursoPssPortalTest

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

# Suite completa
php artisan test
```

Legado (v5), caso seja necessário confirmar comportamento antes da migração:

```bash
cd v5
./vendor/bin/phpunit
./vendor/bin/phpunit --filter ConcursoPss   # não há testes: retorna "No tests executed"
```

## 9. Cenários manuais de homologação

| # | Cenário | Resultado esperado |
|---|---|---|
| 1 | Cadastrar certame com 2 PDFs e 2 descrições | Registro criado; 2 anexos listados no CMS e no portal. |
| 2 | Editar sem anexar novo arquivo | Os 2 arquivos anteriores permanecem. |
| 3 | Remover o 1º arquivo | Restam 2; a ordem e as descrições se mantêm. |
| 4 | Excluir o certame | some da listagem do portal; continua no banco com `deleted_at`. |
| 5 | Filtrar o portal por ano/tipo/texto | Só os registros esperados. |
| 6 | Exportar nos 4 formatos | Arquivos baixados com o conteúdo filtrado. |
| 7 | Acessar o CMS sem permissão | Redirecionamento com mensagem de autorização. |
| 8 | Acessar `/concurso-pss/{slug}` inexistente | Página 404 amigável. |

## 10. Pendências de teste (lacunas conhecidas)

| Lacuna | Impacto | Ação na v7 |
|---|---|---|
| Nenhum teste automatizado na v5 | Alto | Escrever a suíte acima desde o primeiro dia. |
| Sem `defineEnvironment` para o tema do portal | Médio | Criar helper de teste que fixa `configurations.layout = 'modelo1'` e verifica que as 6 variantes do layout renderizam. |
| Sem teste para o `whereRaw` | Alto | Testes P4–P6 exigem PostgreSQL real. |
| Sem teste de upload real | Médio | Usar `Storage::fake()` + `UploadedFile::fake()->create('edital.pdf', 100, 'application/pdf')`. |
| Sem teste da migration de menu/resource | Baixo | Teste de migration que garante `modulos` + `resources` criados e idempotentes. |
