# Obras Paralisadas — Testes

> Estratégia de verificação para a reconstrução do módulo na v7 (Laravel 11/12,
> PHPUnit 11/12 + Pest opcional).

---

## 1. Estratégia

| Camada | Ferramenta | Cobertura |
|---|---|---|
| Feature (HTTP) | Pest ou PHPUnit | Rotas do CMS e do portal: permissões, validação, criação, edição, exclusão, anexos, busca, exportação. |
| Unit | PHPUnit | Model: geração de slug, casts, accessor de anexos, escopo de soft delete. |
| Browser (opcional) | Laravel Dusk | Fluxo do operador no AdminLTE (adicionar arquivo, excluir anexo) e do cidadão no portal. |
| Banco | `RefreshDatabase` + factories | Isolamento por transação; sem dependência da base de produção. |

Regras gerais:

1. Nenhum teste depende do banco `prefeituras_v5` (que, além disso, tem a
   tabela `obras_paralisadas` **vazia** — 0 linhas).
2. Upload de arquivos usa `Storage::fake('public')` e
   `UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf')`.
3. A **v6 não possui este módulo**; portanto não há suíte herdada a migrar.
   Todos os testes abaixo são novos.
4. Comandos exatos de execução ao final do arquivo.

## 2. Fixtures e factories

```php
// exemplo ilustrativo
namespace Database\Factories;

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

class ObraParalisadaFactory extends Factory
{
    protected $model = ObraParalisada::class;

    public function definition(): array
    {
        return [
            'identificacao'      => 'Obra ' . $this->faker->unique()->numberBetween(1, 9999),
            'motivo_paralisacao' => $this->faker->sentence(),
            'responsavel'        => $this->faker->name(),
            'previsao_reinicio'  => now()->addMonth()->toDateString(),
        ];
    }

    public function comAnexos(int $quantidade = 2): static
    {
        return $this->hasMany(ObraParalisadaArchive::factory()->count($quantidade), 'archives');
    }
}
```

| Fixture | Papel |
|---|---|
| `ObraParalisadaFactory` | Registro base válido. |
| `ObraParalisadaArchiveFactory` | Anexo com `path`, `name`, `description`, `order`. |
| Usuário com permissão `obras-paralisadas.create/edit/delete/index` | Papel de operador do CMS. |
| Usuário sem nenhuma permissão | Verifica o bloqueio. |
| Usuário anônimo | Verifica o `auth`. |
| `Configuration` com `key = 'layout'` e `value = 'modelo1'` | Necessária para `getTemplateActived()` resolver uma view existente. |

## 3. Casos de teste — camada HTTP (CMS)

### 3.1 Controle de acesso

| # | Arrange | Act | Assert |
|---|---|---|---|
| T01 | Usuário anônimo | `GET /admin/obras-paralisadas` | 302 para `login` (ou 403, conforme a política adotada). |
| T02 | Usuário anônimo | `POST /admin/obras-paralisadas` com dados válidos | 302/401/403 e **nenhum** registro criado (`assertDatabaseCount('obras_paralisadas', 0)`). Este é o teste que fecha a fragilidade A11/T da v5. |
| T03 | Usuário anônimo | `PUT /admin/obras-paralisadas/{id}` | Idem: registro inalterado. |
| T04 | Usuário anônimo | `DELETE /admin/obras-paralisadas/{id}` | Idem: registro não deletado. |
| T05 | Usuário sem a permissão `update` | `PUT /admin/obras-paralisadas/{id}` | 403 e registro inalterado. |
| T06 | Usuário sem a permissão `delete` | `DELETE /admin/obras-paralisadas/{id}` | 403 e registro presente. |
| T07 | Usuário sem a permissão `create` | `GET /admin/obras-paralisadas/create` | 403. |
| T08 | Usuário com `super-admin` | `DELETE /admin/obras-paralisadas/{id}` | 302/204 e `deleted_at` preenchido. |

### 3.2 Validação

| # | Arrange | Act | Assert |
|---|---|---|---|
| T10 | Payload sem `identificacao` | `POST` | 422 e `identificacao` em `assertSessionHasErrors`. |
| T11 | Payload sem `motivo_paralisacao` | `POST` | 422 com a mensagem "O campo Motivo da Paralisação é obrigatório.". |
| T12 | Payload sem `responsavel` | `POST` | 422. |
| T13 | Payload sem `previsao_reinicio` | `POST` | 422. |
| T14 | `arquivo` = imagem PNG | `POST` | 422 com a mensagem "Os arquivos devem estar no formato PDF.". |
| T15 | `arquivo` = PDF de 11 MB | `POST` | 422 com a mensagem "Cada arquivo não pode ser maior que 10MB.". |
| T16 | `arquivo` = PDF de 9 MB | `POST` | 302 e arquivo gravado no disk `public`. |
| T17 | `arquivo_desc` com 300 caracteres | `POST` | 422 em `arquivo_desc.0`. |
| T18 | `arquivo_desc` com mais itens que `arquivo` | `POST` | Comportamento definido: ou 422 (recomendado na v7) ou descrições extras descartadas (comportamento da v5). **Decisão a registrar.** |

### 3.3 CRUD

| # | Arrange | Act | Assert |
|---|---|---|---|
| T20 | Usuário autorizado | `POST` com 4 campos válidos | 302 para `obra-paralisada.index`; `assertDatabaseHas` com os 4 campos. |
| T21 | idem | `POST` | O `slug` foi gerado e é único; `assertDatabaseHas('obras_paralisadas', ['slug' => ...])`. |
| T22 | idem | `POST` com 2 PDFs e 2 descrições | 2 registros em `obra_paralisada_archives`, com `order` 0 e 1 e as descrições corretas. |
| T23 | Registro existente | `PUT` alterando a `identificacao` | O **`slug` não muda** (correção da v5, que quebrava o link público). |
| T24 | Registro com 1 anexo | `PUT` anexando mais 1 PDF | Total de 2 anexos; o original preservado e na primeira posição. |
| T25 | Registro inexistente (id 999999) | `GET /admin/obras-paralisadas/999999/edit` | 404. |
| T26 | Registro existente | `DELETE` | `deleted_at` não nulo; `assertSoftDeleted`. |
| T27 | Registro existente | `DELETE` | A listagem do CMS e do portal não o exibem mais. |
| T28 | Registro com 3 anexos | `DELETE /admin/obras-paralisadas/{id}/arquivos/{idDoAnexo}` | O anexo sai; o **arquivo físico** é apagado do storage (comportamento novo, ver M7 em `06-MIGRACAO-MODERNA.md`). |
| T29 | Registro com 1 anexo | `DELETE` do único anexo | A tabela de anexos fica vazia **e** o arquivo é removido do disk. |

### 3.4 DataTable do CMS

| # | Arrange | Act | Assert |
|---|---|---|---|
| T30 | 2 registros, um soft-deleted | `GET /admin/datatable/obras-paralisadas` | JSON com **1** linha (o soft-deleted é excluído). |
| T31 | 1 registro | idem | JSON contém `identificacao`, `responsavel`, `previsao_reinicio` e as chaves de permissão `edit`/`delete` conforme o usuário. |

## 4. Casos de teste — camada HTTP (portal)

| # | Arrange | Act | Assert |
|---|---|---|---|
| P01 | 20 registros | `GET /obras-paralisadas` | 200; 15 registros na página; ordenação alfabética por `identificacao`. |
| P02 | 20 registros | idem | A segunda página traz os 5 restantes; os links de paginação preservam a query string. |
| P03 | Registro com slug conhecido | `GET /obra-paralisada/{slug}` | 200; exibe identificação, motivo, responsável, previsão e os anexos. |
| P04 | Slug inexistente | `GET /obra-paralisada/nao-existe` | 404. |
| P05 | Registro soft-deleted | `GET /obra-paralisada/{slug}` | 404. |
| P06 | 2 registros, um com "Praça" no identificativo | `GET /obras-paralisadas?busca=praca` | Encontra o registro sem acento. |
| P07 | idem, busca "Praça" com acento | idem | Encontra o registro (a v5 **não** encontrava em `motivo_paralisacao` com acento). |
| P08 | Registro com "Paralisação" no motivo | `GET /obras-paralisadas?busca=paralisação` | Encontra (a v5 falhava neste caso). |
| P09 | 1 registro | `GET /obras-paralisadas?type=csv` | 200, `Content-Type: text/csv`, `Content-Disposition` com `obras-paralisadas.csv`, corpo com o cabeçalho e a linha. |
| P10 | idem | `GET /obras-paralisadas?type=xls` | 200, `application/vnd.ms-excel`, corpo começando com BOM UTF-8. |
| P11 | idem | `GET /obras-paralisadas?type=txt` | 200, `text/plain`, corpo com "Identificação da Obra: ...". |
| P12 | idem | `GET /obras-paralisadas?type=doc` | 404. |
| P13 | 30 registros filtrados por `busca` | `GET /obras-paralisadas?busca=X&type=csv` | O CSV contém **todos** os filtrados, não só os 15 da página. |
| P14 | Usuário anônimo | `GET /obras-paralisadas` | 200 (o portal é público). |
| P15 | `configurations.layout = 'default'` | `GET /obras-paralisadas` | Comportamento definido: ou o módulo funciona com qualquer layout (recomendado), ou a rota retorna 404 com mensagem clara — **nunca** "View not found". |

## 5. Casos de teste — camada Unit (model)

| # | Arrange | Act | Assert |
|---|---|---|---|
| U01 | Model novo | `save()` | `slug` preenchido, em minúsculas, sem acento e com sufixo. |
| U02 | Model com `identificacao` "Pavilhão Central" | `save()` | `slug` = `pavilhao-central` + sufixo. |
| U03 | Model persistido | `update(['identificacao' => 'Outro Nome'])` | `slug` **inalterado** (decisão E9). |
| U04 | Model com `previsao_reinicio` = '2026-10-01' | `$model->previsao_reinicio` | Instância de `Carbon` (cast `date`). |
| U05 | Registro soft-deleted | `ObraParalisada::count()` | Não o retorna (escopo global). |
| U06 | Registro soft-deleted | `ObraParalisada::withTrashed()->count()` | Retorna. |
| U07 | Registro com anexos | `$obra->archives` | Lista ordenada por `order`. |
| U08 | Model sem `identificacao` | `save()` | `ValidationException` (o model valida ou o Request impede). |

## 6. Testes de regressão herdados da v5 (comportamento a fixar)

Estes casos documentam os **bugs** encontrados na v5. Na v7 devem ser
verificados com o **resultado corrigido**:

| # | Comportamento na v5 | Resultado esperado na v7 |
|---|---|---|
| R01 | `store()` com exceção: a mensagem original é sobrescrita pela genérica | A mensagem real é exibida no log; a genérica apenas no toast |
| R02 | Excluir um anexo deixa o PDF acessível em `/storage/docs/...` | O arquivo é removido do disk |
| R03 | Renomear a `identificacao` muda o slug e quebra o link público | O slug permanece |
| R04 | Busca por termo com espaço não encontra "obras publicas" | Encontra |
| R05 | Busca com acento não encontra em `motivo_paralisacao` | Encontra |
| R06 | `POST /cms/obras-paralisadas` anônimo é barrado só por herança do controller base | Barrado por `auth` + `can:` explícitos |
| R07 | A listagem pública injeta todos os registros no HTML | Somente a página atual é injetada; o PDF usa endpoint próprio |
| R08 | `destroy()` responde JSON, mas o redirect de "sem permissão" quebra o `fetch` | Resposta JSON coerente em todos os casos de erro |

## 7. Ambientes de teste e seed

```php
// exemplo ilustrativo — DatabaseSeeder
ObraParalisada::factory()
    ->count(3)
    ->comAnexos(2)
    ->create();

Configuration::updateOrCreate(['key' => 'layout'], ['value' => 'modelo1']);
```

| Item | Valor |
|---|---|
| Banco de teste | PostgreSQL de teste (mesmo engine do produção), criado por `php artisan migrate --env=testing` |
| Usuário de teste | `operador@prefeitura.test` com o papel que possui `obras-paralisadas.*` |
| Storage | `Storage::fake('public')` nos testes de upload; o `storage:link` não é necessário |
| Layout | Sempre fixar `configurations.layout` no setup, para não depender do ambiente |

## 8. Como rodar

```bash
# todos os testes do módulo
php artisan test --filter=ObraParalisada

# por arquivo
php artisan test tests/Feature/Admin/ObraParalisadaTest.php
php artisan test tests/Feature/Portal/ObrasParalisadasTest.php
php artisan test tests/Unit/ObraParalisadaTest.php

# com Pest
./vendor/bin/pest --filter=ObraParalisada

# recriar o banco de teste do zero
php artisan migrate:fresh --env=testing --seed
```

Cobertura mínima exigida para dar o módulo como pronto:

- 100% das rotas do CMS (T01 a T29).
- 100% das rotas do portal (P01 a P15).
- 100% dos casos de regressão (R01 a R08).
