From b88f6d0963e4b20ca103eb85a982c87043ee2ce5 Mon Sep 17 00:00:00 2001 From: hoelee Date: Tue, 22 Sep 2026 03:44:24 +0800 Subject: [PATCH] Fix NameError in load_ignored_keywords from pagination refactor The previous commit renamed the record list to 'rows' but missed the loop in load_ignored_keywords, leaving a bare 'j' -> 'tick error: name j is not defined' every tick. The loop died, health.json went ok:false, the container reported unhealthy, and alert_on_health fired against a live failure. - fix the stale reference - use real emoji instead of literal backslash escapes in alert text - test: exercise the real loaders end-to-end against a fake NocoDB so a missed rename is caught (mutation-verified: reintroducing the bug fails 3 checks) --- monitor.py | 6 +- test_pagination.py | 168 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/monitor.py b/monitor.py index 12a08d1..73b425c 100644 --- a/monitor.py +++ b/monitor.py @@ -479,7 +479,7 @@ def load_ignored_keywords(ignored_keywords_tid, settings_tid, kw_fk_col=None): # 先收集 watch 链接的 Settings 行 Id -> 关键词集合 by_watch_id = {} # settings row Id -> set(keywords lower) - for r in j.get("list", []): + for r in rows: kw = (r.get("keyword") or "").strip().lower() if not kw: continue @@ -746,7 +746,7 @@ def alert_on_health(ok, err): st["fail_streak"] = int(st.get("fail_streak", 0)) + 1 if (st["fail_streak"] >= ERROR_ALERT_AFTER and not st.get("alerted")): - msg = (f"\U0001F6A8 carousell-monitor 故障\n" + msg = (f"🚨 carousell-monitor 故障\n" f"连续失败 {st['fail_streak']} 次\n" f"错误: {err or '(none)'}\n" f"容器将标记为 unhealthy") @@ -758,7 +758,7 @@ def alert_on_health(ok, err): return # ok == True if st.get("alerted"): - msg = ("\u2705 carousell-monitor 已恢复\n" + msg = ("✅ carousell-monitor 已恢复\n" f"故障持续 {st.get('fail_streak', 0)} 个 tick\n" f"上次错误: {st.get('last_error') or '(none)'}") tg("sendMessage", {"chat_id": TELEGRAM_CHAT_ID, "text": msg}) diff --git a/test_pagination.py b/test_pagination.py index 926ea3c..6cb3a30 100644 --- a/test_pagination.py +++ b/test_pagination.py @@ -85,6 +85,10 @@ def mkrows(n, notified=1, start_id=1): for i in range(start_id, start_id + n)] +def json_dumps_ok(t): + return _json.dumps({"text": t}) + + FAILURES = [] @@ -262,6 +266,170 @@ with tempfile.TemporaryDirectory() as td: run_hc({"last_run_epoch": now - 9999, "ok": True, "error": ""}) == 1) + + +# --------------------------------------------------------------------------- # +print("\n[9] alert message text is Telegram-safe (no literal backslash escapes)") + +with tempfile.TemporaryDirectory() as td: + m = fresh_monitor_with_state(td) + captured = [] + m.tg = lambda method, payload: (captured.append(payload.get("text", "")), + 200, {"ok": True})[1:] + for _ in range(3): + m.alert_on_health(False, "tick error: boom") + txt = captured[0] + check("no literal backslash-U escape in text", "\\U0001F6A8" not in txt, + repr(txt[:40])) + check("no literal backslash-u escape in text", "\\u2705" not in txt) + check("contains a real emoji codepoint", any(ord(ch) > 0x2000 for ch in txt)) + check("text is valid JSON-encodable", isinstance(json_dumps_ok(txt), str)) + check("no stray control chars", all(ord(ch) >= 32 or ch == "\n" for ch in txt)) + +# round-trip the exact payload through the same conversion _http uses +with tempfile.TemporaryDirectory() as td: + m = fresh_monitor_with_state(td) + payloads = [] + m.tg = lambda method, payload: (payloads.append(payload), 200, {"ok": True})[1:] + for _ in range(3): + m.alert_on_health(False, "boom") + m.alert_on_health(True, "") + check("rebuilds JSON cleanly for every alert payload", + all(isinstance(_json.dumps(p), str) for p in payloads), + f"{len(payloads)} payloads") + check("chat_id kept as string", all(p["chat_id"] == "123" for p in payloads)) + + + + +# --------------------------------------------------------------------------- # +print("\n[10] REAL functions run end-to-end against a fake NocoDB (catches typos)") + +# A fake that answers the specific table ids the real code uses, so every +# refactored loader is actually EXECUTED. Guards the "renamed the variable but +# missed a reference -> NameError kills the whole tick" bug class. +TIDS = {"L": "t_listings", "S": "t_settings", "IS": "t_sellers", "IK": "t_kw"} + + +def build_fake(): + calls = [] + + def fake_nc(method, path, body=None): + calls.append((method, path)) + if method != "GET": + return 200, {"ok": True} + if "/records" not in path: + return 200, {"list": []} + tid = path.split("/tables/")[1].split("/")[0] + q = {} + if "?" in path: + for part in path.split("?", 1)[1].split("&"): + if "=" in part: + k, v = part.split("=", 1) + q[k] = v + limit = int(q.get("limit", 1000)) + offset = int(q.get("offset", 0)) + if tid == TIDS["L"]: + rows = mkrows(1035) + elif tid == TIDS["S"]: + rows = [{"Id": 5, "title": "Uniform", "url": "https://c/u", + "enabled": 1, "notify": 1, + "check_interval_minutes": 4, + "last_checked_at": "2026-09-21 19:00:00"}] + elif tid == TIDS["IS"]: + rows = [{"Id": 1, "seller_name": "badguy"}] + else: + rows = [{"Id": 1, "keyword": "dakimakura", + "nc_x___Settings_id": 5}] + page = rows[offset:offset + limit] + return 200, {"list": page, "pageInfo": {"totalRows": len(rows)}} + + return fake_nc, calls + + +mon = load_monitor() +fake_nc, calls = build_fake() +mon.nc = fake_nc + +# every refactored loader must run without NameError +try: + loaded_seen = mon.load_seen(TIDS["L"]) + check("load_seen executes", isinstance(loaded_seen, set)) +except Exception as e: + check("load_seen executes", False, f"{type(e).__name__}: {e}") + +try: + w = mon.load_watches(TIDS["S"]) + check("load_watches executes + returns enabled", len(w) == 1 and w[0]["enabled"] == 1) +except Exception as e: + check("load_watches executes", False, f"{type(e).__name__}: {e}") + +try: + s = mon.load_ignored_sellers(TIDS["IS"]) + check("load_ignored_sellers executes", s == {"badguy"}, f"{s}") +except Exception as e: + check("load_ignored_sellers executes", False, f"{type(e).__name__}: {e}") + +try: + k = mon.load_ignored_keywords(TIDS["IK"], TIDS["S"], "nc_x___Settings_id") + check("load_ignored_keywords executes + maps url", k == {"https://c/u": {"dakimakura"}}, + f"{k}") +except Exception as e: + check("load_ignored_keywords executes", False, f"{type(e).__name__}: {e}") + +# full send_pending_notifications with the fake (the original failure site) +mon2 = load_monitor() +fake_nc2, calls2 = build_fake() +sent_ids = [] +mon2.nc = fake_nc2 +mon2.send_listing_from_record = lambda rec: (sent_ids.append(rec["Id"]), True)[1] +try: + mon2.send_pending_notifications(TIDS["L"], TIDS["S"], TIDS["IS"], TIDS["IK"], + "nc_x___Settings_id") + # all 1035 rows are notified=1 in mkrows -> nothing pending is correct + check("send_pending_notifications executes cleanly", True, f"sent={len(sent_ids)}") +except Exception as e: + check("send_pending_notifications executes cleanly", False, + f"{type(e).__name__}: {e}") + +# and with a real backlog on page 2 +mon3 = load_monitor() + + +def fake_nc_backlog(method, path, body=None): + if method != "GET": + return 200, {"ok": True} + tid = path.split("/tables/")[1].split("/")[0] if "/tables/" in path else "" + q = {} + if "?" in path: + for part in path.split("?", 1)[1].split("&"): + if "=" in part: + kk, vv = part.split("=", 1) + q[kk] = vv + limit = int(q.get("limit", 1000)); offset = int(q.get("offset", 0)) + if tid == TIDS["L"]: + rows = mkrows(1000, notified=1) + mkrows(3, notified=0, start_id=1001) + elif tid == TIDS["S"]: + rows = [{"Id": 5, "title": "Uniform", "url": "https://c/u", "notify": 1, + "enabled": 1, "check_interval_minutes": 4}] + elif tid == TIDS["IS"]: + rows = [] + else: + rows = [] + return 200, {"list": rows[offset:offset + limit], "pageInfo": {"totalRows": len(rows)}} + + +sent3 = [] +mon3.nc = fake_nc_backlog +mon3.send_listing_from_record = lambda rec: (sent3.append(rec["Id"]), True)[1] +try: + mon3.send_pending_notifications(TIDS["L"], TIDS["S"], TIDS["IS"], TIDS["IK"], None) + check("backlog on page 2 detected end-to-end", sent3 == [1001, 1002, 1003], + f"sent={sent3}") +except Exception as e: + check("backlog on page 2 detected end-to-end", False, f"{type(e).__name__}: {e}") + + print("\n" + "=" * 62) if FAILURES: print(f"FAILED ({len(FAILURES)}): " + "; ".join(FAILURES))