Files
ecw-charge-validation/tests/test_run_lock.py
T
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

351 lines
12 KiB
Python

r"""
core/run_lock.py — the record of the latest bot run, Logs\bot_run.json
(spec 2026-10-14-start-bot-from-web-page-design.md).
python tests\test_run_lock.py
Every case runs with cfg.LOGS pointed at a temp folder.
"""
import os
import sys
import json
import pathlib
import tempfile
import subprocess
from datetime import datetime, timedelta
ROOT = pathlib.Path(__file__).resolve().parent.parent
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
CASES = []
def case(fn):
CASES.append(fn)
return fn
def _dead_pid() -> int:
p = subprocess.Popen([sys.executable, "-c", "pass"])
p.wait()
return p.pid
@case
def pid_alive_is_true_for_this_python_process():
assert run_lock.pid_alive(os.getpid()) is True
@case
def pid_alive_is_false_for_junk_and_ended_processes():
for junk in (None, "", "x", 0, -5):
assert run_lock.pid_alive(junk) is False, junk
assert run_lock.pid_alive(_dead_pid()) is False
@case
def pid_alive_is_false_for_a_live_process_that_is_not_python():
"""A stale record's pid reused by another program must not read as a
live bot run."""
p = subprocess.Popen(["ping", "-n", "6", "127.0.0.1"],
stdout=subprocess.DEVNULL)
try:
assert run_lock.pid_alive(p.pid) is False
finally:
p.kill()
p.wait()
@case
def process_created_reads_the_creation_time():
made = run_lock.process_created(os.getpid())
assert made is not None and made <= datetime.now(), made
assert datetime.now() - made < timedelta(days=1), made
assert run_lock.process_created(_dead_pid()) is None
assert run_lock.process_created(None) is None
@case
def process_created_is_none_for_an_ended_process_still_held_open():
"""The Popen object still holds its handle after wait(): the ended
process can be opened and has times, but it is not running."""
p = subprocess.Popen([sys.executable, "-c", "pass"])
p.wait()
assert run_lock.process_created(p.pid) is None
del p
@case
def an_offset_aware_start_time_keeps_the_pid_verdict():
"""A record whose started_at carries a UTC offset must not crash
is_live() (aware vs naive) — in the page or in the bot's acquire()."""
rec = {"pid": os.getpid(), "started_at": "2026-10-14T10:00:00+05:30",
"ended_at": None}
assert run_lock.is_live(rec) is True
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
run_lock.path().write_text(json.dumps(rec), encoding="utf-8")
try:
run_lock.acquire("b.log")
except run_lock.Busy:
pass
else:
raise AssertionError("acquire went over a live record")
@case
def a_reused_pid_does_not_read_as_live():
"""The record's pid is this (live python) process, but the record was
started an hour before this process existed: the pid was reused."""
old = run_lock.process_created(os.getpid()) - timedelta(hours=1)
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
run_lock.path().write_text(json.dumps(
{"pid": os.getpid(),
"started_at": old.isoformat(timespec="seconds"),
"log_path": "old.log", "ended_at": None}), encoding="utf-8")
assert run_lock.is_live(run_lock.read()) is False
rec = run_lock.acquire("new.log")
assert rec["log_path"] == "new.log", rec
@case
def an_unreadable_start_time_keeps_the_pid_verdict():
rec = {"pid": os.getpid(), "started_at": "garbage", "ended_at": None}
assert run_lock.is_live(rec) is True
assert run_lock.is_live({"pid": os.getpid(), "ended_at": None}) is True
@case
def acquire_writes_this_process_record():
rec = run_lock.acquire(pathlib.Path("Logs/run_x.log"), source="web")
assert rec["pid"] == os.getpid() and rec["source"] == "web", rec
assert rec["ended_at"] is None and rec["exit_code"] is None, rec
on_disk = json.loads(run_lock.path().read_text(encoding="utf-8"))
assert on_disk == rec, on_disk
@case
def acquire_source_defaults_to_the_env_then_terminal():
os.environ.pop("ECW_RUN_SOURCE", None)
assert run_lock.acquire("a.log")["source"] == "terminal"
run_lock.finish(0)
os.environ["ECW_RUN_SOURCE"] = "web"
try:
assert run_lock.acquire("a.log")["source"] == "web"
finally:
os.environ.pop("ECW_RUN_SOURCE", None)
@case
def a_live_record_refuses_a_second_run():
run_lock.acquire("a.log")
try:
run_lock.acquire("b.log")
except run_lock.Busy as exc:
assert "already going" in str(exc), exc
else:
raise AssertionError("second acquire was allowed")
@case
def a_stale_record_of_a_dead_pid_is_replaced():
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
run_lock.path().write_text(json.dumps(
{"pid": _dead_pid(), "started_at": "2026-10-14T09:00:00",
"log_path": "old.log", "ended_at": None}), encoding="utf-8")
rec = run_lock.acquire("new.log")
assert rec["log_path"] == "new.log", rec
@case
def an_ended_record_does_not_block():
run_lock.acquire("a.log")
run_lock.finish(0)
assert run_lock.acquire("b.log")["log_path"] == "b.log"
@case
def finish_records_the_exit_code_and_end_time():
run_lock.acquire("a.log")
run_lock.finish(5)
rec = run_lock.read()
assert rec["exit_code"] == 5 and rec["ended_at"], rec
assert run_lock.is_live(rec) is False
@case
def finish_leaves_another_process_record_alone():
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
other = {"pid": os.getpid() + 100000, "ended_at": None,
"exit_code": None}
run_lock.path().write_text(json.dumps(other), encoding="utf-8")
run_lock.finish(0)
assert run_lock.read() == other
@case
def finish_never_raises_without_a_record():
run_lock.finish(0) # no file at all
run_lock.path().parent.mkdir(parents=True, exist_ok=True)
run_lock.path().write_text("{not json", encoding="utf-8")
run_lock.finish(0)
assert run_lock.read() is None
@case
def mark_stopped_ends_the_named_run():
rec = run_lock.acquire("a.log")
run_lock.mark_stopped(rec["pid"])
rec = run_lock.read()
assert rec["stopped_by_operator"] is True and rec["ended_at"], rec
assert rec["exit_code"] is None, rec
# ── the flow: main() and the [RESULT] line ───────────────────────────────────
import io # noqa: E402
import contextlib # noqa: E402
from ecw_bot.flows import export_medcor_ready as flow # noqa: E402
def _main_with(inner):
"""flow.main() with runlog stubbed and `_main` replaced by `inner`."""
orig = (flow.runlog.start, flow._main)
# In a temp folder: main() writes the Run's events beside the log, and
# a test must not leave one in the real Logs\.
test_log = pathlib.Path(tempfile.mkdtemp()) / "run_test.log"
flow.runlog.start = lambda: test_log
flow._main = inner
out = io.StringIO()
try:
with contextlib.redirect_stdout(out):
return flow.main(), out.getvalue()
finally:
flow.runlog.start, flow._main = orig
@case
def main_records_the_run_and_its_exit_code():
seen = {}
def inner(log_path):
seen["live"] = run_lock.is_live(run_lock.read())
seen["log"] = str(log_path)
return 5
code, _ = _main_with(inner)
assert code == 5
assert seen["live"] is True and seen["log"].endswith("run_test.log"), seen
rec = run_lock.read()
assert rec["exit_code"] == 5 and rec["ended_at"], rec
@case
def main_finishes_the_record_when_the_run_raises():
def inner(log_path):
raise RuntimeError("boom")
try:
_main_with(inner)
except RuntimeError:
pass
rec = run_lock.read()
assert rec["ended_at"] and rec["exit_code"] == 1, rec
@case
def main_refuses_a_second_run_before_login():
run_lock.acquire("other.log") # this process: live python
called = []
code, out = _main_with(lambda log_path: called.append(1) or 0)
assert code == 2 and called == [], (code, out)
assert "[RUN] A bot run is already going" in out, out
@case
def main_stops_cleanly_when_the_record_cannot_be_written():
"""acquire() failing for a reason other than Busy (disk full, a locked
file): a [RUN] line and exit 2, not a traceback."""
def broken(log_path, source=None):
raise PermissionError(13, "Access is denied\nsecond line")
orig = flow.run_lock.acquire
flow.run_lock.acquire = broken
called = []
try:
code, out = _main_with(lambda log_path: called.append(1) or 0)
finally:
flow.run_lock.acquire = orig
assert code == 2 and called == [], (code, out)
assert ("[RUN] Could not write the run record ([Errno 13] Access is "
"denied) — not starting") in out, out
@case
def each_claim_prints_its_result_line():
orig = (cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
flow.claim_results.search_claim, flow._read_claim_form,
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
flow._save_results_db, flow.claim_form.clear_screen)
cfg.SHOW_EACH_CLAIM = False
cfg.SHOW_CLAIM_IDS = False
cfg.CLAIM_PROCESS_LIMIT = 0
def search(page, cid):
return {"id": cid, "count": 1, "found": cid != "3", "seconds": 1.0}
flow.claim_results.search_claim = search
flow._read_claim_form = lambda page, cid, head: {
"claim_no": cid,
"cpt": [{"code": "99213", "checked": True, "fee": "100.00"}],
"icd": [{"code": "Z00.00"}],
"progress_notes": {"opened": "modal", "text": "Office visit."},
"_conditions": {"fired": [], "saved": False, "failed": [],
"review": [], "comments": {}}}
flow.claim_filters.clear_claim_no = lambda page: None
flow._write_charge_summary = lambda info, o: None
flow._save_results_db = lambda info, o, d: None
flow.claim_form.clear_screen = lambda page, cid, keep=(): []
out = io.StringIO()
try:
with contextlib.redirect_stdout(out):
flow._process_claims(None, {"ids": ["1", "3"], "file": None})
finally:
(cfg.SHOW_EACH_CLAIM, cfg.SHOW_CLAIM_IDS, cfg.CLAIM_PROCESS_LIMIT,
flow.claim_results.search_claim, flow._read_claim_form,
flow.claim_filters.clear_claim_no, flow._write_charge_summary,
flow._save_results_db, flow.claim_form.clear_screen) = orig
text = out.getvalue()
assert "[RESULT] claim 1/2 1: Pass" in text, text
assert "[RESULT] claim 2/2 3: Not found" in text, text
# The whole queue up front (2026-10-15): the page's Claim status panel
# lists the claims still waiting, which no other line names.
assert "[CLAIMS] Queue: 1, 3" in text, text
assert text.index("[CLAIMS] Queue:") < text.index("--- claim 1/2"), text
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"
try:
fn()
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())