Files
ManikandanandClaude Opus 5.5 ba15a574ab Initial import: eCW Charge Validation bot and results Portal
Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one
commit with no earlier history: the tracked files were de-identified
first (synthetic claim ids and patient data only; see Context.md
section 7). Credentials, run logs, screenshots, claim reports and client
workbooks are gitignored and not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:38:17 +05:30

427 lines
14 KiB
Python

r"""
web/bot_runner.py — start, watch and stop the bot as a child process. A
fake child script stands in for run.py: it takes the run record the way the
bot does, writes the env it was given, and waits.
python tests\test_bot_runner.py
"""
import os
import sys
import json
import time
import pathlib
import tempfile
import threading
import contextlib
import io
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "src"))
from ecw_bot import settings as cfg # noqa: E402
from ecw_bot.core import run_lock # noqa: E402
from web import bot_runner as br # noqa: E402
CASES = []
def case(fn):
CASES.append(fn)
return fn
# The child: like run.py, it takes the record (cfg.LOGS from argv), dumps
# the env keys under test, then sleeps until killed or `seconds` pass.
CHILD = r'''
import os, sys, json, time, pathlib
sys.path.insert(0, sys.argv[1])
from ecw_bot import settings as cfg
from ecw_bot.core import run_lock
cfg.LOGS = pathlib.Path(sys.argv[2])
log = cfg.LOGS / "run_child.log"
log.write_text("[LOGIN] fake\n", encoding="utf-8")
run_lock.acquire(log)
keys = ["SAVE_CORRECTIONS", "ECW_KEEP_OPEN", "QUIET_BOXES",
"CONFIRM_BEFORE_LOOKUP", "ECW_RUN_SOURCE", "ECW_STARTED_BY",
"ECWBOT_TEST_STALE", "ECWBOT_TEST_REAL", "RESULTS_DB_CONN"]
(cfg.LOGS / "env.json").write_text(
json.dumps({k: os.environ.get(k) for k in keys}), encoding="utf-8")
time.sleep(float(sys.argv[3]))
run_lock.finish(0)
'''
def _runner(tmp, seconds=30):
script = pathlib.Path(tmp) / "child.py"
script.write_text(CHILD, encoding="utf-8")
cmd = [sys.executable, str(script), str(ROOT / "src"), str(cfg.LOGS),
str(seconds)]
return br.BotRunner(command=cmd, root=ROOT)
def _cleanup(r):
"""Stop whatever a case left running."""
try:
r.stop()
except (br.NotRunning, br.StopFailed):
pass
def _wait(pred, timeout=15):
end = time.monotonic() + timeout
while time.monotonic() < end:
if pred():
return True
time.sleep(0.2)
return False
@case
def start_runs_the_child_with_the_forced_env(tmp):
r = _runner(tmp)
# The parent says LIVE-WRITE in its REAL environment (not from .env, so
# the .env-key filter does not drop it): FORCED_ENV must override it.
file_keys = getattr(cfg, "ENV_FILE_KEYS", set())
was_file_key = "SAVE_CORRECTIONS" in file_keys
was_value = os.environ.get("SAVE_CORRECTIONS")
file_keys.discard("SAVE_CORRECTIONS")
os.environ["SAVE_CORRECTIONS"] = "1"
try:
st = r.start(started_by="asha")
assert st["running"] is True, st
assert _wait(lambda: (cfg.LOGS / "env.json").exists())
env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8"))
forced = {k: env[k] for k in br.FORCED_ENV}
assert forced == {"SAVE_CORRECTIONS": "0", "ECW_KEEP_OPEN": "0",
"QUIET_BOXES": "1", "CONFIRM_BEFORE_LOOKUP": "0",
"ECW_RUN_SOURCE": "web"}, env
assert env["ECW_STARTED_BY"] == "asha", env
assert _wait(lambda: r.status()["starting"] is False)
st = r.status()
assert st["running"] is True and st["source"] == "web", st
assert st["log_path"].endswith("run_child.log"), st
assert st["started_by"] == "asha", "the card says who started it"
finally:
if was_value is None:
os.environ.pop("SAVE_CORRECTIONS", None)
else:
os.environ["SAVE_CORRECTIONS"] = was_value
if was_file_key:
file_keys.add("SAVE_CORRECTIONS")
_cleanup(r)
@case
def the_run_writes_to_the_database_the_page_reads(tmp):
"""Review 2026-10-14: the child reads .env afresh, but the page keeps
the results database it was started with. An RESULTS_DB_CONN edited
since would send the run's rows where the page never looks — so the
child is given the page's own connection."""
r = _runner(tmp)
was = cfg.RESULTS_DB_CONN
cfg.RESULTS_DB_CONN = "DSN=the-database-this-page-reads"
try:
r.start()
assert _wait(lambda: (cfg.LOGS / "env.json").exists())
env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8"))
assert env["RESULTS_DB_CONN"] == "DSN=the-database-this-page-reads", \
"the child does not use the page's results database"
finally:
cfg.RESULTS_DB_CONN = was
_cleanup(r)
@case
def the_child_reads_dotenv_afresh_not_the_servers_copy(tmp):
"""Review 2026-10-14: the web server took .env into its own environment
when it started, and the child inherited that copy — settings.py's
setdefault then kept it, so an .env edited since (claim sheet, claim
limit) was not seen by a page run. A key that came from .env is left
out of the child's environment; a real environment key is kept."""
r = _runner(tmp)
file_keys = getattr(cfg, "ENV_FILE_KEYS", set())
os.environ["ECWBOT_TEST_STALE"] = "old value from .env"
file_keys.add("ECWBOT_TEST_STALE")
os.environ["ECWBOT_TEST_REAL"] = "set in the real environment"
try:
r.start()
assert _wait(lambda: (cfg.LOGS / "env.json").exists())
env = json.loads((cfg.LOGS / "env.json").read_text(encoding="utf-8"))
assert env["ECWBOT_TEST_STALE"] is None, env
assert env["ECWBOT_TEST_REAL"] == "set in the real environment", env
finally:
os.environ.pop("ECWBOT_TEST_STALE", None)
os.environ.pop("ECWBOT_TEST_REAL", None)
file_keys.discard("ECWBOT_TEST_STALE")
_cleanup(r)
@case
def a_second_start_is_refused(tmp):
r = _runner(tmp)
other_runner = _runner(tmp)
try:
r.start()
assert _wait(lambda: run_lock.is_live(run_lock.read()))
for other in (r, other_runner):
try:
other.start()
except br.AlreadyRunning as exc:
assert "already going" in str(exc), exc
else:
raise AssertionError("second start allowed")
finally:
_cleanup(r)
@case
def stop_kills_the_run_and_marks_it_stopped(tmp):
r = _runner(tmp)
try:
r.start()
assert _wait(lambda: run_lock.is_live(run_lock.read()))
pid = run_lock.read()["pid"]
st = r.stop(stopped_by="olu")
assert st["running"] is False, st
assert st["stopped_by_operator"] is True, st
assert run_lock.pid_alive(pid) is False
# The killed bot cannot end its own events file: the page does.
events = cfg.LOGS / "events_child.jsonl"
last = json.loads(events.read_text(encoding="utf-8").splitlines()[-1])
assert last["kind"] == "stopped" and last["stopped_by"] == "olu", last
finally:
_cleanup(r)
@case
def stop_while_still_starting_is_the_operator_not_a_crash(tmp):
"""No record yet (the child is still importing): the runner stopped it,
so it must not read as a crash with exit code 1."""
r = _runner(tmp)
try:
st = r.start()
assert st["starting"] is True, st
st = r.stop()
assert st["running"] is False and st["starting"] is False, st
assert st["stopped_by_operator"] is True, st
assert st["exit_code"] is None, st
finally:
_cleanup(r)
@case
def a_stop_that_does_not_kill_raises_and_leaves_the_record(tmp):
"""taskkill refused (an elevated run): the record must not be marked
ended while the bot is still going."""
class NoKill(br.BotRunner):
stop_wait = 1.0
def _kill(self, pid):
pass
script = pathlib.Path(tmp) / "child.py"
script.write_text(CHILD, encoding="utf-8")
r = NoKill(command=[sys.executable, str(script), str(ROOT / "src"),
str(cfg.LOGS), "30"], root=ROOT)
try:
r.start()
assert _wait(lambda: run_lock.is_live(run_lock.read()))
pid = run_lock.read()["pid"]
try:
r.stop()
except br.StopFailed as exc:
assert str(pid) in str(exc) and "still running" in str(exc), exc
else:
raise AssertionError("stop did not raise StopFailed")
rec = run_lock.read()
assert not rec["ended_at"] and not rec["stopped_by_operator"], rec
assert run_lock.is_live(rec) is True
assert r.status()["running"] is True
finally:
br.BotRunner._kill(r, r._proc.pid)
_wait(lambda: not run_lock.pid_alive(r._proc.pid))
@case
def two_starts_at_once_spawn_one_child(tmp):
r = _runner(tmp)
results = []
gate = threading.Barrier(2)
def go():
gate.wait()
try:
r.start()
results.append("started")
except br.AlreadyRunning:
results.append("refused")
threads = [threading.Thread(target=go) for _ in range(2)]
try:
for t in threads:
t.start()
for t in threads:
t.join(30)
assert sorted(results) == ["refused", "started"], results
finally:
_cleanup(r)
@case
def stop_never_kills_its_own_process(tmp):
"""A live record whose pid is this process (the web server itself)."""
run_lock.acquire("self.log", source="web")
try:
_runner(tmp).stop()
except br.NotRunning:
pass
else:
raise AssertionError("stop went for its own pid")
assert run_lock.pid_alive(os.getpid()) is True
run_lock.finish(0)
@case
def stop_with_nothing_running_says_so(tmp):
try:
_runner(tmp).stop()
except br.NotRunning:
pass
else:
raise AssertionError("stop of nothing did not raise")
@case
def a_run_that_ends_reports_its_exit_code(tmp):
r = _runner(tmp, seconds=0.5)
try:
r.start()
assert _wait(lambda: r.status()["running"] is False)
st = r.status()
assert st["exit_code"] == 0 and st["stopped_by_operator"] is False, st
finally:
_cleanup(r)
@case
def a_terminal_run_is_seen_and_blocks_start(tmp):
"""The record of a live run this runner did not start (this test
process stands in for a terminal run)."""
run_lock.acquire("terminal.log", source="terminal")
r = _runner(tmp)
st = r.status()
assert st["running"] is True and st["source"] == "terminal", st
try:
r.start()
except br.AlreadyRunning:
pass
else:
raise AssertionError("started over a terminal run")
run_lock.finish(0)
@case
def a_terminal_run_after_a_page_run_shows_as_itself(tmp):
"""The page's child ended; a later terminal run (this test process
stands in for it) ended with exit 4. The page must show that run, not
the old child's exit 0."""
r = _runner(tmp, seconds=0.5)
try:
r.start()
assert _wait(lambda: r.status()["running"] is False)
assert _wait(lambda: r._proc.poll() is not None)
assert r.status()["exit_code"] == 0
run_lock.acquire("t.log", source="terminal")
run_lock.finish(4)
st = r.status()
assert st["source"] == "terminal", st
assert st["exit_code"] == 4 and st["running"] is False, st
assert st["log_path"] == "t.log", st
finally:
_cleanup(r)
class _NoKillRecorder(br.BotRunner):
"""Records what stop() would kill; never kills anything."""
stop_wait = 1.0
def __init__(self, *a, **k):
super().__init__(*a, **k)
self.killed = []
def _kill(self, pid):
self.killed.append(pid)
@case
def stop_refuses_a_run_started_in_the_terminal(tmp):
"""A live terminal run (maybe LIVE-WRITE): the page cannot stop it."""
import subprocess
script = pathlib.Path(tmp) / "child.py"
script.write_text(CHILD, encoding="utf-8")
env = {k: v for k, v in os.environ.items() if k != "ECW_RUN_SOURCE"}
p = subprocess.Popen([sys.executable, str(script), str(ROOT / "src"),
str(cfg.LOGS), "30"], env=env)
r = _NoKillRecorder(command=["unused"], root=ROOT)
try:
assert _wait(lambda: run_lock.is_live(run_lock.read()))
assert run_lock.read()["source"] == "terminal"
try:
r.stop()
except br.NotRunning as exc:
assert "started in the terminal" in str(exc), exc
else:
raise AssertionError("stop went for a terminal run")
assert r.killed == [], r.killed
assert run_lock.pid_alive(p.pid) is True
assert run_lock.is_live(run_lock.read()) is True
finally:
p.kill()
p.wait()
@case
def stop_refuses_a_terminal_record_of_this_process(tmp):
"""The record's pid is the test process itself: refused with the
terminal message, and the test process is not killed."""
run_lock.acquire("t.log", source="terminal")
r = _NoKillRecorder(command=["unused"], root=ROOT)
try:
r.stop()
except br.NotRunning as exc:
assert "started in the terminal" in str(exc), exc
else:
raise AssertionError("stop did not refuse")
assert r.killed == [] and run_lock.pid_alive(os.getpid()) is True
run_lock.finish(0)
def main() -> int:
failures = 0
orig = cfg.LOGS
for fn in CASES:
name = fn.__name__.replace("_", " ")
with tempfile.TemporaryDirectory() as tmp:
cfg.LOGS = pathlib.Path(tmp) / "Logs"
cfg.LOGS.mkdir()
try:
with contextlib.redirect_stdout(io.StringIO()):
fn(tmp) # the runner's [WEB] prints stay out
print(f" ok {name}")
except AssertionError as exc:
print(f" FAIL {name}: {exc}")
failures += 1
except Exception as exc:
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
failures += 1
finally:
cfg.LOGS = orig
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())