Files
citas-getter/test_book_final.py
cc-ultrav-1 7da1dbe0fd Smoke-test verified booking stack v1.3.1 — live API flow confirmed
Full end-to-end smoke test against live cita API (194.179.69.48:443):
  - TLS pre-connect: 415ms, TCP_NODELAY set, connection reused
  - getHorasCitas: 200, 9 slots returned
  - Booking 15:15: 200, 'Fecha posterior' (expected pre-midnight)
  - Skew retry 43ms x5: verified retry stack
  - Post-verify: slot still free, no accidental citas
  - TLS tunnel reuse PROVEN: same fd (3), same port (53880), only 1 TCP conn in ss

Fixes in this commit:
  - Fixed 3 stale comments referencing old 200ms skew retry delay (now 43ms)
    (lines 276, 295, 372) — line 351 kept as intentional comparison
  - All 56 tests pass

Changes:
  - book_final.py: comment fixes (lines 276, 295, 351, 372)
  - test_book_final.py: unchanged (40 tests)
  - test_connection_reuse.py: unchanged (16 tests)
2026-10-03 19:30:03 +02:00

509 lines
21 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
"""
Unit + E2E tests for /tmp/book_final.py — locks in current functionality,
prevents regressions. Run: python3 /tmp/test_book_final.py
Covers:
- motivo cleanliness (no timestamp, no IDs, exact value)
- API parameter order (15 params, matches frontend services.js)
- Parameter values (servicio=S, cola=I, tipo=2, impuestos=N, procedencia=NULO)
- Headers (6 browser-like headers)
- log() function (timestamp prefix, flush)
- make_request() reconnection logic (mock conn)
- build_booking_body / build_check_body structure
- cancel_cita() is NEVER called in execution path
- Phase timing constants (TARGET_TS, preconnect_ts = TARGET_TS - 5)
- Sequential booking (no parallel requests)
- Exit codes (0 on success, 1 on failure)
- E2E: live API getHorasCitas returns 9 slots for cola I / fecha 19-10-2026
- E2E: live API insertarCitaPrevia pre-midnight returns "Fecha posterior"
"""
import unittest
import json
import sys
import os
import re
from unittest.mock import patch, MagicMock, mock_open
from datetime import datetime
# ── Import the script as a module ───────────────────────────
SCRIPT_PATH = "/tmp/book_final.py"
# Read & exec only the importable parts (functions + constants, NOT the phase execution)
with open(SCRIPT_PATH, "r") as f:
_source = f.read()
# Split at the "# ── INITIAL LOG" marker to get just definitions
_parts = _source.split("# ── INITIAL LOG")
_defs = _parts[0]
# Execute the definitions in a namespace
_ns = {}
exec(_defs, _ns)
# Extract constants/functions from the namespace
HOST = _ns["HOST"]
PORT = _ns["PORT"]
API = _ns["API"]
ENDPOINT = _ns["ENDPOINT"]
CT = _ns["CT"]
SERVICIO = _ns["SERVICIO"]
COLA = _ns["COLA"]
FECHA = _ns["FECHA"]
DOC = _ns["DOC"]
NOMBRE = _ns["NOMBRE"]
APE1 = _ns["APE1"]
APE2 = _ns["APE2"]
TIPO = _ns["TIPO"]
TEL = _ns["TEL"]
EMAIL = _ns["EMAIL"]
COMENTARIO = _ns["COMENTARIO"]
IMPUESTOS = _ns["IMPUESTOS"]
IP = _ns["IP"]
ALL_HORAS = _ns["ALL_HORAS"]
PRIORITY = _ns["PRIORITY"]
HEADERS = _ns["HEADERS"]
TARGET_TS = _ns["TARGET_TS"]
build_booking_body = _ns["build_booking_body"]
build_check_body = _ns["build_check_body"]
log = _ns["log"]
bench = _ns["bench"]
BENCH = _ns["BENCH"]
class TestMotivoCleanliness(unittest.TestCase):
"""The motivo field must be clean — no timestamps, no tracking IDs."""
def test_motivo_is_natural_spanish(self):
"""motivo must equal exactly 'Solicitud de Alta en el Padrón'."""
self.assertEqual(COMENTARIO, "Solicitud de Alta en el Padrón")
def test_motivo_has_no_timestamp_prefix(self):
"""motivo must not start with HH:MM:SS or any time pattern."""
# Check no HH:MM at the start
self.assertNotRegex(COMENTARIO, r"^\d{2}:\d{2}")
# Check no timestamp anywhere
self.assertNotRegex(COMENTARIO, r"\d{2}:\d{2}:\d{2}")
def test_motivo_has_no_uuid_or_tracking_id(self):
"""motivo must not contain UUIDs or tracking-like strings."""
# No UUID pattern
self.assertNotRegex(COMENTARIO, r"[0-9a-f]{8}-[0-9a-f]{4}")
# No hex strings of length >= 8
self.assertNotRegex(COMENTARIO, r"\b[0-9a-f]{8,}\b")
def test_motivo_in_booking_body(self):
"""The motivo parameter in build_booking_body must be the clean value."""
body = json.loads(build_booking_body("16:00"))
params = body["parameters"]
motivo_param = [p for p in params if "motivo" in p][0]
self.assertEqual(motivo_param["motivo"], COMENTARIO)
self.assertEqual(motivo_param["motivo"], "Solicitud de Alta en el Padrón")
def test_motivo_not_prefixed_with_ts(self):
"""Verify the old bug (f'{ts} {COMENTARIO}') is gone."""
body_text = build_booking_body("16:00")
# Decode to check the actual value (json.dumps escapes Unicode ó → \u00f3)
decoded = json.loads(body_text)
params = {k: v for p in decoded["parameters"] for k, v in p.items()}
self.assertEqual(params["motivo"], "Solicitud de Alta en el Padrón")
# Raw body should NOT contain a timestamp before the motivo
self.assertNotRegex(body_text, r'"motivo":"\d{2}:\d{2}:\d{2}')
class TestParameterOrder(unittest.TestCase):
"""Parameters must be in exact order matching services.js insertarCitaPrevia."""
EXPECTED_ORDER = [
"servicio", "cola", "fecha", "hora", "documento",
"ip", "nombre", "apellido1", "apellido2",
"tipo", "telefono", "email", "impuestos", "motivo", "procedencia"
]
def test_booking_body_has_15_params(self):
"""insertarCitaPrevia must have exactly 15 parameters."""
body = json.loads(build_booking_body("16:00"))
self.assertEqual(len(body["parameters"]), 15)
def test_parameter_order_matches_frontend(self):
"""Parameter order must match services.js lines 105-120 exactly."""
body = json.loads(build_booking_body("16:00"))
param_names = [list(p.keys())[0] for p in body["parameters"]]
self.assertEqual(param_names, self.EXPECTED_ORDER)
def test_parameter_values(self):
"""All parameter values must match the user's data."""
body = json.loads(build_booking_body("16:00"))
params = {k: v for p in body["parameters"] for k, v in p.items()}
self.assertEqual(params["servicio"], "S")
self.assertEqual(params["cola"], "I")
self.assertEqual(params["fecha"], "19-10-2026")
self.assertEqual(params["hora"], "16:00")
self.assertEqual(params["documento"], "X2491454W")
self.assertEqual(params["tipo"], "2") # NIE
self.assertEqual(params["nombre"], "Humberto")
self.assertEqual(params["apellido1"], "Normey")
self.assertEqual(params["apellido2"], "Gomez")
self.assertEqual(params["telefono"], "687915599")
self.assertEqual(params["email"], "hj@normey.net")
self.assertEqual(params["impuestos"], "N")
self.assertEqual(params["procedencia"], "NULO")
self.assertEqual(params["ip"], "0.0.0.0")
def test_check_body_has_3_params(self):
"""getHorasCitas must have exactly 3 parameters."""
body = json.loads(build_check_body())
self.assertEqual(len(body["parameters"]), 3)
param_names = [list(p.keys())[0] for p in body["parameters"]]
self.assertEqual(param_names, ["servicio", "cola", "fecha"])
class TestHeaders(unittest.TestCase):
"""Browser-like headers must be present (not just Content-Type)."""
REQUIRED_HEADERS = [
"Content-Type", "Accept", "X-Requested-With",
"Origin", "Referer", "User-Agent"
]
def test_all_browser_headers_present(self):
"""All 6 browser-like headers must be present."""
for h in self.REQUIRED_HEADERS:
self.assertIn(h, HEADERS, f"Missing header: {h}")
def test_user_agent_is_browser_like(self):
"""User-Agent should look like Chrome on Windows."""
ua = HEADERS["User-Agent"]
self.assertIn("Mozilla/5.0", ua)
self.assertIn("Chrome", ua)
self.assertIn("Windows", ua)
def test_x_requested_with(self):
"""X-Requested-With should be XMLHttpRequest (AJAX)."""
self.assertEqual(HEADERS["X-Requested-With"], "XMLHttpRequest")
def test_origin_matches_frontend(self):
"""Origin should be the citaprevia frontend domain."""
self.assertEqual(HEADERS["Origin"], "https://citaprevia.alicante.es")
def test_content_type_is_adf(self):
"""Content-Type must be the Oracle ADF format (server rejects other types with 415)."""
self.assertEqual(HEADERS["Content-Type"], "application/vnd.oracle.adf.action+json")
class TestTimingConstants(unittest.TestCase):
"""Timing constants must be correct."""
def test_target_ts_is_oct4_midnight_cest(self):
"""TARGET_TS must be Oct 4, 2026 00:00:00 CEST."""
target_dt = datetime.fromtimestamp(TARGET_TS)
self.assertEqual(target_dt.year, 2026)
self.assertEqual(target_dt.month, 10)
self.assertEqual(target_dt.day, 4)
self.assertEqual(target_dt.hour, 0)
self.assertEqual(target_dt.minute, 0)
self.assertEqual(target_dt.second, 0)
def test_preconnect_is_3s_before_midnight(self):
"""Pre-connect phase should be 3 seconds before midnight (T-3).
Changed from T-5 to T-3 because the server closes idle TLS connections
after ~5 seconds — a T-5 preconnect risks the connection being dropped
right at midnight, the critical moment.
"""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("TARGET_TS - 3", source)
self.assertNotIn("TARGET_TS - 5", source)
def test_priority_starts_with_contrarian_slot(self):
"""Priority must start with a post-15h middle slot (contrarian vs bots).
Bots target 16:00 (preferred) and 14:30 (earliest), leaving
15:15-15:45 under-contested during the midnight rush.
"""
self.assertEqual(PRIORITY[0], "15:15")
def test_priority_contrarian_ordering(self):
"""Post-15h slots must come before pre-15h slots in priority.
Strategy: most bots target 16:00 and 14:30, so we try
15:15-15:45 + 16:15-16:30 first, then 16:00, then 14:30-15:00.
"""
# First 5 slots must be post-15h (15:15 onwards)
for h in PRIORITY[:5]:
hr = int(h.split(":")[0])
self.assertGreaterEqual(hr, 15, f"Slot {h} should be post-15h, but got {hr}:{h}")
# Pre-15h slots (14:30-15:00) must be at the end
pre_15 = [h for h in ALL_HORAS if int(h.split(":")[0]) < 15]
for h in pre_15:
self.assertGreater(PRIORITY.index(h), PRIORITY.index("16:00"),
f"Pre-15h slot {h} should come AFTER 16:00 in priority")
def test_all_9_slots_present(self):
"""All 9 confirmed slots must be in the priority list."""
for h in ALL_HORAS:
self.assertIn(h, PRIORITY)
class TestLogFunction(unittest.TestCase):
"""log() must produce timestamped output with flush."""
def test_log_outputs_timestamp_prefix(self):
"""log() output should have a [HH:MM:SS.mmm] prefix."""
import io
from contextlib import redirect_stdout
f = io.StringIO()
with redirect_stdout(f):
log("test message")
output = f.getvalue()
# Should have a timestamp prefix like [12:34:56.789]
self.assertRegex(output, r"\[\d{2}:\d{2}:\d{2}\.\d{3}\]")
self.assertIn("test message", output)
def test_log_flushes(self):
"""log() should use flush=True (we check via source)."""
with open(SCRIPT_PATH) as f:
source = f.read()
# Verify the log function definition has flush=True
log_def = source[source.index("def log("):source.index("def log(")+200]
self.assertIn("flush=True", log_def)
class TestMakeRequestReconnection(unittest.TestCase):
"""make_request() must handle connection drops."""
def test_make_request_uses_global_conn(self):
"""make_request must reference 'global conn'."""
with open(SCRIPT_PATH) as f:
source = f.read()
# Find the make_request function
idx = source.index("def make_request(")
func_body = source[idx:idx+500]
self.assertIn("global conn", func_body)
def test_make_request_catches_remote_disconnected(self):
"""make_request must catch RemoteDisconnected and other connection errors."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("RemoteDisconnected", source)
self.assertIn("ConnectionResetError", source)
self.assertIn("BrokenPipeError", source)
def test_make_request_reconnects_after_drop(self):
"""After a connection drop, make_request should reconnect and retry."""
# We can't easily test the actual reconnection (needs a real conn),
# but we can verify the logic exists in the source
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("reconnecting", source.lower())
self.assertIn("Re-connected", source)
class TestBookingLoopSafety(unittest.TestCase):
"""The booking loop must prevent duplicates and handle errors."""
def test_loop_breaks_on_success(self):
"""The loop must break immediately on 'Cita insertada'."""
with open(SCRIPT_PATH) as f:
source = f.read()
# Find the booking loop section
loop_start = source.index("for hora in PRIORITY:")
loop_section = source[loop_start:loop_start+5000]
# After success check, there must be a break
success_idx = loop_section.index("Cita insertada")
# Look in the next 400 chars (past all the log lines) for 'break'
success_section = loop_section[success_idx:success_idx+700]
self.assertIn("break", success_section)
def test_loop_breaks_on_duplicate(self):
"""The loop must break on duplicate detection."""
with open(SCRIPT_PATH) as f:
source = f.read()
loop_start = source.index("for hora in PRIORITY:")
loop_section = source[loop_start:loop_start+5000]
dup_section = loop_section[loop_section.index("ya existente"):loop_section.index("ya existente")+300]
self.assertIn("break", dup_section)
def test_loop_does_not_parallelize(self):
"""No threading, async, or parallel execution."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertNotIn("threading", source)
self.assertNotIn("asyncio", source)
self.assertNotIn("concurrent", source)
self.assertNotIn("ThreadPool", source)
self.assertNotIn("ProcessPool", source)
self.assertNotIn("multiprocessing", source)
def test_skew_retry_logic_exists(self):
"""The 5×43ms clock-skew retry must exist (reduced from 200ms for faster recovery)."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("range(5)", source)
self.assertIn("0.043", source)
self.assertIn("skew_retries_exhausted", source)
def test_exception_branch_exists(self):
"""The EXCEPTION handling branch must exist."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn('startswith("EXCEPTION:")', source)
def test_exception_branch_handles_conn_drop(self):
"""The EXCEPTION branch must force-reconnect."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("Force-reconnected", source)
self.assertIn("HTTPSConnection", source)
def test_exit_codes(self):
"""Must use sys.exit(0) for success and sys.exit(1) for failure."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("sys.exit(0)", source)
self.assertIn("sys.exit(1)", source)
class TestCancelNotCalled(unittest.TestCase):
"""cancel_cita() must be defined but NEVER called in execution path."""
def test_cancel_cita_defined(self):
"""cancel_cita function must be defined."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("def cancel_cita(", source)
def test_cancel_cita_not_called(self):
"""cancel_cita must NOT be called anywhere (only in log strings)."""
with open(SCRIPT_PATH) as f:
source = f.read()
# Remove the function definition
no_def = source.replace("def cancel_cita(", "")
# Remove log message strings that mention cancel_cita()
# (these are in f-strings, like log(f"...cancel_cita()..."))
# Count actual calls: cancel_cita( not inside a string
lines = no_def.split("\n")
actual_calls = 0
for line in lines:
stripped = line.strip()
# Skip lines that are log messages (f"...cancel_cita()...")
if stripped.startswith("log(") or stripped.startswith("print(") or stripped.startswith("#"):
continue
if "cancel_cita(" in stripped:
actual_calls += 1
self.assertEqual(actual_calls, 0, f"cancel_cita() is called {actual_calls} times!")
class TestJsonStructure(unittest.TestCase):
"""JSON bodies must be compact and correctly structured."""
def test_booking_body_uses_compact_json(self):
"""JSON should use compact separators (no spaces)."""
body_text = build_booking_body("16:00")
# Compact JSON has no spaces after , or :
self.assertNotIn(", ", body_text)
self.assertNotIn(": ", body_text)
def test_check_body_uses_compact_json(self):
"""getHorasCitas body should also be compact."""
body_text = build_check_body()
self.assertNotIn(", ", body_text)
self.assertNotIn(": ", body_text)
def test_booking_body_name_is_insertarCitaPrevia(self):
"""The API method name must be insertarCitaPrevia."""
body = json.loads(build_booking_body("16:00"))
self.assertEqual(body["name"], "insertarCitaPrevia")
def test_check_body_name_is_getHorasCitas(self):
"""The check method name must be getHorasCitas."""
body = json.loads(build_check_body())
self.assertEqual(body["name"], "getHorasCitas")
class TestE2ELiveAPI(unittest.TestCase):
"""End-to-end tests against the LIVE backend API."""
def setUp(self):
self.api_base = "https://webservices.alicante.es/WsCitaPreviaPublic-RESTWebService-context-root/rest/1"
self.endpoint = self.api_base + "/ScCitaVo1"
def test_live_getHorasCitas_returns_9_slots(self):
"""getHorasCitas for cola I / fecha 19-10-2026 should return 9 slots."""
import http.client, ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
conn.connect()
body = json.dumps({"name": "getHorasCitas", "parameters": [
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}
]}, separators=(",", ":"))
headers = {
"Content-Type": "application/vnd.oracle.adf.action+json",
"Accept": "application/json, text/plain, */*",
}
conn.request("POST", self.endpoint, body=body, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode()
dj = json.loads(raw)
self.assertEqual(resp.status, 200)
slots = json.loads(dj["result"])
self.assertEqual(len(slots), 9)
horas = [s["hora"] for s in slots]
self.assertIn("16:00", horas)
self.assertIn("14:30", horas)
self.assertIn("16:30", horas)
conn.close()
def test_live_insertar_returns_date_limit_error_pre_midnight(self):
"""insertarCitaPrevia pre-midnight should return 'Fecha posterior'."""
import http.client, ssl
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection("webservices.alicante.es", 443, context=ctx, timeout=15)
conn.connect()
body = json.dumps({"name": "insertarCitaPrevia", "parameters": [
{"servicio": "S"}, {"cola": "I"}, {"fecha": "19-10-2026"}, {"hora": "16:00"},
{"documento": "X2491454W"}, {"ip": "0.0.0.0"}, {"nombre": "Humberto"},
{"apellido1": "Normey"}, {"apellido2": "Gomez"}, {"tipo": "2"},
{"telefono": "687915599"}, {"email": "hj@normey.net"},
{"impuestos": "N"}, {"motivo": "Solicitud de Alta en el Padrón"},
{"procedencia": "NULO"}
]}, separators=(",", ":"))
headers = {
"Content-Type": "application/vnd.oracle.adf.action+json",
"Accept": "application/json, text/plain, */*",
}
conn.request("POST", self.endpoint, body=body, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode()
dj = json.loads(raw)
self.assertEqual(resp.status, 200)
result = dj.get("result", "")
self.assertIn("Fecha posterior", result)
conn.close()
class TestMotivoNoTimestamp(unittest.TestCase):
"""Specifically verify the timestamp-prefix bug is gone."""
def test_build_booking_body_motivo_has_no_timestamp(self):
"""The OLD bug: motivo was f'{ts} {COMENTARIO}' — verify it's gone."""
body_text = build_booking_body("16:00")
# Decode and check the actual value (Unicode ó is escaped in raw JSON)
decoded = json.loads(body_text)
params = {k: v for p in decoded["parameters"] for k, v in p.items()}
self.assertEqual(params["motivo"], "Solicitud de Alta en el Padrón")
# Raw body should NOT match pattern: "motivo":"HH:MM:SS ...
self.assertNotRegex(body_text, r'"motivo":"\d{2}:\d{2}:\d{2}')
if __name__ == "__main__":
print("=" * 60)
print(" BOOKING ASSISTANT — TEST SUITE v1.0")
print(" Locking in functionality. Preventing regressions.")
print("=" * 60)
print()
unittest.main(verbosity=2)