""" Where the flow writes its Run events — `flows/export_medcor_ready.py` (spec 2026-10-22 Stage 1, EEWA-31). No browser, no eCW: the login, guard, run lock and disk are stand-ins. python tests\\test_run_events_flow.py * **Every claim** gets a `claim_result`; a search that raised, a form not read and a form that never loaded are Claim Errors; not found is a Warning. * **Each eCW Login Attempt** has a start and an end with its result — OK, 2FA timeout (and the retry is a second attempt), restricted, captcha failed, failed. * **The disk check**: under DISK_STOP_MB stops before login (Run Error, step disk); under DISK_WARN_MB is a Warning; enough space, nothing. * **main()** always ends the file with `run_end` and its exit code, and a raise that escapes `_main` is a Run Error with its traceback. Claim numbers here are made up. """ import sys import json import pathlib import tempfile from collections import namedtuple 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_events # noqa: E402 from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 from ecw_bot.auth.dead_end import LoginTimedOut # noqa: E402 from ecw_bot.auth.restricted import Restricted # noqa: E402 CASES = [] def case(fn): CASES.append(fn) return fn def _started(): run_events._reset() d = pathlib.Path(tempfile.mkdtemp()) log = d / "run_20261022_141530.log" run_events.start(log, mode="VERIFY") return log def _events(log, kind=None): lines = run_events.path_for(log).read_text(encoding="utf-8").splitlines() ev = [json.loads(l) for l in lines] return [e for e in ev if kind is None or e["kind"] == kind] class _Patch: """Set attributes for one case and put them back.""" def __init__(self, *triples): self.triples = triples def __enter__(self): self.saved = [(o, n, getattr(o, n)) for o, n, _ in self.triples] for o, n, v in self.triples: setattr(o, n, v) def __exit__(self, *exc): for o, n, v in self.saved: setattr(o, n, v) # ── Claims ──────────────────────────────────────────────────────────────────── @case def a_read_claim_has_only_its_result(): log = _started() flow._claim_events(1, "9001", {"found": True}, {"cpt": []}, {"Category": "Pass"}) assert [e["kind"] for e in _events(log)][1:] == ["claim_result"] assert _events(log, "claim_result")[0]["category"] == "Pass" @case def a_search_that_raised_is_a_claim_error_not_a_not_found(): log = _started() flow._claim_events(1, "9001", {"found": False, "search_error": "timeout"}, {"_search_error": "timeout"}, {"Category": "Review"}) errs = _events(log, "claim_error") assert [(e["claim"], e["step"]) for e in errs] == [("9001", "search")] assert not _events(log, "warning") @case def a_claim_not_found_is_a_warning(): log = _started() flow._claim_events(2, "9002", {"found": False}, {}, {}) assert _events(log, "claim_result")[0]["category"] == "Not found" w = _events(log, "warning") assert [(e["code"], e["claim"]) for e in w] == [("not_found", "9002")] @case def a_form_not_read_and_a_form_not_loaded_are_claim_errors(): log = _started() flow._claim_events(1, "9001", {"found": True}, {"_error": "boom"}, {"Category": "Review"}) flow._claim_events(2, "9002", {"found": True}, {"_not_evaluated": "rows never came"}, {"Category": "Review"}) errs = _events(log, "claim_error") assert [(e["claim"], e["step"]) for e in errs] == [ ("9001", "form"), ("9002", "load")], errs # ── Disk ────────────────────────────────────────────────────────────────────── Usage = namedtuple("Usage", "total used free") def _disk(free_mb): return lambda drive: Usage(0, 0, free_mb * 1024 * 1024) @case def a_nearly_full_disk_stops_before_login(): log = _started() with _Patch((flow.shutil, "disk_usage", _disk(100))): assert flow._disk_check() is False e = _events(log, "run_error")[0] assert e["step"] == "disk" and e["exit_code"] == 2, e assert _events(log, "disk")[0]["free_mb"] == 100 @case def a_low_disk_is_a_warning_and_goes_on(): log = _started() with _Patch((flow.shutil, "disk_usage", _disk(1000))): assert flow._disk_check() is True assert [e["code"] for e in _events(log, "warning")] == ["disk_low"] assert not _events(log, "run_error") @case def enough_disk_is_no_warning(): log = _started() with _Patch((flow.shutil, "disk_usage", _disk(50_000))): assert flow._disk_check() is True assert not _events(log, "warning") and not _events(log, "run_error") # ── eCW Login Attempts ──────────────────────────────────────────────────────── def _login_patches(login): g = flow.guard return _Patch((g, "wait_if_cooling", lambda: None), (g, "check", lambda: None), (g, "record_attempt", lambda: None), (flow, "login", login), (cfg, "LOGIN_RETRY_ON_TIMEOUT", 1)) def _attempt_ends(log): return [(e["attempt"], e["result"]) for e in _events(log, "login_attempt_end")] @case def a_timed_out_attempt_then_an_ok_one(): log = _started() calls = [] def login(pw, name): calls.append(1) if len(calls) == 1: raise LoginTimedOut("no mail") return ("b", "c", "p") with _login_patches(login): assert flow._login_with_retry(None) == ("b", "c", "p") assert _attempt_ends(log) == [(1, "twofa_timeout"), (2, "ok")] assert [e["attempt"] for e in _events(log, "login_attempt_start")] == [1, 2] @case def restricted_captcha_and_other_failures_end_their_attempt(): for exc, result in ((Restricted("blocked"), "restricted"), (RuntimeError("CAPTCHA not solved"), "captcha_failed"), (RuntimeError("Login failed"), "failed")): log = _started() def login(pw, name, exc=exc): raise exc with _login_patches(login): try: flow._login_with_retry(None) raise AssertionError("did not raise") except type(exc): pass assert _attempt_ends(log) == [(1, result)], (result, _attempt_ends(log)) # ── main() ──────────────────────────────────────────────────────────────────── def _main_patches(log, inner): return _Patch((flow.runlog, "start", lambda: log), (flow.run_lock, "acquire", lambda p: {}), (flow.run_lock, "finish", lambda c: None), (flow, "_main", inner)) @case def main_ends_the_file_with_the_exit_code(): run_events._reset() log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" with _main_patches(log, lambda p: 3): assert flow.main() == 3 ev = _events(log) assert ev[0]["kind"] == "run_start" and ev[-1]["kind"] == "run_end", ev assert ev[-1]["exit_code"] == 3 @case def a_raise_escaping_main_is_a_run_error_with_its_traceback(): run_events._reset() log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" def boom(p): raise KeyError("lost") with _main_patches(log, boom): try: flow.main() raise AssertionError("did not raise") except KeyError: pass err = _events(log, "run_error")[0] assert err["step"] == "unexpected" and err["exit_code"] == 1, err assert "KeyError" in err["traceback"], err assert _events(log)[-1] == {**_events(log)[-1], "kind": "run_end", "exit_code": 1} @case def a_busy_run_lock_is_a_run_error_and_an_end(): run_events._reset() log = pathlib.Path(tempfile.mkdtemp()) / "run_20261022_141530.log" def busy(p): raise flow.run_lock.Busy("A bot run is already going") with _Patch((flow.runlog, "start", lambda: log), (flow.run_lock, "acquire", busy)): assert flow.main() == 2 kinds = [e["kind"] for e in _events(log)] assert kinds == ["run_start", "run_error", "run_end"], kinds def main() -> int: failures = 0 for fn in CASES: name = fn.__name__.replace("_", " ") 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 run_events._reset() print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())