Files
Exporta-SD/scripts/teste_e2e.py
T
wanderandClaude Sonnet 5 0d4fdd2533 Commit inicial: app de exportação da SD + deploy Docker/Coolify
Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON),
scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile,
docker-compose.yml) para VPS via Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:07:38 -03:00

403 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
teste_e2e.py — o fluxo inteiro, do upload ao download, sem servidor rodando.
Usa o test client do Flask: exercita exatamente as rotas de app/servidor.py,
sem porta aberta e sem depender de o servidor estar de pé.
O que esta suíte existe para provar, na ordem:
1. upload NUNCA baixa nada — o download só sai do /exportar, depois de um
clique, e só quando o exportador não tem pendência;
2. SD com pendência é recusada no servidor mesmo por POST direto, e a tela
mostra formulário SÓ com os campos pendentes;
3. a correção feita no formulário chega ao JSON — e o JSON é byte-idêntico
ao do exporta_sd.py na linha de comando sobre um SD.md com a mesma
correção;
4. campo não pendente não é tocado por um Salvar (o formulário só manda o
que está pendente, e o servidor só toca no que vem).
Roda assim:
python3 scripts/teste_e2e.py
0 = tudo passou; 1 = alguma checagem falhou
"""
from __future__ import annotations
import html as H
import json
import re
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
RAIZ = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RAIZ / "scripts"))
sys.path.insert(0, str(RAIZ / "app"))
from caminhos import PROJETOS # noqa: E402
from regras_sd import ler_sd_texto, secao # noqa: E402
import servidor # noqa: E402
FALHAS: list[str] = []
CLIENTE = servidor.app.test_client()
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
SD20 = PROJETOS / "P4-cancer-de-mama/sds/SD20-siscan-d1/SD.md"
SD24 = PROJETOS / "P5-portal-rmds/sds/SD24-mvp-institucional/SD.md"
SD31 = PROJETOS / "P7-assistencia-farmaceutica/sds/SD31-fundacao-da-base-de-dispensacao/SD.md"
def ck(cond, desc):
print(f" {'ok ' if cond else 'FALHA'} {desc}")
if not cond:
FALHAS.append(desc)
def post(rota, dados=None, **kw):
return CLIENTE.post(rota, data=dados or {}, follow_redirects=True, **kw)
def texto(resp):
return resp.get_data(as_text=True)
def enviar(caminho: Path, nome: str | None = None, conteudo: bytes | None = None):
dados = {"arquivo": (BytesIO(conteudo if conteudo is not None else caminho.read_bytes()),
nome or caminho.name)}
return post("/upload", dados, content_type="multipart/form-data")
def pagina():
return texto(CLIENTE.get("/revisar", follow_redirects=True))
def reiniciar():
post("/reiniciar")
def campos_do_form(pag: str) -> dict:
"""Relê o formulário de pendências como o navegador o reenviaria."""
campos = {}
for m in re.finditer(r'<input[^>]*name="([^"]+)"[^>]*>', pag):
tag = m.group(0)
if any(t in tag for t in ('type="radio"', 'type="checkbox"', 'type="file"')):
continue
v = re.search(r'value="([^"]*)"', tag)
campos[m.group(1)] = H.unescape(v.group(1)) if v else ""
for m in re.finditer(r'<textarea[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', pag, re.S):
campos[m.group(1)] = H.unescape(m.group(2))
for m in re.finditer(r'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', pag, re.S):
sel = re.search(r'<option value="([^"]*)"[^>]*selected', m.group(2))
campos[m.group(1)] = H.unescape(sel.group(1)) if sel else ""
return campos
def estado_na_tela(pag: str) -> str:
"""O estado que a tela DECLARA ("estado: <strong>…</strong>"). A trilha do
cabeçalho repete os nomes de todos os estados, então procurar o texto solto
daria positivo sempre."""
m = re.search(r"estado: <strong>([^<]+)</strong>", pag)
return m.group(1) if m else ""
def baixou(resp) -> bool:
return "attachment" in resp.headers.get("Content-Disposition", "")
def exportar_pela_cli(sd_md: Path) -> str:
with tempfile.TemporaryDirectory() as tmp:
r = subprocess.run([sys.executable, str(RAIZ / "scripts/exporta_sd.py"), str(sd_md),
"--dir", tmp], capture_output=True, text=True, encoding="utf-8")
saidas = list(Path(tmp).glob("*.json"))
if not saidas:
return f"(CLI não gerou JSON: {r.stderr.strip()})"
return saidas[0].read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
def preencher_linhas(pag: str, linha: str = "1090-L1") -> str:
"""Responde no formulário todas as pendências de `linha_os` com a mesma
linha — o que a pessoa faria copiando da tela da OS. Devolve a página."""
c = campos_do_form(pag)
for k in c:
if k.endswith("__linha_os"):
c[k] = linha
return texto(post("/revisar", c))
def sd_pronta():
print("\n1. SD31: só falta a linha de OS; preenchida, o clique exporta — e nada sai antes dele")
reiniciar()
r = enviar(SD31)
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
pag = texto(r)
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag,
"estado: com pendências — as 4 linhas de OS que o SD.md não declara")
c = campos_do_form(pag)
ck(set(c) == {f"e{i}__linha_os" for i in range(4)},
f"o formulário traz SÓ os quatro campos de linha de OS, como texto ({sorted(c)})")
ck("<select" not in pag, "sem select de candidatas: não há de-para local, a pessoa copia da tela da OS")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada enquanto falta a linha")
pag = preencher_linhas(pag)
ck(estado_na_tela(pag) == "Pronta para exportar", "preenchidas, estado: pronta para exportar")
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
"sem pendência não há formulário nenhum na tela")
ck("1090-L1" in pag, "a linha de OS declarada aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado")
r = post("/exportar")
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
baixado = texto(r)
p = json.loads(baixado)
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável saiu com a linha de OS preenchida")
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
"status nos rótulos do contrato")
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
# A mesma régua da CLI: grava o SD.md corrigido (Salvar SD.md) e exporta
# esse arquivo pela linha de comando — tem de dar os mesmos bytes.
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4,
"o SD.md gravado leva as quatro linhas declaradas")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
def pendencia_de_semanas():
print("\n2. SD24: semanas declaradas ≠ datas → pendente; corrigido no formulário, exporta")
reiniciar()
pag = texto(enviar(SD24))
ck(estado_na_tela(pag) == "Com pendências", "estado: com pendências")
ck("18 dias" in pag and "3 semana" in pag and "declara 1" in pag,
"a pendência diz os dois números — derivado e declarado — e aponta o entregável")
c = campos_do_form(pag)
ck(set(c) == {"e3__semanas", "e3__inicio", "e3__fim"},
f"o formulário traz SÓ o entregável pendente: semanas + as datas dele ({sorted(c)})")
r = post("/exportar")
ck(not baixou(r) and "Exportação recusada" in texto(r),
"POST /exportar direto é recusado pelo servidor, com a razão")
c["e3__semanas"] = "3"
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidas as semanas, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e4 = p["entregaveis"][3]
ck(e4["numero_semanas"] == 3 and e4["ust"] is None and e4["valor_entregavel"] is None,
"o JSON leva 3 semanas; UST e valor saem null para o importador derivar")
ck(p["sd"]["nome"] == "MVP Institucional e Protótipos do Portal RMDS",
"o título — que NÃO estava no formulário — continua intacto")
# Apagar a declaração também resolve: em branco, deriva.
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__semanas"] = ""
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"semanas em branco = 'derive das datas' — também destranca")
def teto_de_28_dias():
print("\n3. SD8: janela de 31 dias e duas divergências de semanas")
reiniciar()
pag = texto(enviar(SD8))
ck("3 pendência(s)" in pag, "três pendências")
ck("31 dias" in pag and "teto de 28 dias" in pag, "o teto de 28 dias é nomeado, com os dias reais")
c = campos_do_form(pag)
ck(set(c) == {"e1__semanas", "e1__inicio", "e1__fim", "e2__semanas", "e2__inicio", "e2__fim",
"e7__inicio", "e7__fim"},
f"formulário só dos entregáveis 2, 3 e 8 — janela inteira de cada um ({sorted(c)})")
c["e1__semanas"] = "2"
c["e2__semanas"] = "1"
c["e7__fim"] = "2026-06-30" # 28 dias → 4 semanas, o que o SD já declarava
pag = texto(post("/revisar", c))
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidos os três, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
e8 = p["entregaveis"][7]
ck(e8["data_prevista_termino"] == "2026-06-30" and e8["numero_semanas"] == 4 and e8["ust"] is None,
"o entregável 8 saiu com a data corrigida e 4 semanas; a UST é do importador")
ck(p["entregaveis"][1]["numero_semanas"] == 2 and p["entregaveis"][2]["numero_semanas"] == 1,
"os entregáveis 2 e 3 saíram com as semanas das datas")
ck(p["sd"]["status"] == "Em andamento" and e8["status"] == "Documentado",
"estado `documentada` → sd.status 'Em andamento', entregável 'Documentado'")
ck(p["entregaveis"][0]["alocacoes"][0]["perfil"] == "Especialista de Inteligência (Cientista de Dados)",
"o alias do perfil saiu traduzido para o nome do cadastro")
def po_e_objetivo():
print("\n4. SD10: PO em branco e Seção 1 ausente → formulário com os dois; corrigidos, exporta")
reiniciar()
pag = texto(enviar(SD10))
c = campos_do_form(pag)
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)},
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})")
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
c["sd__po_responsavel"] = "Guilherme Bernardino"
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
for i in range(5):
c[f"e{i}__linha_os"] = "1090-L1" if i == 2 else "2587-L3" # n3 é I-02; os demais, I-05
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
p = json.loads(texto(post("/exportar")))
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
ck(p["sd"]["objetivo"].startswith("Permitir à equipe do CRIE"), "o objetivo chega ao JSON")
corpo = servidor.ATUAL["sessao"].corpo
ck(secao(corpo, "1") is not None and corpo.index("## 1.") < corpo.index("## 5."),
"a Seção 1 foi criada no corpo, antes da Seção 5")
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
"cada entregável saiu com a linha que a pessoa declarou")
def sem_datas():
print("\n5. SD20: sem datas, e PO 'PENDENTE' — reprovada, nada baixa")
reiniciar()
pag = texto(enviar(SD20))
ck("11 pendência(s)" in pag, "onze pendências: PO + 5 × (início, fim)")
ck("convenção do repositório" in pag, "o PO 'PENDENTE — ...' é reconhecido como não-nome")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada")
def dado_invalido():
print("\n6. Dado inválido vira mensagem na tela, não traceback no console")
reiniciar()
c = campos_do_form(texto(enviar(SD24)))
c["e3__inicio"] = "31/13/2026"
pag = texto(post("/revisar", c))
ck("ilegível" in pag and "Traceback" not in pag, "data ilegível é pendência nomeada")
c = campos_do_form(pag)
c["e3__inicio"] = "2026-07-13"
c["e3__fim"] = "2026-07-10"
ck("anterior ao início" in texto(post("/revisar", c)), "fim antes do início é pendência nomeada")
def entrada_ruim():
print("\n7. Entrada ruim: uma mensagem específica para cada caso")
for nome, conteudo, esperado, desc in (
("notas.txt", b"nao sou markdown", "não é um arquivo .md", "extensão errada"),
("vazio.md", b" \n", "está vazio", "arquivo vazio"),
("semfm.md", "# só prosa\n".encode(), "primeira linha deve ser", "sem frontmatter"),
("aberto.md", "---\nsd: X\n\n# sem fecho\n".encode(), "não fechado", "frontmatter não fechado"),
("lista.md", "---\n- a\n- b\n---\n".encode(), "não é um mapeamento", "frontmatter que não é mapa"),
("latin.md", "---\nsd: Vacinação\n---\n".encode("latin-1"), "não está em UTF-8", "fora de UTF-8"),
):
reiniciar()
ck(esperado in texto(enviar(Path(nome), nome, conteudo)), desc)
reiniciar()
pag = texto(enviar(Path("minima.md"), "minima.md",
"---\nsd: X\nestado: voando\nentregaveis: []\n---\n# t\n".encode()))
ck("fora da notação" in pag and "não tem entregáveis" in pag and "voando" in pag,
"frontmatter mínimo: identificador, estado e entregáveis reprovados com nome")
ck("Este conserto é no" in pag, "pendência estrutural aponta para o SD.md, sem controle na tela")
def writeback():
print("\n8. Salvar SD.md: correção gravada, comentários preservados, prosa reanexada")
reiniciar()
c = campos_do_form(texto(enviar(SD10)))
c["sd__po_responsavel"] = "PO de teste"
c["corpo__objetivo"] = "Objetivo de teste."
post("/revisar", c)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists(), "arquivo gravado")
txt = alvo.read_text(encoding="utf-8")
ck("CORREÇÃO 17/08/2026" in txt and "protótipo navegável está EM CRIAÇÃO" in txt,
"o comentário datado do campo `estado` sobreviveu inteiro")
ck("_corpo" not in txt, "nenhuma chave _corpo dentro do frontmatter")
ck('po_responsavel: PO de teste' in txt or 'po_responsavel: "PO de teste"' in txt, "o PO foi gravado")
ck("## 1. Objetivo" in txt and "## 5. Enquadramento no Termo de Referência" in txt,
"a Seção 1 nova e a prosa original estão no arquivo")
d, _ = ler_sd_texto(txt)
ck(d["po_responsavel"] == "PO de teste" and len(d["entregaveis"]) == 5,
"o arquivo gravado reparseia com os mesmos entregáveis")
reiniciar()
enviar(SD10)
with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo)})
ck(not alvo.exists(), "sem a caixa de confirmação marcada, nada é gravado")
def linha_de_os_no_formulario():
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga")
sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
"entregaveis:\n"
' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
' linha_os: "L1"\n inicio: 2026-09-01\n fim: 2026-09-11\n'
"---\n# T\n\n## 1. Objetivo\n\nAlgo.\n").encode("utf-8")
reiniciar()
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag, "campo de texto, sem lista local")
c = campos_do_form(pag)
c["e0__linha_os"] = "9999-L7"
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"linha bem formada que o repositório não conhece PASSA — quem confere existência é o banco")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
reiniciar()
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
c["e0__linha_os"] = ""
pag = texto(post("/revisar", c))
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")),
"em branco não há fallback nenhum: continua pendente e não exporta")
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0],
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia")
def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar()
preencher_linhas(texto(enviar(SD31)))
post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"})
pag = pagina()
ck(estado_na_tela(pag) == "Pronta para exportar",
"a edição volta a sessão para 'pronta' — o que foi exportado era outra versão")
ck("outro título" in pag, "e a edição foi aplicada")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
sd_pronta()
pendencia_de_semanas()
teto_de_28_dias()
po_e_objetivo()
sem_datas()
dado_invalido()
entrada_ruim()
writeback()
linha_de_os_no_formulario()
editar_depois_de_exportar()
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(" ·", f)
return 1
print("ponta a ponta: tudo passou.")
return 0
if __name__ == "__main__":
sys.exit(main())