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>
This commit is contained in:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,426 @@
|
||||
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())
|
||||
Reference in new issue
Block a user