# Obras — Estratégia de Testes

> Alvo: a reconstrução em Laravel 11/12 (PHP 8.x). A v5 **não possui
> nenhum teste** para este módulo (`v5/tests/Feature/` contém apenas
> `ExampleTest.php` e `CnpjAlphanumericValidationTest.php`), o que torna a
> reconstrução um terreno sem rede de proteção.

---

## 1. Estratégia

| Tipo | Foco | Ferramenta |
|---|---|---|
| **Feature (CMS)** | Rotas, autorização, Form Requests, upload, galeria, view SQL/listagem. | Pest ou PHPUnit + `RefreshDatabase` + `Storage::fake('public')`. |
| **Feature (Portal)** | Listagem pública, filtros, ficha, exportações. | Pest + `RefreshDatabase`. |
| **Unit** | Regras puras: normalização de datas/valores, formatação de moeda, geocoding (com HTTP fake). | PHPUnit puro. |
| **Browser (opcional)** | Fluxo completo do operador (CEP → geocoding → upload → galeria). | Laravel Dusk / Playwright. |

**Comandos (v7):**

```bash
# tudo
php artisan test

# só o módulo obras
php artisan test --filter=Obras

# feature do CMS
php artisan test tests/Feature/Admin/Works

# feature do portal
php artisan test tests/Feature/Portal/Works

# unit
php artisan test tests/Unit/Works

# com cobertura do módulo
php artisan test --coverage --min=80 tests/Feature/Admin/Works tests/Feature/Portal/Works
```

> Na v5 o runner é `vendor/bin/phpunit` com `phpunit.xml` (suites `Feature` e
> `Unit`, `APP_ENV=testing`, `CACHE_DRIVER=array`, `SESSION_DRIVER=array`,
> `QUEUE_DRIVER=sync`). Na v7, o comando é `php artisan test`.

---

## 2. Fixtures necessárias

### 2.1 Factories

```php
// database/factories/WorkStatusFactory.php
class WorkStatusFactory extends Factory
{
    public function definition(): array
    {
        return ['name' => fake()->unique()->randomElement(
            ['Aberta', 'Em Andamento', 'Finalizada', 'Cancelada', 'Suspensa']
        )];
    }
}
```

```php
// database/factories/WorkCategoryFactory.php
class WorkCategoryFactory extends Factory
{
    public function definition(): array
    {
        return ['name' => fake()->unique()->words(2, true)];
    }
}
```

```php
// database/factories/WorkFactory.php
class WorkFactory extends Factory
{
    public function definition(): array
    {
        $start = fake()->dateTimeBetween('-2 years', 'now');
        return [
            'title' => 'Obra ' . fake()->numerify('###'),
            'edital' => (string) fake()->numberBetween(1, 9999),
            'contract' => (string) fake()->numberBetween(1, 9999),
            'work_category_id' => WorkCategory::factory(),
            'status_id' => WorkStatus::factory(),
            'value' => fake()->randomFloat(2, 10000, 5000000),
            'value_paid' => fake()->randomFloat(2, 0, 5000000),
            'start_date' => $start,
            'end_date' => fake()->dateTimeBetween($start, 'now'),
            'cep' => fake()->numerify('#####-###'),
            'state' => 'PB',
            'city' => 'João Pessoa',
            'neighborhood' => fake()->city(),
            'street' => fake()->streetName(),
            'house_number' => (string) fake()->numberBetween(1, 999),
            'complement' => null,
            'latitude' => fake()->latitude(),
            'longitude' => fake()->longitude(),
            'company_holder' => fake()->company(),
            'holder' => fake()->name(),
            'gallery' => false,
            'justification' => null,
            'description' => null,
            'estimated_completion_date' => null,
            'completion_percentage' => null,
        ];
    }

    public function semCategoria(): static { return $this->state(fn () => ['work_category_id' => null]); }
    public function suspensa(): static  { return $this->state(fn () => [
        'status_id' => WorkStatus::where('name', 'Suspensa')->value('id'),
        'justification' => 'Licitação anulada por decisão judicial.',
    ]); }
    public function comGaleria(): static { return $this->state(fn () => ['gallery' => true]); }
}
```

### 2.2 Usuário e permissões

```php
$admin = User::factory()->create();
$admin->givePermissionTo('obras.index', 'obras.create', 'obras.edit', 'obras.delete');

$semAcesso = User::factory()->create();   // nenhum permiso
```

Como a v6/v7 usa `modulos` + `resources`, o seed do módulo deve criar:
`modulos.slug = 'obras'`, `resources.slug = 'obras'` ligado a ele, e as
permissões `obras.index`, `obras.create`, `obras.edit`, `obras.delete`,
`obras.show`.

### 2.3 Arquivos de teste

```php
Storage::fake('public');
$pdf = UploadedFile::fake()->create('contrato.pdf', 100, 'application/pdf');
$img = UploadedFile::fake()->image('foto-obra.jpg', 800, 600);
```

### 2.4 Configuração do portal

O template do portal vem de `Configuration('layout')`. Para os testes de
portal, semear `layout = 'modelo1'` **e** garantir que exista a view
correspondente — ou, melhor, usar um template de teste dedicado.

---

## 3. Casos de teste — CMS

### 3.1 Autorização e acesso

| # | Arrange | Act | Assert |
|---|---|---|---|
| A1 | usuário sem permissão | `GET /cms/obras` | 403 (ou redirect de acesso negado, conforme a Policy) |
| A2 | usuário com `obras.index` | `GET /cms/obras` | 200 e view `admin.works.index` |
| A3 | usuário sem permissão | `POST /cms/obras` com payload válido | 403 e **nenhuma** obra criada |
| A4 | usuário com `obras.create` | `GET /cms/obras/create` | 200 e view com selects de categoria e situação |
| A5 | não autenticado | `GET /cms/obras` | redirect para `login` |
| A6 | usuário sem `obras.delete` | `DELETE /cms/obras/{id}` | 403 e obra **não** deletada |

### 3.2 Cadastro (`POST /cms/obras`)

| # | Arrange | Act | Assert |
|---|---|---|---|
| C1 | — | payload completo válido | 302 para `obras.index`; 1 linha em `works`; flash de sucesso |
| C2 | payload sem `title` | POST | 302 de volta; 0 linhas em `works`; erros de validação na sessão |
| C3 | payload sem `value_paid` | POST | 422/redirect com erro em `value_paid` |
| C4 | `work_category_id` inexistente | POST | erro em `work_category_id` |
| C5 | `status_id` inexistente | POST | erro em `status_id` |
| C6 | `start_date` em `d/m/Y` | POST | `works.start_date` gravado como `Y-m-d` |
| C7 | `start_date` em `Y-m-d` | POST | erro de validação (não exceção 500) |
| C8 | `end_date` **anterior** a `start_date` | POST | erro em `end_date` |
| C9 | `end_date` igual a `start_date` | POST | aceito |
| C10 | `completion_percentage = 150` | POST | erro (between 0 e 100) |
| C11 | `completion_percentage = 50` | POST | aceito e persistido |
| C12 | `gallery` marcado (`on`) | POST | `works.gallery = true` |
| C13 | `gallery` ausente | POST | `works.gallery = false` |
| C14 | situação "Suspensa" **sem** justificativa | POST | **422** e erro em `justification` (regra que não existia na v5) |
| C15 | situação "Suspensa" **com** justificativa | POST | aceito |
| C16 | situação "Aberta" sem justificativa | POST | aceito |
| C17 | `gallery = 1` | POST | 302 para `obras.edit` com `goto=galeria` |
| C18 | `gallery = 0` | POST | 302 para `obras.index` (sem `goto`) |

### 3.3 Edição (`PUT /cms/obras/{id}`)

| # | Arrange | Act | Assert |
|---|---|---|---|
| E1 | obra existente | PUT com payload válido | linha atualizada; `updated_at` maior |
| E2 | obra existente | PUT sem `gallery` | `gallery` continua `true` (a v5 zerava por causa do `merge()`) |
| E3 | obra com `gallery = false` | PUT com `gallery = 1` | 302 para `obras.edit` com `goto=galeria` |
| E4 | obra com `gallery = true` | PUT com `gallery = 1` | 302 para `obras.index` (sem `goto`) |
| E5 | id inexistente | PUT | 404; **nenhuma** obra nova criada (a v5 criava!) |
| E6 | obra existente | PUT com `title` novo | título atualizado; demais campos preservados |

### 3.4 Exclusão

| # | Arrange | Act | Assert |
|---|---|---|---|
| X1 | obra existente | `DELETE /cms/obras/{id}` | soft delete: `deleted_at` preenchido; linha ainda existe; JSON `success` |
| X2 | id inexistente | DELETE | JSON de erro; 0 exclusões |
| X3 | obra com arquivos | DELETE | `archives` **não** são apagados; os arquivos continuam no storage |

### 3.5 Anexos

| # | Arrange | Act | Assert |
|---|---|---|---|
| F1 | — | POST com `files[0]` (PDF) | 1 linha em `works`; 1 em `work_files` com `type = 'file'`; arquivo no `Storage::fake('public')` |
| F2 | — | POST com `files[0]` + `files[0].description` | descrição persistida |
| F3 | — | POST com 3 arquivos | 3 linhas em `work_files` |
| F4 | — | POST com arquivo de 3 MB | erro de validação (limite) |
| F5 | — | POST com `.exe` | erro de validação (mimes) |
| F6 | — | POST sem arquivos | 0 linhas em `work_files`; obra criada normalmente |
| F7 | falha na gravação do disco | POST com arquivo | transação revertida: **nenhuma** linha em `works` (a v5 deixava arquivo órfão) |

### 3.6 Galeria

| # | Arrange | Act | Assert |
|---|---|---|---|
| G1 | obra existente | `POST /api/obras/{id}/gallery` com imagem | linha em `work_files` com `type = 'gallery'`; arquivo no storage |
| G2 | obra existente | `GET /api/obras/{id}/gallery` | JSON só com as imagens daquela obra |
| G3 | obra existente | GET gallery | **não** retorna arquivos do tipo `file` |
| G4 | id de obra inexistente | POST gallery | 422/404; nenhum registro criado |
| G5 | — | upload de imagem | variantes `_min`/`_med`/`_larg` geradas pelo job |

### 3.7 Categorias

| # | Arrange | Act | Assert |
|---|---|---|---|
| K1 | — | `POST /cms/categorias-obras` com `name` | 1 linha em `work_categories` |
| K2 | — | POST sem `name` | erro de validação |
| K3 | categoria **com** obra | `DELETE /cms/categorias-obras/{id}` | 400/422 com a mensagem "Não é possível excluir esta categoria de obra, pois existem obras vinculadas a ela."; categoria **não** deletada |
| K4 | categoria **sem** obra | DELETE | soft delete; JSON `success` |
| K5 | categoria com obra **soft-deleted** | DELETE | permitido (comportamento da v5) |
| K6 | id inexistente | DELETE | 404 com `{"erro": "Categoria de obra não encontrada."}` |

### 3.8 Listagem do CMS (DataTable)

| # | Arrange | Act | Assert |
|---|---|---|---|
| L1 | 3 obras | `GET /cms/datatable/listagem_obras` | JSON com `recordsTotal = 3` e 3 itens |
| L2 | obra **soft-deleted** | GET | não aparece na listagem |
| L3 | obra com `work_category_id` nulo | GET | **aparece** (a v5 escondia por causa do INNER JOIN) |
| L4 | 2 obras com a mesma `created_at` | GET | `value` (row_number) é sequencial e estável |
| L5 | usuário sem `obras.edit` | GET | `data[0].edit = false` |
| L6 | usuário com `obras.edit` | GET | `data[0].edit = true` |

### 3.9 Geocodificação

| # | Arrange | Act | Assert |
|---|---|---|---|
| Q1 | `Http::fake` para o Google retornando `status: OK` | `GET /cms/obras/get-geolocation-data?address=...` | 200 e `data.lat`/`data.lng` |
| Q2 | `Http::fake` retornando `ZERO_RESULTS` | GET | 500 com `error` |
| Q3 | chave da API vazia | GET | erro controlado (não 500 genérico sem mensagem) |
| Q4 | — | request sem `address` | erro de validação, não exceção |

---

## 4. Casos de teste — Portal

| # | Arrange | Act | Assert |
|---|---|---|---|
| P1 | 15 obras | `GET /obras` | 200; 10 obras na tabela; paginação presente |
| P2 | obra com situação "Suspensa" e justificativa | `GET /obras/{id}` | justificativa exibida |
| P3 | obra sem `status_id` (FK nula) | `GET /obras/{id}` | **não** estoura erro 500 |
| P4 | id inexistente | `GET /obras/999999` | 404 (a v5 dava 500) |
| P5 | obras com contratos "CONTRATO ÁÉÍ" e "CONTRATO AAEI" | `GET /obras?contract=contrato` | **ambas** retornam (busca insensível a acentos) |
| P6 | mesma busca com `'` no termo | GET | 200, sem erro de SQL (teste de SQL injection) |
| P7 | obras em 2 categorias | `GET /obras?category={id}` | só a categoria filtrada |
| P8 | obras em 2 situações | `GET /obras?status={id}` | só a situação filtrada |
| P9 | obras com `start_date` em jan/2025 e mar/2025 | `GET /obras?dt_inicial=2025-02-01&dt_final=2025-02-28` | só as de fevereiro |
| P10 | 3 obras | `GET /obras` com header `X-Requested-With: XMLHttpRequest` | JSON do paginator |
| P11 | obra com 2 arquivos e 3 fotos | `GET /obras/{id}` | bloco de arquivos e galeria renderizados |
| P12 | — | `GET /exportacao-obras?type=xls` | download `.xlsx` com **todas** as obras filtradas (não só a página 1) |
| P13 | — | `GET /exportacao-obras?type=csv` | download `.csv` |
| P14 | — | `GET /exportacao-obras?type=txt` | download `.txt` com cabeçalho e separadores |
| P15 | 2 obras, uma sem coordenadas | `GET /exportacao-obras?type=xls` | a obra **sem** coordenadas também aparece no PDF/XLS (a v5 omitia no PDF) |
| P16 | obra com valor `1234.56` | `GET /obras/{id}` | exibe `R$ 1.234,56` |
| P17 | portal em modo manutenção | `GET /obras` | resposta de manutenção (middleware `check.site.manutention` equivalente) |

---

## 5. Testes unitários

| # | Alvo | Assert |
|---|---|---|
| U1 | Normalizador de data `d/m/Y` → `Y-m-d` | "31/12/2026" → "2026-12-31"; string vazia → null; formato inválido → exceção controlada |
| U2 | Normalizador de valor monetário | "1.234,56" → 1234.56; "1234.56" → 1234.56; "0,00" → 0.0 |
| U3 | Formatador de moeda pt-BR | 1234.56 → "1.234,56" |
| U4 | `WorkStatus::requiresJustification()` | true para Suspensa/Cancelada; false para as demais |
| U5 | Service de geocodificação (com `Http::fake`) | retorna `lat`/`lng` em `status: OK`; lança exceção em erro |
| U6 | `GeocodingService` com chave vazia | falha antes de chamar a API |

---

## 6. Fixtures de view / seed para o ambiente de teste

| Item | Origem na v5 | Ação na v7 |
|---|---|---|
| `configurations.layout = 'modelo1'` | `v5/database/migrations/...` / seed | Seeder de configuração do ambiente de teste; ou template de teste próprio |
| `configurations.estado = 'PB'` | idem | idem |
| `configurations.geocoding_api_key` | vazia na v5 | **preencher com chave de teste** (ou `Http::fake` em todos os testes) |
| `work_statuses` (5 registros) | migration `2025_04_01_022047` | `WorkStatusSeeder` + `requires_justification` |
| `work_categories` ("Infraestrutura") | idem | `WorkCategorySeeder` |
| `resources.slug = 'obras'` + `modulos` | idem | Seeder de módulo + permissões |
| `menu_orders.slug = 'obras'` | idem | idem (ou remover o contador de cliques) |
| Portal: pasta `obras/` nos 6 templates | views | Componentizar (ver `06-MIGRACAO-MODERNA.md` §6.2) |

---

## 7. Cenário de aceite manual (para homologação)

1. Entrar no CMS como usuário com `obras.create` e `obras.index`.
2. Abrir `/cms/obras` → a listagem carrega via DataTable, com as colunas ID,
   Título, Categoria, Situação, Valor Total, Valor Pago e Ações.
3. Clicar em "Criar Obra" → o formulário abre com os selects de situação e
   categoria preenchidos.
4. Digitar um CEP válido e sair do campo → estado, cidade, bairro e rua são
   preenchidos; latitude e longitude são preenchidas pelo geocoding.
5. Selecionar a situação "Suspensa" → o campo "Motivo/Justificativa" aparece.
6. Trocar para "Aberta" → o campo some e é limpo.
7. Anexar 2 arquivos com descrições → salvar → as duas entradas aparecem em
   "Arquivos enviados" com download, visualizar e excluir.
8. Marcar "Ativar Galeria" → salvar → o sistema redireciona para a aba
   "Galeria"; subir 2 fotos → elas aparecem na galeria.
9. Editar a obra → os arquivos e as fotos permanecem.
10. Excluir a obra → ela some da listagem; os arquivos continuam no storage.
11. No portal, `/obras` → mapa, tabela e filtros; filtrar por "Finalizada"
    e por um trecho de contrato com acento → resultados coerentes.
12. Abrir a ficha da obra → dados, justificativa (se houver), percentual de
    conclusão, arquivos e galeria.
13. Exportar em xls, csv, txt e pdf → conferir que o PDF traz o nome do arquivo
    correto e a coluna "Valor Pago" preenchida.
14. No CMS, `/cms/categorias-obras` → criar "Pavimentação"; tentar excluí-la
    com uma obra vinculada → a exclusão é bloqueada com a mensagem explicativa.
15. Confirmar no banco que `works`, `work_archives` e `archives` mantêm o
    formato esperado e que a listagem do CMS mostra o `id` real da obra (não a
    posição).
