# Teste E2E — Assistente RH (módulo Fichas) — 2026-09-22

Teste independente da webapp em DEV (http://192.168.69.150), feito por HTTP real (curl com
login, cookies e CSRF, contra o CT diretamente e via o endereço público) e, para o motor/BD,
por SSH ao CT 117 e ao Sabichão dev (CT 100, BD `dev`). Suite Pest corrida a cada correção.
Baseline: 179 testes a passar. Final: **203 testes a passar** (24 novos, todos de regressão
às falhas encontradas abaixo). PT-PT, sem emojis.

## Resumo

- Cenários com pelo menos uma falha real: **A, B, C, E, F, G, H, I** (8 de 9).
- Único cenário sem nenhuma falha encontrada: **D**.
- Bugs reais encontrados e corrigidos: **9** (mais 2 gaps menores registados, não corrigidos).
- Não testado: interação visual real em browser (zoom/painel de imagem/tema) — revisto por
  código (`app.js`, `viewer.js`) e pelo HTML servido, nunca clicado num browser real (sem
  browser headless disponível no CT nem no Mac para este teste); ver secção final.

## A — Login

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Login certo (rh / password de `storage/app/.rh-pass`) | 302 → `/fichas`, sessão criada | Confirmado | ok |
| Login errado | 302 de volta a `/login`, mensagem "Credenciais inválidas." | Confirmado | ok |
| 6 tentativas seguidas erradas | Bloqueio ao fim de 5/min (mesma chave username+IP), mensagem com contagem decrescente | Bloqueou na 4ª/5ª tentativa consoante o histórico da sessão de teste; mensagem "Demasiadas tentativas. Tente de novo em N segundos." confirmada; limite de 5 confirmado no código (`LoginRequest::assegurarNaoBloqueado`) | ok |
| Logout | 302 → `/login`, sessão terminada | Confirmado (acesso a `/fichas` depois dá 302) | ok |
| Acesso a `/fichas` sem sessão | 302 → `/login` | Confirmado | ok |
| Fila (`GET /fichas`) logo a seguir ao login | 200, tabela da fila | **500** (`Permission denied` a ler `resources/views/fichas/index.blade.php`) | falha → corrigido |

**Bug 1 — corrigido.** `resources/views/fichas/index.blade.php` tinha permissões `rwx------`
(só o dono `claude` conseguia ler; `www-data`/php-fpm não), tanto na fonte no Mac como,
por `rsync -a`, no CT — qualquer limpeza de cache de views ou arranque dava 500 mudo na
fila. Corrigido `chmod 644` nos dois lados. Commit `6f5529a`.

## B — Novo caso individual

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| NIF inválido (111111111) | Mensagem PT "NIF inválido (checksum)." | Confirmado | ok |
| Data inválida (2026-13-45 / 31/02/2026) | Mensagem PT | Confirmado | ok |
| MAI com letras (ABC123) | Mensagem PT | **"The mai field must be 6 digits." (inglês)** | falha → corrigido |
| Zona inexistente (Marte) | Mensagem PT "Zona não reconhecida." | Confirmado | ok |
| Caso válido | Entra na fila, `estado=registado`, formulário livre para o próximo | Confirmado | ok |

**Bug 2 — corrigido.** O projeto não tinha `lang/en` nem `lang/pt` (Laravel 12 não vem com
eles por omissão); com `APP_LOCALE=pt` mas sem ficheiros de tradução, todas as regras
nativas do Laravel (`digits`, `required`, `integer`, `max`, ...) saíam em inglês — só as
mensagens escritas à mão no código (NIF, data, zona) estavam em PT. Publicado `lang/en`
(fallback) e `lang/pt` (traduzido, com `attributes` para os nomes dos campos da app).
Commit `6f5529a`.

## C — Lote (5 linhas, 2 com erro)

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| 2 linhas válidas, 1 NIF inválido, 1 MAI repetido na lista, 1 já em fila | Válidas identificadas; erradas com mensagem explicando porquê | NIF inválido e "já existe um caso ativo" identificados corretamente; **MAI repetido dentro da mesma lista NÃO era detetado** — a pré-visualização mostrava "2 linhas OK", mas ao confirmar só 1 caso era criado, sem qualquer aviso | falha → corrigido |
| Confirmar lote só com as linhas válidas | Todos os casos válidos entram, despacham `LerCaso` | Confirmado (depois da correção) | ok |
| Lote com uma linha em erro, a confirmar | Recusado com mensagem, nada é criado | Confirmado | ok |

**Bug 3 — corrigido.** `FichasController::validarLote()` só verificava duplicados contra a
BD, nunca contra as próprias linhas do lote colado. Duas linhas idênticas na mesma lista
passavam as duas como "válidas"; ao confirmar, `criarCaso()` descartava a 2ª em silêncio
(proteção só ao nível da BD), sem nenhuma explicação ao utilizador. Corrigido: `validarLote`
agora deteta "duplicada da linha X nesta mesma lista". Commit `83033b7`.

**Achado lateral (deploy, documentado, não é bug de produto).** A correção do bug 3 só
"pegou" depois de reiniciar o `php-fpm` — `php artisan optimize:clear` não invalida o OPcache
do pool `www-data`; `tinker` (CLI) via logo o código novo, mascarando o problema. Documentado
em `docs/DEPLOY.md` §10.

## D — Leitura real pela fila (3 casos da NAS)

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| MAI 157459, 156614, 125457 já registados e lidos em sessões anteriores | `por_rever` ou `bloqueado`, tabela preenchida | Os 3 estavam `por_rever` com a tabela completa (cartões, campos, documentos) | ok |
| Reler o caso 157459 | Nova extração (versão incrementa), estado recalculado | Versão passou de 8 para 9, nova extração criada | ok |
| Nenhum aprovado nem aplicado | — | Confirmado, nenhum dos 3 tocado além da releitura | ok |

Nenhuma falha neste cenário.

## E — Página do caso (real, MAI 157459)

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Ordem dos campos (spec da skill) | nome, nascimento, ncc, validade_cc, niss, filho_de, val_rc, naturalidade, concelho, distrito, nacionalidade, morada, iban, ... | Confirmado | ok |
| Datas dd/mm/aaaa | Todas as datas (validade de cartão, validade CC) em dd/mm/aaaa | Confirmado | ok |
| NCC com 8 dígitos, Luhn sobre os 12 | Caixa mostra só os 8 dígitos, hint "Luhn OK (8+1+3)" | Confirmado (`13253021`, hint "Luhn OK (13253021 0 ZW5)") | ok |
| Filho de normalizado | Title Case com partículas | Confirmado | ok |
| `[img]` em todas as linhas de campo com documento de origem e na lista de documentos da pasta | Botão funcional, devolve a imagem/PDF rasterizado | Confirmado — `fichas/documentos/{id}/pagina/{n}` devolve JPEG real (`Content-Type: image/jpeg`, `Cache-Control: no-store`), inclusive para o PDF do RC rasterizado. Campos sem documento de origem (portal: email, contacto, emergência) legitimamente sem `[img]`, como no mockup | ok |
| Leituras alternativas "usar" | Botões clicáveis que copiam Vision/Qwen para a caixa | **Eram texto simples (`<span>`), sem nenhum botão nem hook de JS** — o RH tinha de copiar o valor à mão | falha → corrigido |
| Editar uma data em dd/mm/aaaa e em ddmmaaaa | Ambas aceites, gravadas como aaaa-mm-dd | Confirmado (`31122031` → grava e mostra `31/12/2031`) | ok |
| Campo obrigatório vazio bloqueia Aprovar | Botão `disabled` enquanto houver `rever` | Confirmado | ok |
| Desmarcar um cartão | Deixa de contar como especialidade, fica gravado | Confirmado (cartão ARE desmarcado e depois restaurado ao estado original) | ok |
| Reler | Nova extração, versão incrementa | Confirmado (ver cenário D) | ok |

**Bug 4 — corrigido.** As leituras alternativas do Vision/Qwen num campo `rever` apareciam
como `<span>` de texto, não como os botões "[usar]" descritos no `PLANO.md` — o CSS
(`.alt button`) já esperava botões, só a view tinha regredido para `<span>`. Corrigido para
`<button data-usar-campo/data-usar-valor>` com handler novo em `app.js` (mesmo padrão de
delegação de eventos do `[img]`). Commit `7a0e942`.

**Achado não corrigido (menor).** A linha "Especialidades resultantes: VIG/ARD/..." do
mockup não aparece na página real (só as checkboxes dos cartões, que funcionam
corretamente). Cosmético, não bloqueia nada — registado para uma próxima sessão.

## F — Fluxo completo com caso sintético (MAI 900020, NIF fictício 290005230, série `2900052xx` nunca usada em prod)

Fixtures geradas com Pillow no mac mini (reutilizando `~/leitor/.venv/bin/python
/tmp/gerar_fixtures.py`, adaptado para MAI/NIF novos), servidas por `FICHAS_PROCESSOS_PATH_TESTE`
(segundo caminho, só tentado quando o MAI não existe na NAS real). Reserva criada por SQL
no Sabichão dev (`processo` id 279, `estado='Reservado'`).

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Registar → ler | Fila → `por_rever`, tabela preenchida | Confirmado (worker processou em ~15 s) | ok |
| Rever e resolver os campos `rever` | Editar/usar leituras resolve o campo, Aprovar desbloqueia | **Um campo `rever` cujo `valor_final` já era a leitura mostrada (ex. "Só lido pelo Qwen: confirmar") nunca saía de `rever`** — reenviar o mesmo texto (com ou sem clicar "[usar]") era tratado como "nada mudou" e ignorado em silêncio; Aprovar ficava bloqueado para sempre sem explicação | falha → corrigido |
| Aprovar | Manifesto + hashes, estado `aprovado` | Confirmado, depois da correção acima | ok |
| Editar um campo depois de aprovado | Aprovação anulada, volta a `por_rever` | Confirmado (editar `contacto` fez `aprovado` → `por_rever`) | ok |
| Aprovar de novo | `aprovado` | Confirmado | ok |
| Dry-run | `dry_run_ok`, mostra as operações previstas | Confirmado | ok |
| Aplicar em DEV | `inserido_verificado`, `sabichao_id_processo` preenchido | Confirmado (`proc=279`) | ok |
| Conferência pós-inserção | SELECT ao Sabichão comparado com o manifesto | Confirmado — todas as linhas mostradas `ok` (nome, mai, ncc, niss, filho_de, nascimento, validade_cc, morada, ...); `processo.estado` no Sabichão passou a `Ativo` | ok |
| PDF | Download com `Content-Type: application/pdf`, `no-store` | Confirmado (PDF válido, 1 página) | ok |
| Email por caso | Enviado, redirecionado para a caixa de teste do Sabichão (nunca o destinatário real) | Confirmado — resultado real do wrapper: "Email ENVIADO mas REDIRECIONADO pelo modo_teste: foi para ruipedrox13@gmail.com, NAO para recursoshumanos@segunor.pt" | ok |
| Tentar aplicar outra vez (idempotente) | Sem duplicar, sem regressão de estado | Confirmado — POST direto a `/aplicar-dev` com o caso já `inserido_verificado` manteve o estado e não criou 2ª linha no Sabichão (`COUNT(*)=1`) | ok |
| Histórico (`fichas_execucoes`) | Ações `apply`/`verify`/`ficha_pdf`/`email` registadas, exit code 0 | Confirmado, as 4 visíveis na página do caso | ok |
| Reverter no Sabichão dev | `funcionarios`, `processo` (repõe `Reservado`), `status_plataforma_rh`, `log_funcionarios`, `processo_contrato`, `funcionarios_condicoes` limpos | Confirmado por `SELECT` — todas as contagens a 0 | ok |

**Bug 5 — corrigido, o mais significativo do teste.** `FichasController::guardar()`
comparava o texto submetido com `valor_final` e ignorava a gravação quando eram iguais —
mas um campo `rever` recém-lido pelo motor já vem muitas vezes pré-preenchido com a própria
leitura candidata (ex.: "Só lido pelo Qwen: confirmar" com `valor_final` = valor do Qwen).
Clicar "[usar]" nesse valor, ou simplesmente confirmar reescrevendo o mesmo texto, não
mudava nada — o campo ficava `rever` **para sempre**, bloqueando o Aprovar sem qualquer
pista na interface sobre porquê. Reproduzido a sério com o caso sintético (Nome/NCC/Validade
CC/NISS presos nesta armadilha). Corrigido com um sinal explícito: o botão "[usar]" passa a
marcar também `confirmados[campo]=1` (novo input oculto + `app.js`); `guardar()` promove o
campo a confiança alta quando esse sinal chega, mesmo sem alteração de texto, mas continua a
ignorar um reenvio do mesmo valor sem essa confirmação explícita (não promove em massa
campos que o RH nunca viu). Commit `3ed4351`.

## G — Segurança

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Documento de `documento_id` inexistente | 404 | Confirmado | ok |
| Path traversal na rota de documento | 404, sem escapar da pasta autorizada | Confirmado (`../../../../etc/passwd`, página negativa, página fora de intervalo) | ok |
| POST sem CSRF | 419 | Confirmado | ok |
| Caso com `id` inexistente | 404 | Confirmado | ok |
| Documento sem sessão | 302 → login | Confirmado | ok |
| Utilizador desativado tenta entrar | Recusado, mensagem genérica "Credenciais inválidas." (não revela que a conta existe) | Confirmado | ok |
| **Sessão já aberta continua válida depois do utilizador ser desativado por outra pessoa** | Deveria terminar de imediato | **Continuava com acesso total (`/fichas` → 200) até a sessão expirar sozinha (até 1h)** | falha → corrigido |

**Bug 6 — corrigido, falha de segurança real.** Desativar um utilizador (`/utilizadores/{id}/desativar`)
só impedia **novos** logins; uma sessão já autenticada mantinha acesso completo à app.
Middleware novo `GarantirUtilizadorAtivo`, no grupo `web`, verifica `Auth::user()->podeAutenticar()`
em cada pedido autenticado e termina a sessão de imediato (logout + invalidate + novo token
CSRF) assim que deixa de ser válida. Commit `7a0e942`.

## H — Utilizadores e Fiabilidade

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Criar utilizador (`utilizadores:criar` CLI, `--gerar-password`) | Utilizador novo, password só mostrada uma vez, gravada em `.rh-pass` | Confirmado (utilizador de teste `teste_e2e`, apagado no fim) | ok |
| Mudar password (UI, `/utilizadores/{id}/password`) | Login com a nova password funciona | Confirmado | ok |
| Desativar (UI) | Ver cenário G (sessão termina de imediato depois da correção) | Confirmado | ok |
| Página Fiabilidade mostra números depois do teste F | Tabela leitor × campo | Página carrega (200), com colunas "Leitor"/"Percentagem"; alimentada pelas aprovações do cenário F | ok |

**Achado não corrigido (menor).** A página `/utilizadores` não tem nenhum link visível para
"criar novo utilizador" — a rota (`GET /utilizadores/novo`) existe e funciona, só não está
ligada a partir do índice. Só descoberto por tentar a URL diretamente.

## I — Tema e "Voltar à fila"

| Passo | Esperado | Obtido | Estado |
|---|---|---|---|
| Tema claro/escuro, persistente por utilizador | Toggle grava `users.tema`, mantém-se depois de logout/login | Confirmado (`data-theme` mudou de `light` para `dark` e manteve-se depois de sair e voltar a entrar) | ok |
| "Voltar à fila" em todas as páginas autenticadas | Presente em fila, caso, novo caso, lote, utilizadores, fiabilidade | **Faltava em Fiabilidade** | falha → corrigido |

**Bug 7 — corrigido.** `resources/views/fiabilidade/index.blade.php` não tinha o link
"Voltar à fila" que todas as outras páginas têm. Alinhado com o mesmo padrão. Commit `c5619a7`.

## Não testado (declarado, não escondido)

- **Interação visual em browser real** (zoom do painel de imagem, arrastar, "Ajustar",
  fullscreen, troca de tema pelo botão a olho): sem browser headless disponível neste
  ambiente de teste (Mac sem Playwright/Chrome instalado para este projeto, CT sem X).
  Revisto o HTML servido (botões `data-viewer-action` presentes e coerentes) e o código de
  `viewer.js`/`app.js`, mas não clicado fisicamente. Recomenda-se uma passagem visual do
  utilizador antes de considerar a Fase 3 "vista por um humano no browser" definitivamente
  fechada para os campos novos.
- **Pedidos concorrentes / duplo clique / worker caído a meio / NAS desmontada / mini
  indisponível**: fora do âmbito pedido para esta sessão (já cobertos na Fase 3, ver
  `docs/FASE3-RESULTADOS.md`); não repetidos aqui.

## Lista de correções (commits)

| # | Bug | Commit |
|---|---|---|
| 1 | Permissões `rwx------` em `index.blade.php` → 500 na fila | `6f5529a` |
| 2 | Mensagens de validação nativas em inglês (sem `lang/pt`) | `6f5529a` |
| 3 | MAI+NIF+admissão duplicado dentro da mesma lista de lote não detetado | `83033b7` |
| — | Armadilha de deploy: OPcache do php-fpm não revalida sozinho (documentado) | `83033b7` (doc) |
| 4 | Leituras alternativas Vision/Qwen sem botão "usar" (texto simples) | `7a0e942` |
| 5 | Sessão de utilizador desativado continuava ativa (falha de segurança) | `7a0e942` |
| 6 | Campo REVER confirmado sem mudar o texto ficava bloqueado para sempre | `3ed4351` |
| 7 | Falta o link "Voltar à fila" na página Fiabilidade | `c5619a7` |

Suite Pest: 179 → **203 testes a passar** (0 falhas na versão final), 24 testes novos de
regressão (um por bug real, mais um cenário completo).

## Estado final do ambiente

- Fila de dev: os 3 casos reais (157459, 156614, 125457, todos `por_rever`) mais os 2 casos
  sintéticos históricos já existentes antes desta sessão (900010, 900020 — Fase 4 e este
  teste, ambos `inserido_verificado`), deixados como evidência, tal como o precedente da
  Fase 4. Nenhum caso real foi aprovado nem aplicado.
- Sabichão dev: confirmado limpo por `SELECT` de tudo o que este teste criou (NIF 290005230 /
  id_processo 279) — `funcionarios`, `processo`, `status_plataforma_rh`, `log_funcionarios`,
  `processo_contrato`, `funcionarios_condicoes` todos a 0.
- `.env` do CT 117: `FICHAS_PROCESSOS_PATH_TESTE` removido outra vez; pasta
  `storage/app/e2e-fixtures/` apagada.
- Utilizador de teste `teste_e2e` apagado; só `rh` fica na tabela `users`.
