Files
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

511 lines
21 KiB
Python

"""
Settings and import-graph tests.
python tests\\test_settings.py
Three things it checks, each of which has actually broken:
* **`ROOT` resolves to the project root.** `settings.py` moved two levels
deeper during the restructure, so the `.parent` count had to change. Get it
wrong and `.env` silently does not load — every credential reads empty and
the failure surfaces as "ECW_URL is not set", pointing at the wrong file.
* **No selector reaches for `cfg.`** Selectors moved out of settings into
`selectors\\`, so a leftover `cfg.SEL_…` is an AttributeError that only
fires on the line that uses it — possibly minutes into a run, after a login
has been spent.
* **The import graph is acyclic**, layer by layer. The layering is the whole
point of the package; a cycle would make it decorative.
And, since 2026-10-05 (review item P1), how `.env` values are read — because
the reader once turned saving to live eCW ON without a word:
* **Inline comments are stripped.** `SAVE_CORRECTIONS=0 # note` used to
read as "0 # note", which is not a yes/no word, so the setting fell back
to its default — which was ON.
* **An unreadable yes/no or number is a config problem**, not a silent
fallback, so the run stops before login.
* **Writing is off unless asked for**: SAVE_CORRECTIONS defaults to False,
APPLY_CORRECTIONS (which only fills the form in) to True.
These never read the real `.env`: the loader is fed a temp file, the parsers
read test-only keys, and the defaults are checked on a copy of settings.py
loaded with no `.env` beside it.
"""
import os
import sys
import pathlib
import tempfile
import importlib.util
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from ecw_bot import settings as cfg # noqa: E402
# Layer → what it is allowed to import from. A module may always import its own
# layer and anything below it.
ALLOWED = {
"settings": {"selectors"},
"selectors": set(),
"core": {"settings"},
# Reads the exported report off disk. Sits beside settings rather than in
# pages\ because it touches no DOM, and above core\ because it knows what a
# claim report is — which core deliberately does not.
"claim_file": {"settings", "core"},
"auth": {"settings", "selectors", "core"},
"pages": {"settings", "selectors", "core"},
# Detect/apply against a claim, via what pages\ already exposes — never
# its own DOM knowledge, and never reaches into flows\ (which is what
# calls it).
"conditions": {"settings", "selectors", "core", "pages"},
"flows": {"settings", "selectors", "core", "claim_file",
"auth", "pages", "conditions"},
}
def check_root() -> list:
bad = []
if cfg.ROOT != ROOT:
bad.append(f"settings.ROOT is {cfg.ROOT}, expected {ROOT}")
if not cfg.ENV_PATH.exists():
bad.append(f"settings.ENV_PATH does not exist: {cfg.ENV_PATH}")
if not cfg.ECW_URL:
bad.append("ECW_URL is empty — .env was not picked up from ROOT")
for name in ("LOGS", "INPUTS", "SCREENSHOTS", "DOWNLOADS_TMP"):
p = getattr(cfg, name)
if ROOT not in p.parents and p != ROOT:
bad.append(f"settings.{name} ({p}) is outside the project root")
return bad
def check_no_selectors_in_settings() -> list:
leaked = [n for n in dir(cfg) if n.startswith(("SEL_", "TXT_"))]
return [f"selectors still exported by settings: {leaked}"] if leaked else []
def check_no_alias_shadowing() -> list:
"""
No function may bind a name that is also a module-level import alias.
This exists because of a real bug, and because the check that was supposed
to catch it did not. `claim_filters.clear_balance` inherited
`for sel in (cfg.SEL_BAL_OP1, ...)` from the pre-package code, where `sel`
was just a loop variable. After the split, `sel` is also the selectors
module — and binding it anywhere in a function makes it local for the
**whole** function, so a `sel.SEL_BAL_OP1` earlier in the same function
raises UnboundLocalError:
[CLAIMS] Failed: cannot access local variable 'sel'
where it is not associated with a value
An AST pass for *undefined* names cannot see this — the name is bound, just
later than it is read. Only shadowing itself is detectable, so that is what
is checked. It is also simply a bad idea: reading `sel` in one function
should not mean something different from reading it in the next.
"""
import ast
bad = []
pkg = ROOT / "src" / "ecw_bot"
for path in sorted(pkg.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
aliases = set()
for node in tree.body:
if isinstance(node, (ast.Import, ast.ImportFrom)):
for a in node.names:
aliases.add((a.asname or a.name).split(".")[0])
if not aliases:
continue
for fn in [n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
bound = {a.arg for a in fn.args.args} | \
{a.arg for a in fn.args.kwonlyargs}
for n in ast.walk(fn):
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store):
bound.add(n.id)
elif isinstance(n, ast.comprehension):
for t in ast.walk(n.target):
if isinstance(t, ast.Name):
bound.add(t.id)
elif isinstance(n, ast.ExceptHandler) and n.name:
bound.add(n.name)
for name in sorted(bound & aliases):
bad.append(f"{path.relative_to(pkg)}: {fn.name}() binds "
f"{name!r}, which is an imported module alias")
return bad
def check_layers() -> list:
import re
bad = []
pkg = ROOT / "src" / "ecw_bot"
for path in sorted(pkg.rglob("*.py")):
rel = path.relative_to(pkg)
layer = rel.parts[0] if len(rel.parts) > 1 else rel.stem
if layer not in ALLOWED:
continue
text = path.read_text(encoding="utf-8")
# Relative imports only — that is the convention inside the package.
for m in re.finditer(r"^from \.\.?(\w+)", text, re.M):
target = m.group(1)
if target == layer or target in ALLOWED[layer]:
continue
if target in ALLOWED:
bad.append(f"{rel} ({layer}) imports {target}, which is above it")
return bad
# ── .env reading (P1) ─────────────────────────────────────────────────────────
# Every key below is test-only and unset in a real run, so the loader's
# "real environment wins" setdefault always takes the file's value — and none
# of this can collide with, or leak, a real setting.
_PREFIX = "ECWBOT_TEST_"
def _clear_test_keys() -> None:
for k in [k for k in os.environ if k.startswith(_PREFIX)]:
del os.environ[k]
def _load_lines(lines: list) -> None:
"""Run the real loader over a temp .env holding `lines`."""
_clear_test_keys()
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / ".env"
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
cfg._load_env_file(path)
def _expect(cases: dict) -> list:
"""cases: {suffix: expected value}. Problems for every mismatch."""
bad = []
for suffix, want in cases.items():
got = os.environ.get(_PREFIX + suffix)
if got != want:
bad.append(f"{_PREFIX}{suffix}: got {got!r}, expected {want!r}")
return bad
def check_env_inline_comments_unquoted() -> list:
_load_lines([
# The two lines review item P1 found.
f"{_PREFIX}SAVE=0 # verification only",
f"{_PREFIX}LIMIT=5 # demo",
# A tab is whitespace too.
f"{_PREFIX}TAB=1\t# tab before the hash",
# '#' with no whitespace before it is part of the value: the landing
# URL's fragment and a mailbox password both look like this.
f"{_PREFIX}URL=https://host.example/index.jsp#/schedule",
f"{_PREFIX}PASS=abc#123",
# ...and the cut is at the FIRST ` #`, keeping a '#' inside the value.
f"{_PREFIX}BOTH=abc#123 # the comment",
# Blank value with only a comment after it is blank, not "# ...".
f"{_PREFIX}EMPTY= # nothing yet",
# Plain values, spaces inside, are untouched.
f"{_PREFIX}PLAIN=Claim Dt(s)",
])
try:
return _expect({
"SAVE": "0", "LIMIT": "5", "TAB": "1",
"URL": "https://host.example/index.jsp#/schedule",
"PASS": "abc#123", "BOTH": "abc#123", "EMPTY": "",
"PLAIN": "Claim Dt(s)",
})
finally:
_clear_test_keys()
def check_env_inline_comments_quoted() -> list:
_load_lines([
# Quoting is how a value keeps a ` #`.
f'{_PREFIX}HASH="a # b"',
f"{_PREFIX}SINGLE='a # b'",
# Anything after the closing quote is dropped.
f'{_PREFIX}TRAIL="abc" # note',
f"{_PREFIX}STRAIL='abc' # note",
# A quote inside the comment must not drag the comment in.
f'{_PREFIX}QCOMMENT="abc" # it is "quoted"',
# Fully quoted, nothing after: exactly as before, inner quote kept.
f'{_PREFIX}FULL="abc"',
f'{_PREFIX}INNER="pa"ss"',
# ...including an inner `"#`: like an unquoted value, a '#' only
# starts a comment after whitespace, so a password quoted end to end
# that contains `"#` is not cut at it (review of unit A, fix round 1).
f'{_PREFIX}INNERHASH="a"#b"',
f'{_PREFIX}INNERHASH2="x"# y"',
# Same-kind inner quote, then whitespace and '#': indistinguishable
# from a trailing comment, so it IS one (documented in settings.py);
# the other kind of quotes is how such a value is written.
f'{_PREFIX}SAMEQ="a" #b"',
f"""{_PREFIX}OTHERQ='a" #b'""",
# Not quoted end to end and no whitespace before the '#': the first
# closing quote still ends the value.
f'{_PREFIX}NOSPACE="abc"#note',
f'{_PREFIX}BLANKQ=""',
# No closing quote: not a quoted value; the quote stays, as before.
f'{_PREFIX}OPEN="abc',
])
try:
return _expect({
"HASH": "a # b", "SINGLE": "a # b", "TRAIL": "abc",
"STRAIL": "abc", "QCOMMENT": "abc", "FULL": "abc",
"INNER": 'pa"ss', "INNERHASH": 'a"#b', "INNERHASH2": 'x"# y',
"SAMEQ": "a", "OTHERQ": 'a" #b',
"NOSPACE": "abc", "BLANKQ": "", "OPEN": '"abc',
})
finally:
_clear_test_keys()
def check_commented_save_line_reads_as_off() -> list:
"""The P1 incident end to end: loader, then the yes/no parser."""
saved_bad = list(cfg._BAD_VALUES)
_load_lines([f"{_PREFIX}SAVE=0 # verification only"])
try:
bad = []
# Default True on purpose — the old SAVE_CORRECTIONS default — so a
# comment that leaked into the value would show up as True here.
if cfg._env_bool(_PREFIX + "SAVE", True) is not False:
bad.append("SAVE=0 # comment did not read as False")
if cfg._BAD_VALUES != saved_bad:
bad.append(f"a clean commented line was recorded as bad: "
f"{cfg._BAD_VALUES[len(saved_bad):]}")
return bad
finally:
cfg._BAD_VALUES[:] = saved_bad
_clear_test_keys()
def check_bad_values_are_reported_by_validate() -> list:
saved_bad = list(cfg._BAD_VALUES)
_clear_test_keys()
os.environ[_PREFIX + "FLAG"] = "O" # the letter O, not zero
os.environ[_PREFIX + "COUNT"] = "5x"
os.environ[_PREFIX + "NEG"] = "-1"
os.environ[_PREFIX + "BLANK"] = ""
os.environ[_PREFIX + "PASSWORD_FLAG"] = "hunter2"
os.environ[_PREFIX + "OK"] = "Yes"
try:
bad = []
# Unreadable: the default comes back, but the value is recorded.
if cfg._env_bool(_PREFIX + "FLAG", True) is not True:
bad.append("bad bool did not return the default")
if cfg._env_int(_PREFIX + "COUNT", 1) != 1:
bad.append("bad int did not return the default")
cfg._env_int(_PREFIX + "NEG", 1)
cfg._env_bool(_PREFIX + "PASSWORD_FLAG", False)
# Blank and unset mean "use the default" — never a problem.
cfg._env_bool(_PREFIX + "BLANK", False)
cfg._env_int(_PREFIX + "BLANK", 7)
cfg._env_bool(_PREFIX + "UNSET", False)
cfg._env_int(_PREFIX + "UNSET", 7)
# Recognised words are case-insensitive, as before.
if cfg._env_bool(_PREFIX + "OK", False) is not True:
bad.append("'Yes' did not read as True")
problems = cfg.validate()
for want in (f"{_PREFIX}FLAG='O' is not a valid yes/no value",
f"{_PREFIX}COUNT='5x' is not a whole number",
f"{_PREFIX}NEG='-1' is not a whole number"):
if not any(want in p for p in problems):
bad.append(f"validate() did not report: {want}")
secret = [p for p in problems if _PREFIX + "PASSWORD_FLAG" in p]
if len(secret) != 1:
bad.append(f"PASSWORD key reported {len(secret)} time(s), want 1")
if any("hunter2" in p for p in problems):
bad.append("validate() printed the value of a PASSWORD key")
for quiet in ("BLANK", "UNSET", "OK"):
if any(f"{_PREFIX}{quiet}" in p for p in problems):
bad.append(f"{_PREFIX}{quiet} was reported but is fine")
# Recorded once, however often the setting is read.
cfg._env_bool(_PREFIX + "FLAG", True)
if sum(1 for k, _, _ in cfg._BAD_VALUES if k == _PREFIX + "FLAG") != 1:
bad.append("a bad value read twice was recorded twice")
return bad
finally:
cfg._BAD_VALUES[:] = saved_bad
_clear_test_keys()
def check_quoted_value_with_trailing_text_is_not_guessed() -> list:
"""
Final review, 2026-10-05: `SAVE_CORRECTIONS="1"0` read as 1 (LIVE-WRITE)
with nothing recorded, while the same text unquoted (`10`) stopped the
run. A yes/no or number key with text after its closing quote is now a
bad value; a text key keeps reading as the quoted part, as before.
"""
saved_bad = list(cfg._BAD_VALUES)
saved_q = dict(cfg._QUOTE_TRAILING)
_load_lines([
f'{_PREFIX}QSAVE="1"0',
f"{_PREFIX}QINT='5'x",
f'{_PREFIX}QTEXT="abc"xyz',
f'{_PREFIX}QCLEAN="1" # a real comment',
])
try:
bad = []
if cfg._env_bool(_PREFIX + "QSAVE", False) is not False:
bad.append('"1"0 did not fall back to the default')
if cfg._env_int(_PREFIX + "QINT", 1) != 1:
bad.append("'5'x did not fall back to the default")
if cfg._env(_PREFIX + "QTEXT") != "abc":
bad.append("a text key no longer reads as its quoted part")
if cfg._env_bool(_PREFIX + "QCLEAN", False) is not True:
bad.append('"1" # comment did not read as True')
problems = cfg.validate()
for want in (f"""{_PREFIX}QSAVE='"1"0' is not a valid yes/no value""",
f"""{_PREFIX}QINT="'5'x" is not a whole number"""):
if not any(want in p for p in problems):
bad.append(f"validate() did not report: {want}")
if any(f"{_PREFIX}QCLEAN" in p for p in problems):
bad.append("a quoted value with a real comment was reported")
return bad
finally:
cfg._BAD_VALUES[:] = saved_bad
cfg._QUOTE_TRAILING.clear()
cfg._QUOTE_TRAILING.update(saved_q)
_clear_test_keys()
def _settings_without_env(overrides: dict):
"""
A fresh copy of settings.py, run with NO .env beside it and the given
environment overrides (None = unset). So what comes back is the code
defaults, not whatever the real .env on this machine says.
The copy is pointed at an empty temp folder through settings.py's own
frozen-exe branch (ROOT = the executable's folder), so its loader finds
no .env. Nothing is registered in sys.modules; the real `cfg` is untouched.
"""
saved_env = {k: os.environ.get(k) for k in overrides}
had_frozen = hasattr(sys, "frozen")
saved_frozen = getattr(sys, "frozen", None)
saved_exe = sys.executable
with tempfile.TemporaryDirectory() as tmp:
try:
for k, v in overrides.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
sys.frozen = True
sys.executable = str(pathlib.Path(tmp) / "bot.exe")
spec = importlib.util.spec_from_file_location(
"ecw_bot._settings_probe", cfg.__file__)
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
return probe
finally:
sys.executable = saved_exe
if had_frozen:
sys.frozen = saved_frozen
else:
del sys.frozen
for k, v in saved_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def check_write_defaults() -> list:
bad = []
for label, value in (("unset", None), ("blank", "")):
probe = _settings_without_env({"SAVE_CORRECTIONS": value,
"APPLY_CORRECTIONS": value})
# Proves the probe really ran away from the project's .env.
if probe.ROOT == cfg.ROOT:
bad.append(f"{label}: the probe used the real project root")
# A missing or blank line must never enable writes.
if probe.SAVE_CORRECTIONS is not False:
bad.append(f"{label}: SAVE_CORRECTIONS defaults to "
f"{probe.SAVE_CORRECTIONS!r}, expected False")
# Applying only fills the form in (closed with Cancel while saving is
# off); defaulting it off would make a run that omits it check nothing.
if probe.APPLY_CORRECTIONS is not True:
bad.append(f"{label}: APPLY_CORRECTIONS defaults to "
f"{probe.APPLY_CORRECTIONS!r}, expected True")
if any(k in ("SAVE_CORRECTIONS", "APPLY_CORRECTIONS")
for k, _, _ in getattr(probe, "_BAD_VALUES", [])):
bad.append(f"{label}: a {label} value was recorded as bad")
return bad
def check_env_file_keys_are_recorded() -> list:
"""The keys the loader took from .env — not those the real environment
already had — are recorded, so a child process can be started without
them and read .env afresh (web/bot_runner.py, review 2026-10-14)."""
_clear_test_keys()
os.environ[_PREFIX + "REAL"] = "from-the-real-environment"
try:
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / ".env"
path.write_text(f"{_PREFIX}REAL=file\n{_PREFIX}FILE=file\n"
f"Ecwbot_Test_Mixed=file\n", encoding="utf-8")
cfg._load_env_file(path)
keys = getattr(cfg, "ENV_FILE_KEYS", set())
bad = []
if _PREFIX + "FILE" not in keys:
bad.append("a key loaded from .env is not in ENV_FILE_KEYS")
if _PREFIX + "REAL" in keys:
bad.append("a key the real environment had is in ENV_FILE_KEYS")
# Recorded as os.environ lists it — on Windows upper-cased — or the
# child-env filter, which walks os.environ, misses it.
listed = [k for k in os.environ if k.upper() == _PREFIX + "MIXED"]
if not listed or listed[0] not in keys:
bad.append("a mixed-case .env key is not recorded the way "
"os.environ lists it")
return bad
finally:
keys = getattr(cfg, "ENV_FILE_KEYS", set())
for k in (_PREFIX + "FILE", _PREFIX + "REAL", _PREFIX + "MIXED",
"Ecwbot_Test_Mixed"):
keys.discard(k)
_clear_test_keys()
def _run(check) -> list:
"""A check that raises is a failure of that check, not of the file."""
try:
return check()
except Exception as exc:
return [f"raised {type(exc).__name__}: {exc}"]
def main() -> int:
groups = [
("ROOT and .env resolution", _run(check_root)),
("no selectors in settings", _run(check_no_selectors_in_settings)),
("no shadowed import alias", _run(check_no_alias_shadowing)),
("import graph is layered", _run(check_layers)),
(".env: unquoted inline comment stripped",
_run(check_env_inline_comments_unquoted)),
(".env: quoted value and trailing comment",
_run(check_env_inline_comments_quoted)),
("SAVE=0 with a comment reads as off",
_run(check_commented_save_line_reads_as_off)),
("bad yes/no and number reported by validate",
_run(check_bad_values_are_reported_by_validate)),
("quoted yes/no with trailing text is not guessed",
_run(check_quoted_value_with_trailing_text_is_not_guessed)),
("SAVE off / APPLY on by default", _run(check_write_defaults)),
(".env keys recorded for a child process",
_run(check_env_file_keys_are_recorded)),
]
failures = 0
for name, problems in groups:
if problems:
failures += 1
print(f" FAIL {name}")
for p in problems:
print(f" {p}")
else:
print(f" ok {name}")
print(f"\n{len(groups) - failures}/{len(groups)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())