#!/usr/bin/env bash # Pruebas del 28-oct: compras.py — las órdenes de compra del cotizador dentro del centro de mando. # Arranca app.py con carpetas, htpasswd y base SQLite temporales (como tests-1027.sh). No toca el servidor. # Uso: bash backend/tests-1028.sh [puerto] set -u REPO=${1:?ruta a hbox-docs}; PORT=${2:-8098} T=$(mktemp -d); SEC="secreto-de-pruebas-1028" export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock SESION_COOKIE_INSEGURA=1 \ ACTAS_ADMINS=leo PORT INCIDENCIAS_DIR=$T/incidencias INVENTARIO_DIR=$T/inventario TAREAS_DIR=$T/tareas \ EQUIPO_DIR=$T/equipo COMPRAS_DB=$T/drogheda.db COTIZADOR_SECRETO=$SEC mkdir -p $ACTAS_DIR $T/equipo $T/tareas $T/inventario ${PYTHON:-python3} - "$T" <<'EOF' import sys, os, json t = sys.argv[1] for u, n in [("leo", "Leo"), ("adriana", "Adriana"), ("jesush", "Jesús Honorato"), ("alan", "Alán"), ("miguel", "Miguel"), ("antonio", "Antonio"), ("jesus", "Jesús Romero"), ("claude", "Claude")]: json.dump({"id": u, "nombre": n, "area": "operaciones", "activo": True}, open(os.path.join(t, "equipo", u + ".json"), "w")) EOF PY=${PYTHON:-python3} $PY -c 'import flask, sqlite3' 2>/dev/null || { echo "!! $PY no tiene flask (si acabas de cambiar de versión de"; \ echo " Python, se quedó atrás). Cualquiera de las dos:"; \ echo " pip3 install --break-system-packages flask"; \ echo " python3 -m venv ~/.venv-hbox && ~/.venv-hbox/bin/pip -q install flask \\"; \ echo " && PYTHON=~/.venv-hbox/bin/python bash backend/tests-1028.sh \"$REPO\""; exit 2; } if command -v lsof >/dev/null && lsof -nP -iTCP:$PORT -sTCP:LISTEN >/dev/null 2>&1; then echo "!! el puerto $PORT ya está ocupado. Dame otro: bash backend/tests-1028.sh \"$REPO\" 8123"; exit 2 fi $PY $REPO/backend/app.py > $T/app.log 2>&1 & APP=$!; trap "kill $APP 2>/dev/null" EXIT U="http://127.0.0.1:$PORT" # Esperar a que conteste (hasta 15 s): si no arranca, el resto de las pruebas no dice nada útil. arriba="" for _ in $(seq 1 30); do if curl -fsS -o /dev/null "$U/api/salud" 2>/dev/null; then arriba=si; break; fi kill -0 $APP 2>/dev/null || break sleep 0.5 done if [[ -z "$arriba" ]]; then echo "!! app.py no arrancó en el puerto $PORT. Su bitácora:"; echo "────────────" sed -n '1,40p' $T/app.log; echo "────────────" echo "Pistas: falta flask en $PY · el puerto está ocupado · o un módulo de backend/ truena al cargar." exit 2 fi ok=0; fail=0 check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:240}"; fi; } no() { if [[ "$3" != *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — NO debía traer «$2»: ${3:0:240}"; fi; } as() { curl -s -H "X-Remote-User: $1" -H "Content-Type: application/json" "${@:2}"; } # La hora y la firma se calculan aparte para que la cabecera y lo firmado coincidan exactamente. # firma_t(cuerpo [secreto] [desfase]) -> la hora ISO con zona · firma_f(... [hora]) -> el hex del HMAC firma_t() { $PY -c ' import sys, datetime desf = int(sys.argv[1] or 0) print((datetime.datetime.now().astimezone() + datetime.timedelta(seconds=desf)).replace(microsecond=0).isoformat()) ' "${3:-0}"; } firma_f() { $PY -c ' import sys, hmac, hashlib sec_def, cuerpo, sec, t = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] print(hmac.new((sec or sec_def).encode(), t.encode() + b"\n" + cuerpo.encode(), hashlib.sha256).hexdigest()) ' "$SEC" "$1" "${2:-}" "${4:-$(firma_t "$1" "${2:-}" "${3:-0}")}"; } # Las cabeceras se pasan en dos variables (nada de mapfile: el bash 3.2 de macOS no lo trae). post() { local cuerpo="$1"; local t f t=$(firma_t "$cuerpo" "${2:-}" "${3:-0}"); f=$(firma_f "$cuerpo" "${2:-}" "${3:-0}" "$t") curl -s -H "Content-Type: application/json" -H "X-Hbox-Tiempo: $t" -H "X-Hbox-Firma: sha256=$f" \ -d "$cuerpo" $U/api/integracion/cotizador/evento; } get() { local t f t=$(firma_t ""); f=$(firma_f "" "" 0 "$t") curl -s -H "X-Hbox-Tiempo: $t" -H "X-Hbox-Firma: sha256=$f" "$U$1"; } echo "## arranque" check "módulos cargados sin error" "" "$(grep -i 'no se cargó' $T/app.log)" check "la base se creó" "drogheda.db" "$(ls $T/)" echo echo "## ítems del inventario (los que ligan los renglones)" as leo -d '{"codigo":"SOP-RTG","descripcion":"Soporte para RTG","categoria":"instalacion","unidad":"pza","por_instalacion":2}' $U/api/inventario > /dev/null as leo -d '{"codigo":"CBL-10","descripcion":"Cable calibre 10","categoria":"cable","unidad":"m"}' $U/api/inventario > /dev/null check "dos ítems dados de alta" '"total":2' "$(as leo $U/api/inventario)" echo echo "## firma" check "sin firma: 401" '"error":"Firma inv' "$(curl -s -H 'Content-Type: application/json' -d '{}' $U/api/integracion/cotizador/evento)" check "firma con otro secreto: 401" '"error":"Firma inv' "$(post '{"evento_id":"x","tipo":"oc.pagada","oc":{"folio":"OC-1","sitio":"ZLO"}}' 'otro-secreto')" check "firma de hace 10 min: 401" '"error":"Firma inv' "$(post '{"evento_id":"x","tipo":"oc.pagada","oc":{"folio":"OC-1","sitio":"ZLO"}}' '' -600)" check "items pide firma" '"ok":true' "$(get /api/integracion/inventario/items)" check "items trae los códigos" 'SOP-RTG' "$(get /api/integracion/inventario/items)" check "el navegador no entra a integración" '"error":"Firma inv' "$(as leo $U/api/integracion/inventario/items)" echo echo "## oc.autorizada" OC1='{"evento_id":"OC-2026-0012:oc.autorizada:1","tipo":"oc.autorizada","cuando":"2026-10-28T10:15:00-06:00","quien":"tania@helgen.tech","prueba":false,"oc":{"folio":"OC-2026-0012","req_folio":"REQ-2026-0031","proyecto_folio":"PRY-2026-0002","proyecto_clave":"DRO","sitio":"ZLO","proveedor":"Ferretería del Pacífico","fecha_entrega":"2026-10-30","solicitante":"jesus@helgen.tech","autorizo":"adriana@helgen.tech","total":99999,"renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":4,"item":"SOP-RTG","importe":1234},{"n":2,"descripcion":"Tornillo 3/8 x 2 in","unidad":"pza","cantidad":32,"item":null}]}}' R1="$(post "$OC1")" check "la OC entra" '"estado_portal":"autorizada"' "$R1" check "el renglón sin ítem queda por ligar" '"por_ligar":[2]' "$R1" check "abre su pendiente" '"tarea_id":"t-oc-oc-2026-0012"' "$R1" check "el pendiente se llama «Recibir … en ZLO»" 'Recibir OC-2026-0012 en ZLO' "$(cat $T/tareas/t-oc-oc-2026-0012.json)" check "…es de compras y del sitio de entrega" 'timsa|compras' "$($PY -c " import json;d=json.load(open('$T/tareas/t-oc-oc-2026-0012.json'));print('|'.join(d['sitios'])+'|'+d['pista'])")" check "…vence el día de entrega" '2026-10-30' "$(grep -o '"fecha_limite": "[^"]*"' $T/tareas/t-oc-oc-2026-0012.json)" check "…es del responsable de ZLO" 'jesush' "$(grep -o '"responsable": "[^"]*"' $T/tareas/t-oc-oc-2026-0012.json)" no "los montos NUNCA se guardan (total)" '99999' "$($PY -c " import sqlite3;c=sqlite3.connect('$T/drogheda.db');print([r for r in c.execute('SELECT * FROM eventos')])")" no "los montos NUNCA se guardan (importe)" '1234' "$($PY -c " import sqlite3;c=sqlite3.connect('$T/drogheda.db');print([r for r in c.execute('SELECT * FROM eventos')])")" check "el mismo aviso otra vez no duplica" '"repetido":true' "$(post "$OC1")" check "sitio inválido: lo dice claro" 'usa VER, ZLO, ESE, LZC o QRO' "$(post '{"evento_id":"e-malo","tipo":"oc.autorizada","oc":{"folio":"OC-MALA","sitio":"MZO","renglones":[]}}')" check "tipo inválido" 'Tipo de aviso inv' "$(post '{"evento_id":"e2","tipo":"oc.inventada","oc":{"folio":"OC-1","sitio":"ZLO"}}')" check "nada en camino todavía" '"en_camino":{}' "$(as leo $U/api/inventario | tr -d ' ')" echo echo "## oc.en_camino: el material aparece en camino en la pestaña del sitio" R2="$(post '{"evento_id":"OC-2026-0012:oc.en_camino:1","tipo":"oc.en_camino","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0012","sitio":"ZLO","proveedor":"Ferretería del Pacífico","fecha_entrega":"2026-10-30","paqueteria":"Estafeta","guia":"123456789","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":4,"item":"SOP-RTG"},{"n":2,"descripcion":"Tornillo 3/8 x 2 in","unidad":"pza","cantidad":32,"item":null}]}}')" check "la OC queda en camino" '"estado_portal":"en-camino"' "$R2" check "4 soportes en camino a TIMSA" '"en_camino":{"timsa":4}' "$(as leo $U/api/inventario | tr -d ' ')" check "el envío trae el folio como referencia" 'OC-2026-0012' "$(as leo "$U/api/inventario/movimientos")" check "…y la guía en el motivo" '123456789' "$(as leo "$U/api/inventario/movimientos")" check "el pendiente se enteró" 'el proveedor ya lo mandó' "$(cat $T/tareas/t-oc-oc-2026-0012.json)" check "el renglón 2 sigue por ligar" '"por_ligar":[2]' "$(as leo $U/api/compras | tr -d ' ')" echo echo "## ligar el renglón que venía sin ítem" check "no se liga a un ítem que no existe" 'tem NO-HAY' "$(as leo -d '{"oc":"OC-2026-0012","n":2,"item":"NO-HAY"}' $U/api/compras/ligar)" check "se liga al cable" '"por_ligar":[]' "$(as leo -d '{"oc":"OC-2026-0012","n":2,"item":"CBL-10"}' $U/api/compras/ligar | tr -d ' ')" check "…y como la OC ya iba en camino, se manda solo" '"timsa":32' "$(as leo $U/api/inventario | tr -d ' ' | grep -o '"en_camino":{"timsa":32}')" check "no se liga dos veces" 'en camino en el inventario' "$(as leo -d '{"oc":"OC-2026-0012","n":2,"item":"CBL-10"}' $U/api/compras/ligar)" echo echo "## recibir en el centro de mando (lo que llegó de verdad)" check "no pueden llegar más de los que se pidieron" 'no pueden llegar' "$(as jesush -d '{"oc":"OC-2026-0012","renglones":[{"n":1,"recibido":9}]}' $U/api/compras/recibir)" R3="$(as jesush -d '{"oc":"OC-2026-0012","renglones":[{"n":1,"recibido":4},{"n":2,"recibido":20}]}' $U/api/compras/recibir)" check "se recibe parcial" '"estado":"recibida-parcial"' "$(echo $R3 | tr -d ' ')" check "los 4 soportes entraron a TIMSA" '"por_sitio":{"timsa":4}' "$(as leo $U/api/inventario | tr -d ' ' | head -c 4000)" check "el pendiente sigue abierto y dice qué falta" 'llegó parte de OC-2026-0012' "$(cat $T/tareas/t-oc-oc-2026-0012.json)" check "el pendiente NO se cerró" '"estado": "por-hacer"' "$(grep -o '"estado": "[^"]*"' $T/tareas/t-oc-oc-2026-0012.json | head -1)" echo echo "## el cotizador consulta las recepciones" REC="$(get '/api/integracion/cotizador/recepciones')" check "trae la recepción" '"oc_folio":"OC-2026-0012"' "$(echo $REC | tr -d ' ')" check "…marcada como incompleta" '"completa":false' "$(echo $REC | tr -d ' ')" check "…con quién recibió" '"recibio":"jesush"' "$(echo $REC | tr -d ' ')" check "…y cuánto llegó por renglón" '"pedido":32.0,"recibido":20.0' "$(echo $REC | tr -d ' ')" check "con «desde» en el futuro no trae nada" '"recepciones":[]' "$(get '/api/integracion/cotizador/recepciones?desde=2030-01-01T00:00:00-06:00' | tr -d ' ')" echo echo "## una OC que llega completa cierra su pendiente" post '{"evento_id":"OC-2026-0013:oc.autorizada:1","tipo":"oc.autorizada","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0013","req_folio":"REQ-2026-0032","sitio":"ESE","proveedor":"Edwin","fecha_entrega":"2026-11-03","renglones":[{"n":1,"descripcion":"Cable calibre 10","unidad":"m","cantidad":30,"item":"CBL-10"}]}}' > /dev/null post '{"evento_id":"OC-2026-0013:oc.en_camino:1","tipo":"oc.en_camino","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0013","sitio":"ESE","guia":"ABC","renglones":[{"n":1,"descripcion":"Cable calibre 10","unidad":"m","cantidad":30,"item":"CBL-10"}]}}' > /dev/null check "el pendiente de ESE es de Miguel" 'miguel' "$(grep -o '"responsable": "[^"]*"' $T/tareas/t-oc-oc-2026-0013.json)" R4="$(as miguel -d '{"oc":"OC-2026-0013","renglones":[{"n":1,"recibido":30}]}' $U/api/compras/recibir)" check "queda recibida" '"estado":"recibida"' "$(echo $R4 | tr -d ' ')" check "y su pendiente se cerró solo" '"estado": "hecho"' "$(grep -o '"estado": "[^"]*"' $T/tareas/t-oc-oc-2026-0013.json | head -1)" check "el cotizador la ve completa" 'OC-2026-0013=completa' "$(get '/api/integracion/cotizador/recepciones' | $PY -c " import sys,json;d=json.load(sys.stdin) print(' '.join(r['oc_folio']+('=completa' if r['completa'] else '=parcial') for r in d['recepciones']))")" echo echo "## recibir desde Inventario (la otra puerta) también se concilia" post '{"evento_id":"OC-2026-0014:oc.autorizada:1","tipo":"oc.autorizada","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0014","req_folio":"REQ-2026-0033","sitio":"VER","fecha_entrega":"2026-11-05","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":2,"item":"SOP-RTG"}]}}' > /dev/null post '{"evento_id":"OC-2026-0014:oc.en_camino:1","tipo":"oc.en_camino","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0014","sitio":"VER","guia":"XYZ","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":2,"item":"SOP-RTG"}]}}' > /dev/null ENV=$($PY -c " import sqlite3;c=sqlite3.connect('$T/drogheda.db');print(c.execute(\"SELECT envio_id FROM renglones WHERE oc_folio='OC-2026-0014'\").fetchone()[0])") as alan -d "{\"tipo\":\"recepcion\",\"envio_id\":\"$ENV\",\"cantidad\":2}" $U/api/inventario/movimiento > /dev/null check "Compras se entera de la recepción hecha en Inventario" 'OC-2026-0014=recibida' "$(as leo $U/api/compras | $PY -c " import sys,json;d=json.load(sys.stdin) print(' '.join(o['folio']+'='+o['estado'] for o in d['ordenes']))")" check "y cierra su pendiente" '"estado": "hecho"' "$(grep -o '"estado": "[^"]*"' $T/tareas/t-oc-oc-2026-0014.json | head -1)" echo echo "## cancelar: lo que iba en camino deja de contar" post '{"evento_id":"OC-2026-0015:oc.autorizada:1","tipo":"oc.autorizada","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0015","sitio":"LZC","fecha_entrega":"2026-11-06","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":7,"item":"SOP-RTG"}]}}' > /dev/null post '{"evento_id":"OC-2026-0015:oc.en_camino:1","tipo":"oc.en_camino","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0015","sitio":"LZC","guia":"Q","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":7,"item":"SOP-RTG"}]}}' > /dev/null check "7 en camino a LCT" '"lzc":7' "$(as leo $U/api/inventario | tr -d ' ')" post '{"evento_id":"OC-2026-0015:oc.cancelada:1","tipo":"oc.cancelada","quien":"tania@helgen.tech","oc":{"folio":"OC-2026-0015","sitio":"LZC","renglones":[{"n":1,"descripcion":"Soporte para RTG","unidad":"pza","cantidad":7,"item":"SOP-RTG"}]}}' > /dev/null no "ya no hay nada en camino a LCT" '"lzc":7' "$(as leo $U/api/inventario | tr -d ' ')" check "y su pendiente se cerró" '"estado": "hecho"' "$(grep -o '"estado": "[^"]*"' $T/tareas/t-oc-oc-2026-0015.json | head -1)" echo echo "## las rondas de PRUEBA no tocan nada" ANTES=$(ls $T/tareas | wc -l) post '{"evento_id":"OC-PRUEBA-1:oc.autorizada:1","tipo":"oc.autorizada","prueba":true,"quien":"efrain@helgen.tech","oc":{"folio":"OC-PRUEBA-1","sitio":"QRO","renglones":[{"n":1,"descripcion":"Soporte","unidad":"pza","cantidad":5,"item":"SOP-RTG"}]}}' > /dev/null check "la de prueba no abre pendiente" "$ANTES" "$(ls $T/tareas | wc -l)" no "ni sale en la página de Compras" 'OC-PRUEBA-1' "$(as leo $U/api/compras)" no "ni en las recepciones del cotizador" 'OC-PRUEBA-1' "$(get '/api/integracion/cotizador/recepciones')" echo echo "## sin secreto configurado el sitio sigue funcionando" kill $APP 2>/dev/null; sleep 0.5 unset COTIZADOR_SECRETO $PY $REPO/backend/app.py > $T/app2.log 2>&1 & APP=$! for _ in $(seq 1 30); do curl -fsS -o /dev/null "$U/api/salud" 2>/dev/null && break; sleep 0.5; done check "integración apagada: 503 con mensaje claro" 'COTIZADOR_SECRETO' "$(curl -s -d '{}' $U/api/integracion/cotizador/evento)" check "la página de Compras lo dice" '"integracion":false' "$(as leo $U/api/compras | tr -d ' ')" check "…y sigue listando las OC de antes" 'OC-2026-0012' "$(as leo $U/api/compras)" check "el inventario sigue igual" '"ok":true' "$(as leo $U/api/inventario)" echo echo "────────────────────────────────────────" echo " $ok bien · $fail mal" [[ $fail -eq 0 ]] || echo " log: $T/app.log" [[ $fail -eq 0 ]]