# Gemini — Estratégia de Testes

> A v5 **não tem nenhum teste** para este módulo (não há suíte automatizada cobrindo
> `GeminiController`, `GeminiPromptRequest` ou `DataMaskTrait::maskTextWithAi`).
> Verificado por ausência de arquivos de teste correspondentes no repositório.
> O plano abaixo é para a **v7**.

---

## 1. Estratégia

| Tipo | Ferramenta | Escopo |
|---|---|---|
| **Feature** | PHPUnit + `RefreshDatabase` | Rota `gemini.ia`: validação, autorização, resposta, erro de credencial. |
| **Unit** | PHPUnit puro | Parsing título/corpo, pós-processamento de asteriscos, heurísticas de partição. |
| **Feature (serviço)** | `Http::fake()` | `GeminiClient`: payload enviado, headers, timeout, retry, tratamento de resposta inesperada. |
| **Feature (integração opcional)** | `Http::fake()` | `MaskingService`: fallback determinístico quando a IA falha. |
| **Manual** | Checklist de CMS | Fluxo do botão "Preencher com IA" ponta a ponta. |

**Regras de teste:**

1. **Nunca chamar a API real** nos testes → `Http::fake()` sempre. A chave de teste é
   um valor fictício (`'test-key'`).
2. **Nunca tocar a chave real**: os testes devem ler de `config('gemini.key')` com
   override, nunca do banco `configurations`.
3. **Determinismo**: o texto retornado pelo fake deve ser fixo, para testar o parsing.

---

## 2. Fixtures / factories necessárias

```php (ilustrativo)
// database/factories/ConfigurationFactory.php
public function definition(): array
{
    return [
        'title' => 'Chave da API do Gemini',
        'key' => 'gemini_api_key',
        'value' => 'test-key',
        'type' => 'text',
        'order' => 70,
        'max_input' => 255,
        'min_input' => 0,
        'category_configurations_id' => 1,
        'notnull' => false,
        'status_create' => true,
    ];
}
```

| Factory | Uso |
|---|---|
| `Configuration` (chave `gemini_api_key`) | Caminho de leitura da credencial (se a v7 mantiver a tabela) |
| `User` / `Sentinel user` com papel `super-admin` e com papel comum | Testes de autorização |
| `InformationsRequest` (e-SIC) | Verificar `mask_request` |
| `OmbudsmanRequest` (Ouvidoria) | Verificar `mask_description` |

> Se a credencial sair do banco na v7, a factory de `Configuration` **deixa de ser
> necessária** — é um bom sinal de que a migração foi concluída.

---

## 3. Casos de teste — Feature: rota `gemini.ia`

| # | Arrange | Act | Assert |
|---|---|---|---|
| F1 | Usuário **sem** autenticação | `POST /preencher-formulario` com `prompt` válido | **401/302** para login (na v5: **passa** — bug A7) |
| F2 | Usuário logado **sem** a permissão `gerar-conteudo-com-ia` | `POST` com `prompt` válido | **403** |
| F3 | Usuário logado e autorizado; `Http::fake()` responde `candidates.0.content.parts.0.text` = `"Título\n\nCorpo"` | `POST` com `prompt` = `"crie um release"` | **200**; JSON `title === 'Título'`, `text` contém `'Corpo'`; `Http::assertSent` valida `x-goog-api-key` no **header** e **ausência** de `?key=` na URL |
| F4 | idem | `POST` **sem** `prompt` | **422** com `errors.prompt` |
| F5 | idem | `POST` com `prompt` = `"abc"` (3 chars) | **422** |
| F6 | idem | `POST` com `prompt` = 5001 caracteres | **422** |
| F7 | idem | `POST` com `prompt` = array | **422** |
| F8 | Credencial ausente (`config('gemini.key') === null`) | `POST` com `prompt` válido | **503** (na v5: 500 com `title = "Erro de Configuração"`) e **nenhuma** chamada HTTP externa (`Http::assertNothingSent()`) |
| F9 | `Http::fake()` responde 429 | `POST` | resposta de erro controlada; **sem** stack trace nem URL com chave no corpo |
| F10 | `Http::fake()` responde 500 | `POST` | idem; mensagem **não** contém a chave |
| F11 | `Http::fake()` responde 200 sem `candidates` (ex.: `blockReason`) | `POST` | erro controlado com mensagem de bloqueio (na v5: "Resposta inesperada", bug B7) |
| F12 | 100 requisições em sequência com o mesmo usuário | — | a 21ª recebe **429** (`throttle:20,1`) |
| F13 | Requisição sem `X-CSRF-TOKEN` | `POST` | **419** (`VerifyCsrfToken`) |
| F14 | idem F3, mas com `Accept: application/json` e `Content-Type: application/json` | — | o corpo é JSON puro (sem redirecionamento HTML de validação) |

---

## 4. Casos de teste — Unit: parsing de título/corpo

Classe alvo (v7): `App\Services\Gemini\ContentSplitter` (extrai a heurística da v5 para
uma classe testável).

| # | Entrada (`fullText`) | Esperado |
|---|---|---|
| U1 | `"Título\n\nCorpo do texto"` | title = `"Título"`, body = `"Corpo do texto"` (separador removido na v7) |
| U2 | `"Título único"` (sem `\n\n`, sem ponto) | title = `"Título único"` (< 150), body = `""` |
| U3 | `"Primeira frase. Resto do texto"` | title = `"Primeira frase."`, body = `"Resto do texto"` |
| U4 | `"a" . str_repeat("b", 200)` | title com no máximo 150 **caracteres** (não bytes) e sufixo `...` |
| U5 | `"Ação Ação Ação Ação"` (acentos) | title **sem byte UTF-8 partido** (bug B5) — `mb_check_encoding($title, 'UTF-8') === true` |
| U6 | texto com `.` exatamente na posição 150 | title = corte em 150 chars (limite), não o ponto |

---

## 5. Casos de teste — Feature: serviço `GeminiClient`

| # | Arrange | Act | Assert |
|---|---|---|---|
| S1 | `Http::fake()` | `generate('olá')` | `Http::assertSent` → URL `.../models/<model>:generateContent`; body tem `contents[0].parts[0].text`; header `x-goog-api-key` presente; **`key=` ausente da query string** |
| S2 | `Http::fake()` | `generate()` | **sem** `systemInstruction` no caminho A (a instrução vai como `systemInstruction`, não concatenada) |
| S3 | `Http::fake()` 429 na 1ª, 200 na 2ª | `generate()` | resultado OK; `Http::assertSentCount(2)` |
| S4 | `Http::fake()` 500 sempre | `generate()` | lança exceção de domínio `GeminiApiException` com status |
| S5 | `Http::fake()` 200 com corpo inválido | `generate()` | lança `UnexpectedResponseException` (não notice de PHP) |
| S6 | `config('gemini.key')` nulo | `generate()` | lança `GeminiNotConfiguredException` **antes** de qualquer requisição |

---

## 6. Casos de teste — Feature: mascaramento (caminho B)

| # | Arrange | Act | Assert |
|---|---|---|---|
| M1 | Sem credencial (`config('gemini.key')` nulo) | `maskText('Meu CPF é 123.456.789-00')` | resultado **não vazio**; contém `***`; **nenhuma** requisição HTTP (`Http::assertNothingSent()`); log `warning` emitido |
| M2 | `Http::fake()` responde 500 | `maskText(...)` | retorna o **fallback determinístico**; sem exceção |
| M3 | `Http::fake()` responde 200 com `candidates...text` | `maskText(...)` | retorna o texto do modelo; `Http::assertSent` → payload com `model_masking` |
| M4 | `Http::fake()` 200 com corpo inesperado | `maskText(...)` | retorna o fallback determinístico |
| M5 | idem M3 | o texto do modelo é `"João ***"` | o pós-processamento colapsa `"* *"` em `"**"` |
| M6 | idem M3 | o texto do modelo é `"Rua , 234"` | o pós-processamento remove o espaço antes da vírgula |
| M7 | Controller do e-SIC | `POST /informacao-solicitada` | `informations_requests.mask_request` preenchido; `request` original preservado |
| M8 | Controller da Ouvidoria | `POST` de ouvidoria | `ombudsman_requests.mask_description` preenchido |
| M9 | Fila habilitada | `POST` de ouvidoria | job `MaskOmbudsmanDescription` despachado (`Queue::assertPushed`) |

---

## 7. Testes de segurança (regressão dos achados)

| # | Assert |
|---|---|
| X1 | A rota `gemini.ia` **não** responde 200 para usuário anônimo. |
| X2 | A resposta de erro **não** contém a substring da chave (`config('gemini.key')`). |
| X3 | A URL enviada à API **não** contém `?key=` nem `x-goog-api-key` em query string. |
| X4 | Nenhum log escrito contém o corpo do prompt ou da resposta da IA. |
| X5 | `activity_log.properties` **não** contém o valor da credencial após editar a configuração. |
| X6 | A tela de Configurações renderiza o campo da chave com `type="password"` (ou masked). |
| X7 | O `configurations.value` da chave **não** é retornado em nenhum `toArray()`/`toJson()` do model. |
| X8 | Nenhum arquivo versionado contém um literal com padrão de chave do Gemini (`AIza`). |

---

## 8. Teste de sanidade do modelo (manual, opcional)

Não automatizável de forma estável (o modelo pode variar). Checklist manual:

1. Pedir "um release sobre o Carnaval em 3 parágrafos" → o título deve ter ≤ 150 caracteres.
2. Verificar que o corpo **não** contém Markdown (`**`, `#`, `-` de lista).
3. Verificar que o texto **não** contém frases de instrução ("adicione tal coisa").

---

## 9. Fixtures de view / seed para ambiente de teste

| Seed | Conteúdo |
|---|---|
| `GeminiConfigurationSeeder` (teste) | Insere `configurations` com `key = 'gemini_api_key'` e `value = 'test-key'` |
| Usuário de teste | `super-admin` (acesso total) e `editor` (sem a permissão de IA) |
| `Http::fake()` por padrão | `Http::preventStrayRequests()` + fake global com resposta padrão `{"candidates":[{"content":{"parts":[{"text":"Título de teste\n\nCorpo de teste"}]}}]}` |

> O seed de produção `v5/database/seeds/AddToConfigurationsGeminiApiKeyValue.php` **não
> deve** ser usado em ambiente de teste — ele grava uma credencial real.

---

## 10. Como rodar

### Laravel moderno (v7)

```bash
# tudo
php artisan test

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

# feature da rota
php artisan test tests/Feature/Cms/GeminiControllerTest.php

# unit do parsing
php artisan test tests/Unit/Services/Gemini/ContentSplitterTest.php

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

### Laravel 5.5 (v5) — se for necessário validar o legado

```bash
cd v5
./vendor/bin/phpunit
```

> A v5 **não possui** suíte para este módulo; a validação do legado é manual
> (checklist do item 12).

---

## 11. Checklist manual do legado (v5)

| # | Passo | Resultado esperado |
|---|---|---|
| L1 | CMS → Notícias → cadastrar → "Preencher com IA" → prompt "crie um release sobre o Dia da Independência" | Modal abre; o botão fica habilitado só com texto |
| L2 | Prompt vazio | Botão desabilitado; mensagem "O campo de prompt não pode estar vazio." |
| L3 | Prompt válido | Título e corpo preenchidos; modal fecha |
| L4 | Limpar `configurations.value` e repetir | **500**; o operador vê a mensagem genérica (bug B11) — o texto "Erro de Configuração" **não** aparece |
| L5 | Prompt com 3 caracteres | 422; mensagens de validação exibidas |
| L6 | Portal → e-SIC → enviar pedido com CPF | `mask_request` mascarado; relatório público exibe o texto mascarado |
| L7 | Desconectar a rede e enviar pedido | Pedido **é** salvo; `mask_request` = máscara determinística |
| L8 | CMS → Configurações → aba Opções → "Chave da API do Gemini" | Campo de texto com o valor em claro |

---

## 12. Referências

- `v5/app/Http/Controllers/Prefecture/GeminiController.php`
- `v5/app/Http/Requests/GeminiPromptRequest.php`
- `v5/app/Http/Traits/DataMaskTrait.php:213-287`
- `v5/public/assets/js/custom/utilities/helpers/ia-modal.js`
- `v5/database/seeds/AddToConfigurationsGeminiApiKeyValue.php`
