""" 2FA dead-end detection. python tests\\test_dead_end.py The `#dispMessage` strings below are copied from the real page eCW served on 2026-09-07, not invented. That matters: all four states — the healthy one and the three dead ends — are written into the **same element**, so the only thing separating "still waiting for mail" from "this page is finished" is the text. Get the matching wrong in either direction and the cost is real: a missed dead end burns twenty minutes and an approval link, while a false positive throws away a login that was about to succeed. No browser is started. A stub with one method is all `reason()` touches, which is the point of keeping it that small. """ import sys import pathlib sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src")) from ecw_bot.auth import dead_end # noqa: E402 from ecw_bot.auth.dead_end import LoginTimedOut # noqa: E402 class FakePage: """Just enough Page for `reason()`: locator().first.inner_text().""" def __init__(self, text, raises=False): self._text = text self._raises = raises def locator(self, _selector): return self @property def first(self): return self def inner_text(self, timeout=None): if self._raises: raise RuntimeError("locator not found") return self._text # (label, #dispMessage text, should it be treated as a dead end) CASES = [ ("healthy - notification sent", "eClinicalWorks sent a notification to your email. " "Please confirm it was you.", False), ("healthy - 6-digit code prompt", "Enter the 6 digit code sent to your email", False), ("timed out", "Your log in request has timed out. Please try again", True), ("answered not-me", "You confirmed that you weren't trying to Log in. " "If this was a mistake, please try again.", True), ("resends exhausted", "A new notification has been sent to your email, No attempt left.", True), # Case must not matter — eCW has shipped "Log in" and "log in" in the same # sentence, and this is matched on a fragment. ("timed out, lowercased", "your log in request has timed out. please try again", True), # Not on the 2FA screen at all: the element is missing and that is normal. ("element missing", None, False), ] def main() -> int: failures = 0 for label, text, want_dead in CASES: page = FakePage(text or "", raises=text is None) why = dead_end.reason(page) got_dead = bool(why) raised = False try: dead_end.assert_alive(page, "a test") except LoginTimedOut: raised = True except Exception as exc: # pragma: no cover print(f" FAIL {label}: assert_alive raised {type(exc).__name__}") failures += 1 continue # reason() and assert_alive() must never disagree — one is the other's # only input. ok = (got_dead == want_dead) and (raised == want_dead) failures += not ok verdict = "dead end" if got_dead else "alive " wanted = "" if ok else \ (" (wanted dead end)" if want_dead else " (wanted alive)") # ASCII only. A test that crashes on its own output is worse than no # test, and a cp1252 console cannot encode an arrow — see runlog.start(). detail = f" -> {why}" if why else "" print(f" {'ok ' if ok else 'FAIL'} {label:28s} " f"{verdict}{wanted}{detail}") print(f"\n{len(CASES) - failures}/{len(CASES)} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())