Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
"""Notifications on a real database: raise and resolve, seed, recipient resolution, delivery through the fake relay,
|
||||
dry run, the digest, the watch checks, the version note, the feed hook and the two jobs.
|
||||
|
||||
One database for the module (the tunnel is slow); each test starts from empty notification tables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from fakesmtp import Certs, FakeSmtp, can_make_ca, make_ca
|
||||
from notifyhelpers import OUTSIDE, NotifyDir, add_users, clean, fresh_db, sent_since
|
||||
from pshelpers import app_conn, needs_pg
|
||||
|
||||
from packetstation.feed.config import FeedSettings
|
||||
from packetstation.notify import digest, raise_event, resolve_event, seed, send, watch
|
||||
from packetstation.notify.routing import Resolver, load_rule
|
||||
from packetstation.settings import Settings
|
||||
from packetstation.worker.jobs import JobContext, Stack
|
||||
|
||||
pytestmark = [needs_pg, pytest.mark.skipif(not can_make_ca(), reason="no way to make a test CA")]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def certs(tmp_path_factory: pytest.TempPathFactory) -> Certs:
|
||||
return make_ca(tmp_path_factory.mktemp("ca"), "relay")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def relay(certs: Certs) -> Iterator[FakeSmtp]:
|
||||
with FakeSmtp(certs) as server:
|
||||
yield server
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def base(tmp_path_factory: pytest.TempPathFactory, relay: FakeSmtp, certs: Certs) -> Iterator[Settings]:
|
||||
yield from fresh_db(
|
||||
tmp_path_factory.mktemp("db"), smtp_host=relay.host, smtp_port=relay.port, smtp_ca_bundle=certs.ca
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ndir() -> NotifyDir:
|
||||
return NotifyDir()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ids(base: Settings, ndir: NotifyDir) -> dict[str, str]:
|
||||
return add_users(base, ndir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s(base: Settings, ids: dict[str, str], relay: FakeSmtp, ndir: NotifyDir) -> Settings:
|
||||
relay.messages.clear()
|
||||
ndir.down = False
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marker(s: Settings) -> int:
|
||||
return clean(s)
|
||||
|
||||
|
||||
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||||
with app_conn(settings) as conn:
|
||||
return conn.execute(sql, params).fetchone()
|
||||
|
||||
|
||||
def set_recipients(conn: Any, event_type: str, rows: list[dict[str, Any]]) -> None:
|
||||
conn.execute("DELETE FROM notification_recipients WHERE event_type = %s", (event_type,))
|
||||
for r in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name, user_id, address)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(event_type, r["kind"], r.get("group_dn"), r.get("group_name"), r.get("user_id"), r.get("address")),
|
||||
)
|
||||
|
||||
|
||||
def ctx_for(conn: Any, settings: Settings, directory: Any) -> JobContext:
|
||||
stack = Stack()
|
||||
stack.cache["directory"] = directory
|
||||
return JobContext(
|
||||
conn=conn,
|
||||
settings=settings,
|
||||
feed_settings=FeedSettings(),
|
||||
run_id=0,
|
||||
trigger="cli",
|
||||
started_at=datetime.now(ZoneInfo(settings.site_tz)),
|
||||
log=logging.getLogger("test.notify"),
|
||||
stack=stack,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- raise and resolve
|
||||
|
||||
|
||||
def test_condition_dedupes_and_resolve_adds_recovery(s: Settings, marker: int) -> None:
|
||||
with app_conn(s) as conn:
|
||||
first = raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", condition=True)
|
||||
assert first is not None
|
||||
again = raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", body="b2", condition=True)
|
||||
assert again is None
|
||||
row = conn.execute(
|
||||
"SELECT raised_count, resolved_at, kind, event_type, body FROM notification_events WHERE id = %s", (first,)
|
||||
).fetchone()
|
||||
assert row[0] == 2 and row[1] is None and row[2] == row[3] == "backup_stale" and row[4] == "b2"
|
||||
rec = resolve_event(conn, "backup_stale", subject="prod")
|
||||
assert rec is not None
|
||||
r = conn.execute(
|
||||
"SELECT recovery_of, title, resolved_at, severity FROM notification_events WHERE id = %s", (rec,)
|
||||
).fetchone()
|
||||
assert r[0] == first and r[1] == "Cleared: Backup is stale" and r[2] is not None and r[3] == "info"
|
||||
assert conn.execute("SELECT resolved_at FROM notification_events WHERE id = %s", (first,)).fetchone()[0]
|
||||
assert resolve_event(conn, "backup_stale", subject="prod") is None
|
||||
# Open again after it cleared: a new row, a new mail
|
||||
assert raise_event(conn, "backup_stale", subject="prod", title="Backup is stale", condition=True) is not None
|
||||
|
||||
|
||||
def test_one_shot_dedupe_and_unknown_type(s: Settings, marker: int) -> None:
|
||||
with app_conn(s) as conn:
|
||||
a = raise_event(conn, "security_alert", subject="ann", title="Cooldown", dedupe_minutes=15)
|
||||
b = raise_event(conn, "security_alert", subject="ann", title="Cooldown", dedupe_minutes=15)
|
||||
c = raise_event(conn, "security_alert", subject="bob", title="Cooldown", dedupe_minutes=15)
|
||||
d = raise_event(conn, "security_alert", subject="ann", title="Cooldown")
|
||||
assert a and c and d and b is None
|
||||
assert conn.execute("SELECT resolved_at FROM notification_events WHERE id = %s", (a,)).fetchone()[0]
|
||||
assert raise_event(conn, "no_such_type", subject="x", title="x") is None
|
||||
assert resolve_event(conn, "no_such_type", subject="x") is None
|
||||
|
||||
|
||||
def test_seed_to_admins_is_idempotent(s: Settings, marker: int) -> None:
|
||||
with app_conn(s) as conn:
|
||||
added = seed(conn, s)
|
||||
assert len(added) == 13
|
||||
rows = conn.execute(
|
||||
"SELECT r.event_type, r.enabled, r.delivery::text, r.digest_time::text, r.param, c.kind::text,"
|
||||
" c.group_dn, c.group_name FROM notification_rules r JOIN notification_recipients c USING (event_type)"
|
||||
).fetchall()
|
||||
assert len(rows) == 13
|
||||
for r in rows:
|
||||
assert r[1] is True and r[2] == "immediate" and r[3] == "07:00:00" and r[5] == "group"
|
||||
assert r[6] == f"CN=PS-Admins,{s.ldap_group_base_dn}" and r[7] == "PS-Admins"
|
||||
params = {r[0]: r[4] for r in rows}
|
||||
assert params["drawing_failing"] == 24 and params["co_waiting"] == 3 and params["qc_reject"] is None
|
||||
conn.execute("UPDATE notification_rules SET enabled = false WHERE event_type = 'feed_failed'")
|
||||
conn.execute("DELETE FROM notification_recipients WHERE event_type = 'feed_failed'")
|
||||
assert seed(conn, s) == []
|
||||
assert (
|
||||
conn.execute("SELECT enabled FROM notification_rules WHERE event_type = 'feed_failed'").fetchone()[0]
|
||||
is False
|
||||
)
|
||||
n = conn.execute("SELECT count(*) FROM notification_recipients WHERE event_type = 'feed_failed'").fetchone()[0]
|
||||
assert n == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- recipients
|
||||
|
||||
|
||||
def test_resolve_recipients(s: Settings, marker: int, ndir: NotifyDir, ids: dict[str, str]) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
set_recipients(
|
||||
conn,
|
||||
"qc_reject",
|
||||
[
|
||||
{"kind": "group", "group_dn": f"CN=PS-Admins,{s.ldap_group_base_dn}", "group_name": "PS-Admins"},
|
||||
{"kind": "group", "group_dn": ndir.dn(OUTSIDE), "group_name": OUTSIDE},
|
||||
{"kind": "user", "user_id": ids["pm.paula"]},
|
||||
{"kind": "user", "user_id": ids["disabled.dan"]},
|
||||
{"kind": "mailbox", "address": "[email protected]"},
|
||||
{"kind": "mailbox", "address": "[email protected]"},
|
||||
{"kind": "dept_supervisor"},
|
||||
],
|
||||
)
|
||||
rule = load_rule(conn, "qc_reject")
|
||||
assert rule is not None
|
||||
got = Resolver(conn, s, ndir).resolve(rule, "03")
|
||||
by = {r.address: r for r in got}
|
||||
assert by["[email protected]"].why == "group PS-Admins" # once, the group wins over the mailbox
|
||||
assert by["[email protected]"].user_id == ids["admin.ann"]
|
||||
assert by["[email protected]"].why == f"group {OUTSIDE}"
|
||||
assert by["[email protected]"].why == "user"
|
||||
assert "[email protected]" not in by # not active
|
||||
assert by["[email protected]"].why == "mailbox" and by["[email protected]"].user_id is None
|
||||
sup = {a for a, r in by.items() if r.why == "Supervisor group of 03"}
|
||||
assert sup == {"[email protected]", "[email protected]"}
|
||||
assert len(got) == len(by)
|
||||
# No department: the Supervisor recipient resolves to nobody
|
||||
none_dept = {r.address for r in Resolver(conn, s, ndir).resolve(rule, None)}
|
||||
assert "[email protected]" not in none_dept
|
||||
# Exclusions drop by id, and by the person's email (the mailbox row with Ann's address too)
|
||||
conn.execute(
|
||||
"INSERT INTO notification_exclusions (event_type, user_id) VALUES ('qc_reject', %s), ('qc_reject', %s)",
|
||||
(ids["admin.ann"], ids["sup.wes"]),
|
||||
)
|
||||
rule = load_rule(conn, "qc_reject")
|
||||
assert rule is not None
|
||||
after = {r.address for r in Resolver(conn, s, ndir).resolve(rule, "03")}
|
||||
assert "[email protected]" not in after and "[email protected]" not in after
|
||||
assert "[email protected]" in after and "[email protected]" in after
|
||||
|
||||
|
||||
def test_group_members_disabled_or_without_mail_are_left_out(s: Settings, marker: int, ndir: NotifyDir) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
set_recipients(conn, "feed_failed", [{"kind": "group", "group_dn": ndir.dn("PS-03-WeldParts-Worker")}])
|
||||
rule = load_rule(conn, "feed_failed")
|
||||
assert rule is not None
|
||||
got = {r.address for r in Resolver(conn, s, ndir).resolve(rule, None)}
|
||||
assert "[email protected]" in got and "[email protected]" in got
|
||||
assert "[email protected]" not in got
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- delivery
|
||||
|
||||
|
||||
def test_immediate_send_through_the_relay(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
eid = raise_event(
|
||||
conn, "feed_failed", subject="visual-feed", title="visual-feed run failed", body="boom", condition=True
|
||||
)
|
||||
counts = send.dispatch_pending(conn, s, ndir)
|
||||
assert counts == {"sent": 1}
|
||||
row = conn.execute(
|
||||
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
|
||||
).fetchone()
|
||||
assert row[0] is not None and row[1] == "sent"
|
||||
assert send.dispatch_pending(conn, s, ndir) == {}
|
||||
(m,) = relay.messages
|
||||
assert m.tls and m.header("Subject") == "[Packet Station TEST] visual-feed run failed"
|
||||
assert m.header("To") == "[email protected]"
|
||||
assert m.rcpts == ["[email protected]"]
|
||||
assert "boom" in m.text and "Feed run failed" in m.text
|
||||
(rec,) = sent_since(s, marker)
|
||||
assert rec["result"] == "sent" and rec["event_ids"] == [eid] and not rec["digest"] and not rec["test"]
|
||||
assert rec["recipients"] == [
|
||||
{"address": "[email protected]", "user_id": str(ndir.user("admin.ann").guid), "why": "group PS-Admins"}
|
||||
]
|
||||
|
||||
|
||||
def test_recovery_notice_goes_to_the_same_rule(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
raise_event(conn, "backup_stale", subject="prod", title="Backup prod is stale", condition=True)
|
||||
send.dispatch_pending(conn, s, ndir)
|
||||
resolve_event(conn, "backup_stale", subject="prod", body="A fresh dump is there.")
|
||||
raise_event(conn, "backup_stale", subject="prod", title="Backup prod is stale", condition=True)
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"sent": 2}
|
||||
assert [m.header("Subject") for m in relay.messages] == [
|
||||
"[Packet Station TEST] Backup prod is stale",
|
||||
"[Packet Station TEST] Cleared: Backup prod is stale",
|
||||
"[Packet Station TEST] Backup prod is stale",
|
||||
]
|
||||
|
||||
|
||||
def test_dry_run_records_and_test_send_still_sends(
|
||||
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
|
||||
) -> None:
|
||||
dry = s.model_copy(update={"notify_dry_run": True})
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
eid = raise_event(conn, "version_deployed", subject="build:x", title="Version x deployed")
|
||||
assert send.dispatch_pending(conn, dry, ndir) == {"dry_run": 1}
|
||||
assert relay.messages == []
|
||||
assert conn.execute("SELECT dispatch_result FROM notification_events WHERE id = %s", (eid,)).fetchone()[0] == (
|
||||
"dry_run"
|
||||
)
|
||||
out = send.send_test(conn, dry, ndir, "version_deployed", requested_by=ids["admin.ann"])
|
||||
assert out["result"] == "sent" and out["error"] is None
|
||||
assert out["subject"] == "[Packet Station TEST] Test: Version deployed"
|
||||
assert [r["address"] for r in out["recipients"]] == ["[email protected]"]
|
||||
to = send.send_test(conn, dry, ndir, "version_deployed", to="[email protected]")
|
||||
assert to["result"] == "sent" and to["recipients"] == [
|
||||
{"address": "[email protected]", "user_id": None, "why": "test address"}
|
||||
]
|
||||
assert [m.rcpts for m in relay.messages] == [["[email protected]"], ["[email protected]"]]
|
||||
rows = sent_since(s, marker)
|
||||
assert [(r["result"], r["test"]) for r in rows] == [("dry_run", False), ("sent", True), ("sent", True)]
|
||||
assert str(rows[1]["requested_by"]) == ids["admin.ann"]
|
||||
|
||||
|
||||
def test_mail_off_records_failed(s: Settings, marker: int, ndir: NotifyDir) -> None:
|
||||
off = s.model_copy(update={"smtp_host": ""})
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
out = send.send_test(conn, off, ndir, "feed_failed")
|
||||
assert out["result"] == "failed" and out["error"] == "Mail is off: SMTP_HOST is empty."
|
||||
eid = raise_event(conn, "feed_failed", subject="drawing-lookup", title="t", condition=True)
|
||||
assert send.dispatch_pending(conn, off, ndir) == {"failed": 1}
|
||||
# Recorded and not retried
|
||||
assert conn.execute("SELECT dispatched_at FROM notification_events WHERE id = %s", (eid,)).fetchone()[0]
|
||||
assert [(r["result"], r["error"]) for r in sent_since(s, marker)] == [
|
||||
("failed", "Mail is off: SMTP_HOST is empty."),
|
||||
("failed", "Mail is off: SMTP_HOST is empty."),
|
||||
]
|
||||
|
||||
|
||||
def test_rule_off_no_recipients_and_digest_queue(s: Settings, marker: int, ndir: NotifyDir) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
conn.execute("UPDATE notification_rules SET enabled = false WHERE event_type = 'group_missing'")
|
||||
conn.execute("UPDATE notification_rules SET delivery = 'digest' WHERE event_type = 'ad_reconcile_report'")
|
||||
conn.execute("DELETE FROM notification_recipients WHERE event_type = 'backup_stale'")
|
||||
a = raise_event(conn, "group_missing", subject="PS-X", title="t", condition=True)
|
||||
b = raise_event(conn, "ad_reconcile_report", subject="ad", title="t")
|
||||
c = raise_event(conn, "backup_stale", subject="prod", title="t", condition=True)
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"rule_off": 1, "queued": 1, "no_recipients": 1}
|
||||
res = dict(conn.execute("SELECT id, dispatch_result FROM notification_events").fetchall())
|
||||
assert res == {a: "rule_off", b: "queued", c: "no_recipients"}
|
||||
assert conn.execute("SELECT event_id FROM notification_outbox").fetchall() == [(b,)]
|
||||
assert [r["result"] for r in sent_since(s, marker)] == ["no_recipients"]
|
||||
|
||||
|
||||
def test_directory_down_retries_then_fails(s: Settings, marker: int, ndir: NotifyDir) -> None:
|
||||
ndir.down = True
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
eid = raise_event(conn, "integrity_failure", subject="integrity", title="t", condition=True)
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"retry": 1}
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"retry": 1}
|
||||
row = conn.execute(
|
||||
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
|
||||
).fetchone()
|
||||
assert row == (None, "directory_retry:2")
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"failed": 1}
|
||||
row = conn.execute(
|
||||
"SELECT dispatched_at, dispatch_result FROM notification_events WHERE id = %s", (eid,)
|
||||
).fetchone()
|
||||
assert row[0] is not None and row[1] == "failed"
|
||||
rows = sent_since(s, marker)
|
||||
assert [(r["result"], r["error"]) for r in rows] == [("failed", "directory unavailable")] * 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- digest
|
||||
|
||||
|
||||
def test_digest_one_message_per_address(
|
||||
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
|
||||
) -> None:
|
||||
tz = ZoneInfo(s.site_tz)
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
for et in ("ad_reconcile_report", "group_missing"):
|
||||
conn.execute(
|
||||
"UPDATE notification_rules SET delivery = 'digest', digest_time = '07:00' WHERE event_type = %s", (et,)
|
||||
)
|
||||
set_recipients(conn, "group_missing", [{"kind": "user", "user_id": ids["pm.paula"]}])
|
||||
conn.execute(
|
||||
"INSERT INTO notification_recipients (event_type, kind, user_id)"
|
||||
" VALUES ('ad_reconcile_report', 'user', %s)",
|
||||
(ids["pm.paula"],),
|
||||
)
|
||||
e1 = raise_event(conn, "ad_reconcile_report", subject="ad", title="Reconcile: 2 added")
|
||||
e2 = raise_event(conn, "group_missing", subject="PS-A", title="Group PS-A missing", condition=True)
|
||||
e3 = raise_event(conn, "group_missing", subject="PS-B", title="Group PS-B missing", condition=True)
|
||||
assert send.dispatch_pending(conn, s, ndir) == {"queued": 3}
|
||||
# Queued at "now"; a digest time later today has not passed yet for them
|
||||
now = datetime.now(tz)
|
||||
conn.execute("UPDATE notification_outbox SET queued_at = %s", (now - timedelta(days=1),))
|
||||
before = datetime.combine(now.date(), datetime.min.time(), tzinfo=tz) + timedelta(hours=6, minutes=59)
|
||||
assert digest.run_digest(conn, s, ndir, before) == {"due": 0}
|
||||
at = before + timedelta(minutes=2)
|
||||
counts = digest.run_digest(conn, s, ndir, at)
|
||||
assert counts["messages"] == 2 and counts["sent"] == 2 and counts["left"] == 0
|
||||
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 0
|
||||
assert digest.run_digest(conn, s, ndir, at) == {"due": 0}
|
||||
by = {tuple(m.rcpts): m for m in relay.messages}
|
||||
assert set(by) == {("[email protected]",), ("[email protected]",)}
|
||||
# Paula is on both rules: one message with all three. Ann (PS-Admins, the seed) only on the reconcile rule.
|
||||
paula = by[("[email protected]",)]
|
||||
assert paula.header("Subject") == "[Packet Station TEST] Daily digest: 3 notifications"
|
||||
for t in ("Reconcile: 2 added", "Group PS-A missing", "Group PS-B missing"):
|
||||
assert t in paula.text
|
||||
ann = by[("[email protected]",)]
|
||||
assert ann.header("Subject") == "[Packet Station TEST] Daily digest: 1 notification"
|
||||
assert "Reconcile: 2 added" in ann.text and "PS-A" not in ann.text
|
||||
rows = sent_since(s, marker)
|
||||
assert len(rows) == 2 and all(r["digest"] and r["event_type"] == "digest" for r in rows)
|
||||
got = {r["recipients"][0]["address"]: sorted(r["event_ids"]) for r in rows}
|
||||
assert got == {"[email protected]": [e1], "[email protected]": sorted([e1, e2, e3])}
|
||||
|
||||
|
||||
def test_digest_failure_leaves_rows_and_skips_done_addresses(
|
||||
s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir, ids: dict[str, str]
|
||||
) -> None:
|
||||
tz = ZoneInfo(s.site_tz)
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
conn.execute(
|
||||
"UPDATE notification_rules SET delivery = 'digest', digest_time = '00:00'"
|
||||
" WHERE event_type = 'ad_reconcile_report'"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO notification_recipients (event_type, kind, address) VALUES"
|
||||
" ('ad_reconcile_report', 'mailbox', '[email protected]')"
|
||||
)
|
||||
raise_event(conn, "ad_reconcile_report", subject="ad", title="Reconcile")
|
||||
send.dispatch_pending(conn, s, ndir)
|
||||
now = datetime.now(tz)
|
||||
conn.execute("UPDATE notification_outbox SET queued_at = %s", (now - timedelta(days=1),))
|
||||
at = now
|
||||
relay.refuse.add("[email protected]")
|
||||
try:
|
||||
counts = digest.run_digest(conn, s, ndir, at)
|
||||
assert counts["sent"] == 1 and counts["failed"] == 1 and counts["left"] == 1
|
||||
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 1
|
||||
# Backoff after a failure
|
||||
assert digest.run_digest(conn, s, ndir, at + timedelta(minutes=5))["backoff"] is True
|
||||
finally:
|
||||
relay.refuse.clear()
|
||||
counts = digest.run_digest(conn, s, ndir, at + timedelta(minutes=31))
|
||||
assert counts["messages"] == 1 and counts["sent"] == 1 # Ann already has it
|
||||
assert conn.execute("SELECT count(*) FROM notification_outbox WHERE sent_at IS NULL").fetchone()[0] == 0
|
||||
assert [m.rcpts for m in relay.messages] == [["[email protected]"], ["[email protected]"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- watch checks, version, feed hook
|
||||
|
||||
|
||||
def _packet_with_co(conn: Any, dept: str, days: int) -> int:
|
||||
pid = conn.execute("INSERT INTO projects (number, name) VALUES ('L77001', 'P') RETURNING id").fetchone()[0]
|
||||
uid = conn.execute("INSERT INTO units (project_id, lot) VALUES (%s, '001') RETURNING id", (pid,)).fetchone()[0]
|
||||
pk = conn.execute(
|
||||
"INSERT INTO packets (unit_id, dept_code, title) VALUES (%s, %s, 'Nozzle') RETURNING id", (uid, dept)
|
||||
).fetchone()[0]
|
||||
co = conn.execute(
|
||||
"INSERT INTO change_orders (packet_id, co_no, origin, issued_at) VALUES (%s, 1, 'manual',"
|
||||
" now() - make_interval(days => %s)) RETURNING id",
|
||||
(pk, days),
|
||||
).fetchone()[0]
|
||||
return int(co)
|
||||
|
||||
|
||||
def test_watch_drawings_and_change_orders(s: Settings, marker: int, ndir: NotifyDir) -> None:
|
||||
with app_conn(s) as conn:
|
||||
seed(conn, s)
|
||||
conn.execute(
|
||||
"INSERT INTO drawing_lookups (dwg, ok, fail_count, failing_since, note) VALUES"
|
||||
" ('D-1', false, 9, now() - interval '30 hours', 'not found'),"
|
||||
" ('D-2', false, 2, now() - interval '2 hours', 'timeout')"
|
||||
)
|
||||
assert watch.check_drawings(conn) == {"drawings_failing": 1}
|
||||
title, body, dept = conn.execute(
|
||||
"SELECT title, body, dept_code FROM notification_events WHERE kind = 'drawing_failing'"
|
||||
).fetchone()
|
||||
assert title == "1 drawing lookup failing for more than 24 hours" and "D-1" in body and "D-2" not in body
|
||||
conn.execute("UPDATE notification_rules SET param = 1 WHERE event_type = 'drawing_failing'")
|
||||
assert watch.check_drawings(conn) == {"drawings_failing": 2}
|
||||
assert (
|
||||
conn.execute("SELECT count(*) FROM notification_events WHERE kind = 'drawing_failing'").fetchone()[0] == 1
|
||||
)
|
||||
conn.execute("UPDATE drawing_lookups SET failing_since = NULL")
|
||||
assert watch.check_drawings(conn) == {"drawings_failing": 0, "drawings_cleared": True}
|
||||
|
||||
co = _packet_with_co(conn, "04", 5)
|
||||
out = watch.check_change_orders(conn)
|
||||
assert out == {"co_waiting": 1, "co_raised": 1, "co_cleared": 0}
|
||||
subj, dept, title = conn.execute(
|
||||
"SELECT subject, dept_code, title FROM notification_events WHERE kind = 'co_waiting'"
|
||||
).fetchone()
|
||||
assert subj == f"co:{co}" and dept == "04" and title.startswith("Change order CO-1 waiting 5 days")
|
||||
assert watch.check_change_orders(conn)["co_raised"] == 0
|
||||
conn.execute("UPDATE change_orders SET review = 'approved' WHERE id = %s", (co,))
|
||||
assert watch.check_change_orders(conn) == {"co_waiting": 0, "co_raised": 0, "co_cleared": 1}
|
||||
rec = conn.execute(
|
||||
"SELECT dept_code, title FROM notification_events WHERE kind = 'co_waiting' AND recovery_of IS NOT NULL"
|
||||
).fetchone()
|
||||
assert rec[0] == "04" and rec[1].startswith("Cleared: Change order CO-1")
|
||||
conn.execute("DELETE FROM change_orders")
|
||||
conn.execute("DELETE FROM packets")
|
||||
|
||||
|
||||
def test_note_build(s: Settings, marker: int) -> None:
|
||||
with app_conn(s) as conn:
|
||||
first = watch.note_build(conn, "sha-aaa")
|
||||
assert first is not None
|
||||
assert watch.note_build(conn, "sha-aaa") is None
|
||||
second = watch.note_build(conn, "sha-bbb")
|
||||
assert second is not None
|
||||
body = conn.execute("SELECT body FROM notification_events WHERE id = %s", (second,)).fetchone()[0]
|
||||
assert "sha-bbb" in body and "sha-aaa" in body
|
||||
value = conn.execute("SELECT value FROM settings WHERE key = 'notify.last_build'").fetchone()[0]
|
||||
assert value == {"build": "sha-bbb"}
|
||||
|
||||
|
||||
def test_feed_run_hook(s: Settings, marker: int) -> None:
|
||||
def open_kinds(conn: Any) -> set[tuple[str, str]]:
|
||||
return {
|
||||
(r[0], r[1])
|
||||
for r in conn.execute("SELECT kind, subject FROM notification_events WHERE resolved_at IS NULL").fetchall()
|
||||
}
|
||||
|
||||
with app_conn(s) as conn:
|
||||
watch.feed_run(conn, "visual-feed", "failed", ["VisualError: down"], 7)
|
||||
watch.feed_run(conn, "drawing-lookup", "failed", [], 8)
|
||||
assert open_kinds(conn) == {("feed_failed", "visual-feed"), ("feed_failed", "drawing-lookup")}
|
||||
watch.feed_run(conn, "visual-feed", "degraded", ["one row bad"], 9)
|
||||
assert open_kinds(conn) == {("feed_degraded", "visual-feed"), ("feed_failed", "drawing-lookup")}
|
||||
watch.feed_run(conn, "drawing-lookup", "degraded", ["x"], 10)
|
||||
watch.feed_run(conn, "visual-feed", "ok", [], 11)
|
||||
assert open_kinds(conn) == set()
|
||||
watch.feed_run(conn, "visual-feed", "skipped", [], 12)
|
||||
watch.feed_run(conn, "housekeeping", "failed", [], 13)
|
||||
assert open_kinds(conn) == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the jobs and the quota
|
||||
|
||||
|
||||
def test_notify_send_job(s: Settings, marker: int, relay: FakeSmtp, ndir: NotifyDir) -> None:
|
||||
from packetstation.worker.jobs import notify_digest, notify_send
|
||||
|
||||
assert notify_send.SPEC.name == "notify-send" and not notify_send.SPEC.pausable
|
||||
assert notify_digest.SPEC.name == "notify-digest" and not notify_digest.SPEC.pausable
|
||||
build = s.model_copy(update={"ps_build": "sha-job"})
|
||||
with app_conn(s) as conn:
|
||||
ctx = ctx_for(conn, build, ndir)
|
||||
counts = notify_send.run(ctx)
|
||||
assert counts["seeded"] == 13 and counts["version_deployed"] == "sha-job" and counts["sent"] == 1
|
||||
assert counts["drawings_failing"] == 0 and counts["co_waiting"] == 0
|
||||
again = notify_send.run(ctx)
|
||||
assert "version_deployed" not in again and "drawings_failing" not in again # watch waits 15 minutes
|
||||
assert notify_digest.run(ctx_for(conn, build, ndir)) == {"due": 0}
|
||||
assert [m.header("Subject") for m in relay.messages] == ["[Packet Station TEST] Version sha-job deployed"]
|
||||
|
||||
|
||||
def test_check_quota_through_notify(s: Settings, marker: int, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from packetstation.domain import assets
|
||||
|
||||
warn = {"total": 100, "used": 90, "free": 10, "pct": 90.0, "warn": True}
|
||||
ok = {"total": 100, "used": 10, "free": 90, "pct": 10.0, "warn": False}
|
||||
monkeypatch.setattr(assets, "usage_for", lambda *a, **k: dict(warn))
|
||||
with app_conn(s) as conn:
|
||||
assert assets.check_quota(conn, s)["alert"] == "opened"
|
||||
assert assets.check_quota(conn, s)["alert"] is None
|
||||
row = conn.execute(
|
||||
"SELECT event_type, title, detail, raised_count FROM notification_events WHERE kind = 'assets_quota'"
|
||||
).fetchone()
|
||||
assert row[0] == "assets_quota" and row[1] == "Assets storage is 90.0% full" and row[2]["pct"] == 90.0
|
||||
assert row[3] == 1 # check_quota raises only when nothing is open
|
||||
monkeypatch.setattr(assets, "usage_for", lambda *a, **k: dict(ok))
|
||||
out = assets.check_quota(conn, s)
|
||||
assert out["alert"] == "resolved" and out["pct"] == 10.0
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT count(*) FROM notification_events WHERE kind = 'assets_quota' AND recovery_of IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
assert assets.check_quota(conn, s)["alert"] is None
|
||||
|
||||
|
||||
def test_digest_json_detail_is_kept(s: Settings, marker: int) -> None:
|
||||
with app_conn(s) as conn:
|
||||
eid = raise_event(conn, "ad_reconcile_report", subject="ad", title="t", detail={"when": datetime(2026, 1, 2)})
|
||||
got = conn.execute("SELECT detail FROM notification_events WHERE id = %s", (eid,)).fetchone()[0]
|
||||
assert got == {"when": "2026-01-02 00:00:00"}
|
||||
Reference in New Issue
Block a user