r""" Stage 3 (EEWA-33, spec 2026-10-22): the Dashboard's banner for a Run that ended badly, and Started By in `dbo.CV_ChargeSummary`. python tests\test_run_alerts.py * **Banner**: the latest Run ended in a Run Error, or without a record (killed) → a red banner naming when, who, the step and a link to the Run. Done, Stopped and Running → no banner. * **started_by** is written only once its column exists — a table not yet updated must never cost a Run its rows — and the Portal shows it in a claim's side panel; a Portal reading a table without it says how to fix that. Claim numbers here are made up. """ import io import os import sys import json import pathlib import tempfile import contextlib ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(ROOT / "tests")) from ecw_bot import settings as cfg # noqa: E402 from ecw_bot.core import results_db as rdb # noqa: E402 from ecw_bot.flows import export_medcor_ready as flow # noqa: E402 from web import runs as run_view # noqa: E402 from web.repo import SqlResultsRepo # noqa: E402 from test_web_app import (FakeRepo, FakeRunner, signed_in, _row, # noqa: E402 _quiet, DAY) from test_results_db import _row as _sheet_row, _sheet # noqa: E402 CASES = [] class DbError(Exception): """A pyodbc error: SQLSTATE first, as the driver gives it.""" def case(fn): CASES.append(fn) return fn def ev(t, kind, **f): return {"t": f"2026-10-22T{t}.000", "kind": kind, **f} START = ev("14:00:00", "run_start", source="portal", started_by="olu", mode="VERIFY", claim_limit=2, test_sheet="", pid=1, log="x") def _logs(*runs): d = pathlib.Path(tempfile.mkdtemp()) for run_id, events in runs: (d / f"events_{run_id}.jsonl").write_text( "".join(json.dumps(e) + "\n" for e in events), encoding="utf-8") return d ERRORED = [START, ev("14:00:05", "queue", claims=["9001", "9002"]), ev("14:00:06", "claim_start", n=1, total=2, claim="9001"), ev("14:01:00", "run_error", step="claims", exit_code=5, message="Target closed", traceback=""), ev("14:01:01", "run_end", exit_code=5)] DONE = [{**START, "t": "2026-10-22T15:00:00.000"}, {"t": "2026-10-22T15:01:00.000", "kind": "run_end", "exit_code": 0}] # ── Banner ──────────────────────────────────────────────────────────────────── def _dashboard(logs, runner=None): client, _ = signed_in(FakeRepo(), runner or FakeRunner(), logs_dir=logs) return client.get("/").text @case def a_run_error_last_shows_the_banner_with_who_what_and_a_link(): page = _dashboard(_logs(("20261022_140000", ERRORED))) assert 'id="last-run-alert"' in page, page[:500] assert "ended in a Run Error" in page and "started by olu" in page assert "claims (exit 5): Target closed" in page assert "1 claim(s) not reached" in page assert 'href="/runs/20261022_140000"' in page @case def a_finished_run_after_the_error_clears_the_banner(): page = _dashboard(_logs(("20261022_140000", ERRORED), ("20261022_150000", DONE))) assert 'id="last-run-alert"' not in page @case def a_killed_run_shows_the_banner_a_stopped_or_running_one_does_not(): killed = ERRORED[:3] page = _dashboard(_logs(("20261022_140000", killed))) assert "ended without a record" in page stopped = killed + [ev("14:02:00", "stopped", stopped_by="asha")] assert 'id="last-run-alert"' not in _dashboard( _logs(("20261022_140000", stopped))) runner = FakeRunner(running=True) st = runner._st runner._st = lambda: {**st(), "log_path": r"D:\x\Logs\run_20261022_140000.log"} assert 'id="last-run-alert"' not in _dashboard( _logs(("20261022_140000", killed)), runner) @case def no_runs_no_banner_and_the_latest_is_read_alone(): assert 'id="last-run-alert"' not in _dashboard(_logs()) d = _logs(("20261022_140000", ERRORED), ("20261022_150000", DONE)) assert run_view.latest_run(d)["run_id"] == "20261022_150000" assert run_view.latest_run(_logs()) is None # ── started_by in the results table ─────────────────────────────────────────── class ColCursor: def __init__(self, log, has): self.log, self.has = log, has def execute(self, sql, params): self.log.append(("execute", sql, list(params))) if self.has == "raise": raise RuntimeError("no permission") return self def fetchone(self): return (100,) if self.has else (None,) def executemany(self, sql, params): self.log.append(("executemany", sql, list(params))) class ColConn: def __init__(self, has): self.log, self.has = [], has def cursor(self): return ColCursor(self.log, self.has) def commit(self): self.log.append(("commit",)) def close(self): self.log.append(("close",)) def _records(): return [rdb.to_record(dict(zip(_sheet(_sheet_row())[0], _sheet(_sheet_row())[1])), run_at=None, run_mode="VERIFY", details=None)] def _insert(conn): return next(e for e in conn.log if e[0] == "executemany") @case def started_by_is_written_when_the_column_exists(): conn = ColConn(has=True) assert rdb.insert_rows(conn, _records(), started_by="olu") == 1 _, sql, params = _insert(conn) assert sql.split("(")[1].split(")")[0].endswith("details_json, " "started_by"), sql assert params[0][-1] == "olu" and len(params[0]) == len(rdb.DB_COLUMNS) + 1 check = conn.log[0] assert check[1] == "SELECT COL_LENGTH(?, ?)", check assert check[2] == [rdb.TABLE, "started_by"], "parameterized" @case def a_table_without_the_column_still_gets_its_rows(): for has in (False, "raise"): conn = ColConn(has=has) assert rdb.insert_rows(conn, _records(), started_by="olu") == 1 _, sql, params = _insert(conn) assert "started_by" not in sql and len(params[0]) == \ len(rdb.DB_COLUMNS), has @case def no_started_by_no_check(): conn = ColConn(has=True) rdb.insert_rows(conn, _records()) assert [e[0] for e in conn.log] == ["executemany", "commit"], conn.log @case def the_flow_saves_started_by_with_the_rows(): seen = {} saved = (rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build) os.environ["ECW_STARTED_BY"] = "olu" rdb.save_summary = lambda *a, **k: seen.update(k) flow.charge_summary.build = lambda *a: [["CLAIMS#"], ["9001"]] cfg.RESULTS_DB = True try: info = {"ids": ["9001"], "file": None} with contextlib.redirect_stdout(io.StringIO()): flow._save_results_db(info, {"9001": {"Category": "Pass"}}, {"9001": {}}) finally: rdb.save_summary, cfg.RESULTS_DB, flow.charge_summary.build = saved del os.environ["ECW_STARTED_BY"] assert seen.get("started_by") == "olu", seen @case def the_portal_reads_and_shows_started_by(): class Cur: def __init__(self, log): self.log, self.description = log, [("claim_no",)] def execute(self, sql, params): self.log.append(sql) return self def fetchall(self): return [] class Conn: def __init__(self): self.log = [] def cursor(self): return Cur(self.log) def close(self): pass conn = Conn() SqlResultsRepo("DSN=x", connect=lambda cs, timeout: conn).day_results(DAY) assert "started_by" in conn.log[0], conn.log[0] row = _row("9000001", "Pass", 1) row["started_by"] = "olu" client, _ = signed_in(FakeRepo(rows=[row]), FakeRunner()) page = client.get(f"/day/{DAY.isoformat()}").text assert '["Started By", "olu"]' in page, "a field of the side panel" @case def a_table_without_the_column_says_run_the_script_again(): client, _ = signed_in(FakeRepo(fail=DbError("42S22", "Invalid column " "name 'started_by'")), FakeRunner()) r = _quiet(client.get, "/days") assert r.status_code == 503 and "needs an update" in r.text, r.text assert "create_tables.sql" in r.text 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 print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())