"""Tests der Log-Brücke — reine Funktionen, keine Datenbank, keine Uhr. Die Beispielzeilen sind WÖRTLICH aus dem laufenden Worker-Container abgenommen (26.07.2026, Celery 5.4.0) — AGENTS Regel D. Erfundene Formate hätten hier keinen Wert: Genau am geratenen Format scheitert so eine Brücke. """ import logbruecke # Aus `docker compose logs worker`, unverändert. ECHT_CONNECTED = "[2026-07-26 12:04:18,060: INFO/MainProcess] Connected to redis://redis:6379/0" ECHT_MINGLE = "[2026-07-26 12:04:18,063: INFO/MainProcess] mingle: searching for neighbors" ECHT_ALLEIN = "[2026-07-26 12:04:19,071: INFO/MainProcess] mingle: all alone" ECHT_READY = "[2026-07-26 12:04:19,082: INFO/MainProcess] celery@ced31798928d ready." ECHT_WARNUNG = ( "[2026-07-26 12:06:19,094: WARNING/MainProcess] Zombie-Erkennung: " "{'geprueft': 0, 'aufgeraeumt': [], 'uebersprungen': ''}" ) ECHT_BANNER = " -------------- celery@ced31798928d v5.4.0 (opalescent)" def test_ready_ist_die_meldung_auf_die_man_wartet(): """Wenn der Worker nichts tut, ist „ready." die Zeile, die man sehen will — deshalb als Erfolg, nicht als Info im Grundrauschen.""" assert logbruecke.einordnen(ECHT_READY) == ("success", "celery@ced31798928d ready.") def test_verbindung_kommt_durch(): level, text = logbruecke.einordnen(ECHT_CONNECTED) assert level == "info" assert "redis" in text def test_warnungen_und_fehler_kommen_immer_durch(): level, text = logbruecke.einordnen(ECHT_WARNUNG) assert level == "warning" assert "Zombie-Erkennung" in text assert logbruecke.einordnen( "[2026-07-26 12:00:00,000: ERROR/MainProcess] kaputt")[0] == "error" assert logbruecke.einordnen( "[2026-07-26 12:00:00,000: CRITICAL/MainProcess] ganz kaputt")[0] == "error" def test_grundrauschen_wird_verworfen(): """Celery ist bei --loglevel=info gesprächig, und die logs-Tabelle hat keine automatische Aufräumung. Ein zugemülltes Log ist so unbrauchbar wie keins.""" assert logbruecke.einordnen(ECHT_MINGLE) is None assert logbruecke.einordnen(ECHT_ALLEIN) is None assert logbruecke.einordnen(ECHT_BANNER) is None assert logbruecke.einordnen("") is None assert logbruecke.einordnen(None) is None assert logbruecke.einordnen( "[2026-07-26 12:00:00,000: DEBUG/MainProcess] kleinteiliges Zeug") is None def test_absturz_ohne_celery_klammer_kommt_durch(): """Ein Traceback trägt kein Celery-Präfix — und ist das Wichtigste, was passieren kann.""" assert logbruecke.einordnen("Traceback (most recent call last):")[0] == "error" assert logbruecke.einordnen( "ConnectionRefusedError: [Errno 111] Connection refused")[0] == "error" def test_quelle_wird_auf_32_zeichen_gekappt(): """`logs.source` ist String(32). Ohne Kappen scheitert das Insert STILL bei einem langen Worker-Namen — und dann fehlt genau das Log, das man sucht.""" assert logbruecke.quelle_fuer("tobisnicerpc") == "w:tobisnicerpc" lang = logbruecke.quelle_fuer("x" * 100) assert len(lang) == 32 assert lang.startswith("w:") assert logbruecke.quelle_fuer("") == "w:worker" assert logbruecke.quelle_fuer(None) == "w:worker" # --- Drosselung ------------------------------------------------------------- def _bruecke(uhr): geschrieben = [] b = logbruecke.Bruecke( "testpc", schreiber=lambda level, quelle, text: geschrieben.append((level, text)), jetzt=lambda: uhr[0], ) return b, geschrieben def test_drosselung_haelt_bei_30_zeilen_die_minute(): uhr = [0.0] b, geschrieben = _bruecke(uhr) for i in range(40): b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}") assert len(geschrieben) == logbruecke.MAX_ZEILEN_JE_MINUTE def test_naechste_minute_meldet_wieviel_verschluckt_wurde(): """Stilles Verschlucken wäre genau der Fehler, den diese Sitzung schon einmal eine Stunde gekostet hat.""" uhr = [0.0] b, geschrieben = _bruecke(uhr) for i in range(40): b.zeile(f"[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler {i}") uhr[0] = 61.0 b.zeile("[2026-07-26 12:01:01,000: ERROR/MainProcess] naechster Fehler") unterdrueckt = [t for lvl, t in geschrieben if "unterdrückt" in t] assert len(unterdrueckt) == 1 assert "10 weitere" in unterdrueckt[0] def test_kaputter_schreiber_bringt_den_worker_nicht_um(): """Rippy nicht erreichbar → die lokale Log-Datei bleibt der Rückfall. Der Worker muss weiterarbeiten.""" def kaputt(level, quelle, text): raise RuntimeError("Datenbank weg") b = logbruecke.Bruecke("testpc", schreiber=kaputt, jetzt=lambda: 0.0) assert b.zeile("[2026-07-26 12:00:00,000: ERROR/MainProcess] Fehler") is False def test_verworfene_zeilen_zaehlen_nicht_gegen_die_drossel(): uhr = [0.0] b, geschrieben = _bruecke(uhr) for _ in range(100): b.zeile(ECHT_MINGLE) b.zeile(ECHT_READY) assert geschrieben == [("success", "celery@ced31798928d ready.")]