# FASE 15 — Database Foundation & Testing Hardening

> **Data:** 2026-07-21
> **Estado:** ✅ Concluída
> **Validação:** 46 testes, 110 assertions, migrate:fresh --seed OK, build OK

---

## 1. Objectivo

Tornar a base de dados Mangolê:

- **Reproduzível** — qualquer instalação pode recriar o schema do zero;
- **Portável** — funciona em MySQL e SQLite (testes em `:memory:`);
- **Testável** — todos os modelos, relações, enums, rotas e scopes têm cobertura;
- **Instalável do zero** — `php artisan migrate:fresh --seed` sem tabelas pré-existentes;
- **Segura** — XSS sanitizado, rich text limpo, enums tipados.

---

## 2. Estado antes da fase

| Problema | Detalhe |
|---|---|
| Dependência de tabelas legadas | Algumas tabelas só existiam porque foram criadas manualmente ou por migrações antigas |
| Migrations incompletas | `0001_01_01_*` cobriam users/cache/jobs; várias tabelas sem migration |
| Factories insuficientes | Apenas UserFactory; testes não podiam criar dados de forma fiável |
| Testes dependentes de configuração | `ExampleTest` tentava aceder a `/` sem dados, resultava em 500 |
| Problemas de enums | Faltavam labels, casos inconsistentes (`InquiryStatus` sem `Pending`/`Read`) |
| XSS no Leaflet | `{{ $store->name }}` dentro de string JS no `bindPopup` |
| Rich text sem sanitização | `{!! $article->content !!}` expunha XSS |
| SQLite incompatível | `MODIFY COLUMN` não é suportado por SQLite |

---

## 3. Alterações executadas

### 3.1 Migration de Foundation

**Ficheiro:** `2026_07_20_230000_create_foundation_tables.php`

Cria 12 tabelas com `Schema::hasTable()` guards:

`stores`, `products`, `categories`, `campaigns`, `campaign_assets`, `campaign_ctas`, `news_articles`, `partners`, `vacancies`, `settings`, `seo_metadata`, `team_members`

Cada tabela usa `Schema::hasTable()` para ser segura em ambientes legados e em instalações novas.

Ordem de criação respeita dependências de FK:
- `campaign_assets` e `campaign_ctas` após `campaigns` (FK `campaign_id`)

### 3.2 Factories — 15 criadas

| Factory | Model |
|---|---|
| `StoreFactory` | `Store` (com `HasFactory` adicionado) |
| `ProductFactory` | `Product` (com `HasFactory` adicionado) |
| `CategoryFactory` | `Category` (com `HasFactory` adicionado) |
| `BrandFactory` | `Brand` (com `HasFactory` adicionado) |
| `CampaignFactory` | `Campaign` (com `HasFactory` adicionado, state `published()`) |
| `NewsArticleFactory` | `NewsArticle` (com `HasFactory` adicionado, state `draft()`) |
| `PartnerFactory` | `Partner` (com `HasFactory` adicionado) |
| `VacancyFactory` | `Vacancy` (com `HasFactory` adicionado) |
| `OpportunityFactory` | `Opportunity` (com `HasFactory` adicionado) |
| `BusinessInquiryFactory` | `BusinessInquiry` (states `b2b()`, `read()`) |
| `TeamMemberFactory` | `TeamMember` |
| `StoreHourFactory` | `StoreHour` |
| `StoreServiceFactory` | `StoreService` |
| `ProvinceFactory` | `Province` |
| `MunicipalityFactory` | `Municipality` |

### 3.3 HasFactory adicionado a 15 Models

Models que receberam o trait `HasFactory`:

`Brand`, `BusinessInquiry`, `Campaign`, `Category`, `Municipality`, `NewsArticle`, `Opportunity`, `Partner`, `Product`, `Province`, `Store`, `StoreHour`, `StoreService`, `TeamMember`, `Vacancy`

### 3.4 StoreSeeder corrigido

`province_code` removido dos arrays de dados. A coluna legada `province_code` foi eliminada na migration `000013_drop_old_store_columns`. Agora as lojas usam `province_id` (FK).

### 3.5 XSS fix no mapa Leaflet

**Ficheiro:** `resources/views/pages/stores/show.blade.php`

```diff
- bindPopup('<strong>{{ $store->name }}</strong>')
+ bindPopup('<strong>' + @json($store->name) + '</strong>')
```

### 3.6 Rich text sanitization

**Ficheiro:** `app/helpers.php`

Função `safe_html()` que:
- Remove tags não permitidas (`strip_tags` com whitelist)
- Remove atributos `on*` (event handlers)
- Remove `javascript:` URLs em `<img>` e `<a>`
- Registada via `composer.json` autoload.files

Aplicada em:
- `resources/views/pages/news/show.blade.php`
- `resources/views/pages/opportunities/show.blade.php`

### 3.7 SQLite/MySQL compatibility

**Migration:** `000012_convert_team_members_category_to_string`

```php
if (DB::getDriverName() === 'mysql') {
    DB::statement('ALTER TABLE team_members MODIFY category VARCHAR(30) NOT NULL');
}
```

Em SQLite o ALTER TABLE/MODIFY não é necessário pois a coluna já é VARCHAR.

### 3.8 Acessibilidade

| Elemento | Atributo |
|---|---|
| Nav links | `aria-current="page"` |
| Mobile toggle | `aria-expanded`, `aria-controls`, `aria-label="Abrir menu"` |
| Focus visible | Preservado via Tailwind `focus-visible:` |

### 3.9 Cache

**Ficheiro:** `resources/views/welcome.blade.php`

5 queries da homepage envolvidas em `Cache::remember()`:
- `public.categories.showcase` (TTL 1800s = 30min)
- `public.campaigns.current` (TTL 600s = 10min)
- `public.news.latest` (TTL 1800s = 30min)
- `public.stores.count` (TTL 3600s = 1h)
- `public.provinces.all` (TTL 86400s = 24h)

### 3.10 Enum labels

Adicionado método `label()` a todos os enums:

`ArticleStatus`, `BusinessType`, `CampaignStatus`, `InquiryStatus`, `InquiryType`, `OpportunityStatus`, `PartnerLevel`, `StoreStatus`, `StoreType`, `TeamCategory`, `VacancyStatus`, `VacancyType`

Casos adicionados:
- `InquiryStatus`: `Pending` (`pending`), `Read` (`read`)

### 3.11 Testes — 5 ficheiros criados

| Teste | Ficheiro | Tipo |
|---|---|---|
| DatabaseFoundation | `tests/Feature/DatabaseFoundationTest.php` | Feature |
| ContentModel | `tests/Feature/ContentModelTest.php` | Feature |
| BusinessInquiry | `tests/Feature/BusinessInquiryTest.php` | Feature |
| RouteResponse | `tests/Feature/RouteResponseTest.php` | Feature |
| ModelCast | `tests/Unit/ModelCastTest.php` | Unit |

---

## 4. Testes

| Métrica | Valor |
|---|---|
| Testes totais | 46 |
| Assertions | 110 |
| Falhas | 0 |
| Erros | 0 |
| Duração (MySQL) | ~24.7s |
| Duração (SQLite) | ~14.3s |

Comandos usados:
```bash
php artisan test --compact
```

---

## 5. Instalação limpa

### Migrate fresh com seed

```bash
php artisan migrate:fresh --seed
```

**Resultado:** 24 migrations executadas, 12 seeders executados (incluindo EditorialSeeder).

```bash
php artisan optimize:clear
npm run build
```

**Resultado build:** Vite 8.1.5, 6.56s, todos os assets compilados.

```bash
php artisan route:list --except-vendor
```

**Resultado:** 52 rotas registadas (públicas + Filament admin).

### Validação de rota pública

```bash
php artisan serve
# GET / → 200 OK
```

---

## 6. Decisões arquitecturais

### 6.1 Estratégia de migrations — Schema::hasTable()

Todas as migrations usam `Schema::hasTable()` para verificar se a tabela já existe antes de criar/alterar.

**Justificação:** O projecto tem tabelas que foram criadas manualmente antes do sistema de migrations estar operacional. Os guards permitem coexistência pacífica.

**Limitação conhecida:** A migration `000005_rename_product_families_to_categories` pode não executar o rename se a foundation migration já tiver criado a tabela `categories` antes. Nesse cenário legado, a tabela `product_families` permanece na base de dados como tabela órfã. Para uma instalação limpa (recomendada), isto não é um problema.

### 6.2 Estratégia de factories

- Cada factory usa `fake()->unique()` para slugs e títulos
- Factories definem apenas dados base; overrides são passados no `create()`
- States (`draft()`, `published()`, `b2b()`) disponíveis para testes específicos

### 6.3 Estratégia de testes

- Feature tests usam `RefreshDatabase` + SQLite `:memory:`
- RouteResponseTest usa `$this->seed()` no `setUp()`
- ModelCastTest é unit test (não toca na BD)
- `phpunit.xml` força `CACHE_STORE=array` para evitar cache persistence

### 6.4 SQLite vs MySQL

- Desenvolvimento: MySQL (`.env`), Testes: SQLite `:memory:` (`phpunit.xml`)
- Todas as migrations são compatíveis com ambos
- Excepção: `MODIFY COLUMN` na migration `000012` é MySQL-only

### 6.5 Cache

- Cache driver em produção: `database` (`.env`)
- Cache driver em testes: `array` (`phpunit.xml`)
- Homepage usa `Cache::remember()` para evitar queries repetidas

### 6.6 Sanitização

- `safe_html()` usa `strip_tags` com whitelist restritiva de tags HTML
- Expressões regulares removem atributos `on*` e URLs `javascript:`
- Função é carregada via `autoload.files` no `composer.json`

---

## 7. Pendências

### 7.1 Segurança e conformidade

- [ ] **Rate limiting** nos formulários públicos (`/para-negocios`, `/contacto`)
- [ ] **CSRF** — confirmar que todos os formulários usam `@csrf`
- [ ] **Notificações por email** para novas inquiries (pode ser fase separada)

### 7.2 Documentação

- [ ] Documento `docs/DATA_MODEL.md` — actualizar com schema actual (12 tabelas fundação + 8 auxiliares)
- [ ] Documento `docs/DOMAIN_ARCHITECTURE.md` — actualizar com decisões da FASE 15

### 7.3 Qualidade

- [ ] Testes de formulários (validação, submissão, CSRF)
- [ ] Testes Filament (CRUD resources)
- [ ] Cobertura de `safe_html()` com casos de edge (XSS vectors complexos)
- [ ] Testes de cache (TTL, invalidação)

### 7.4 Schema

- [ ] **Migration 000005:** Se `categories` já existe (criada pela foundation) e `product_families` existe (legado), a tabela `product_families` não é renomeada nem removida
- [ ] **Cache cleanup:** Quando `CACHE_STORE=database`, a tabela `cache` precisa de estar presente antes de qualquer query com `Cache::remember()`. Isto é garantido pela ordem das migrations, mas deve ser verificado em deploy.

### 7.5 Build

- [ ] Instalar `fontaine` para optimizar font fallbacks (warning do Vite)
- [ ] Remover `fontaine` warning ou instalar o package

---

## 8. Veredito final

**✅ FASE 15 — Database Foundation & Testing Hardening: CONCLUÍDA**

Validado:

| Validação | Resultado |
|---|---|
| `php artisan migrate:fresh --seed` | ✅ PASS — 24 migrations, 12 seeders |
| `php artisan test --compact` | ✅ PASS — 46 tests, 110 assertions, 0 failures |
| `npm run build` | ✅ PASS — Vite 8.1.5, 6.56s |
| `php artisan route:list` | ✅ PASS — 52 rotas |
| Instalação do zero | ✅ Confirmado — sem dependência de tabelas pré-existentes |
