"""Tickets (#6351, #6352): what a ticket must say before it is opened, how its steps reach the timeline and the audit log, and the pages that show it. Opening, linking, resolving and "known issue" run against Postgres in tests/integration/test_tickets.py.""" from datetime import datetime, timezone from types import SimpleNamespace as NS import pytest from quart import render_template from steward.core.formatting import status_info from steward.mcp.tools.tickets import _hosts, _links from steward.tickets import service as tk from steward.timeline import catalog from steward.timeline.config import Config NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc) AREAS = [list(a) for a in catalog.DEFAULT_AREAS] def _ticket(**kw): base = dict(id=12, title="Backups time out at night", body="Since the 20th.", area="storage", hosts=["nas-1"], subject=None, status="open", opened_at=NOW, opened_by="bryan", resolved_at=None, resolved_by=None, resolution=None, updated_at=NOW) return NS(**{**base, **kw}) async def test_a_ticket_is_checked_before_it_is_opened(): cfg = Config(areas=AREAS) f = await tk.fields(None, cfg, title=" Backups time out ", area="storage", body=" ", subject=" nas-1/restic ") assert f == {"title": "Backups time out", "area": "storage", "hosts": [], "body": None, "subject": "nas-1/restic"} for kw, said in ((dict(title=" ", area="storage"), "Give the ticket a title"), (dict(title="x" * 201, area="storage"), "200 characters"), (dict(title="x", area="plumbing"), "Pick an area: Service, Host OS"), (dict(title="x", area="storage", body="b" * 8001), "8000 characters")): with pytest.raises(ValueError, match=said): await tk.fields(None, cfg, **kw) async def test_a_ticket_links_only_to_an_alert_rule_or_an_anomaly(): with pytest.raises(ValueError, match="not 'monitor'"): await tk.check_links(None, [("monitor", "m1")]) def test_opening_resolving_and_reopening_go_on_the_timeline_and_the_audit_rows_stay_off(): for type_ in ("ticket.opened", "ticket.resolved", "ticket.reopened"): t = catalog.TYPES[type_] assert (t.kind, t.shown) == ("ticket", True), type_ for action in ("ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"): assert catalog.audit_type(action) is None, action def test_a_ticket_s_states_read_as_words(): assert status_info("open") == ("warn", "Open") assert status_info("resolved") == ("ok", "Resolved") assert status_info("known_issue") == ("info", "Known issue") def test_the_mcp_tools_take_hosts_and_ids_as_a_list_or_a_comma_list(): assert _hosts("web-1, ,db-1 ") == ["web-1", "db-1"] assert _hosts(None) == [] and _hosts(["web-1", ""]) == ["web-1"] assert _links("r1, r2", ["a1"]) == [("alert_rule", "r1"), ("alert_rule", "r2"), ("anomaly", "a1")] assert _links(None, None) == [] # ── Pages ─────────────────────────────────────────────────────────────────── async def test_the_list_shows_where_each_ticket_is_and_who_opened_it(app): tickets = [_ticket(), _ticket(id=9, title="Fan noisy", area="hardware", hosts=[], subject="rack fan", status="resolved", resolved_by="sam", resolved_at=NOW)] async with app.test_request_context("/tickets/"): from quart import session session["user_role"] = "viewer" page = await render_template("tickets/list.html", tickets=tickets, counts={12: 2}, status="all", host="", open_count=1, labels=dict(AREAS)) assert '#12 Backups time out at night' in page assert "2 comments" in page and ">Storage<" in page and "rack fan" in page assert 'nas-1' in page assert "1 open" in page and "sam, " in page assert "Open a ticket" not in page # a viewer async def test_an_operator_comments_and_resolves_and_a_viewer_only_reads(app): log = [NS(at=NOW, kind="opened", actor="bryan", body=None), NS(at=NOW, kind="edited", actor="bryan", body="title, linked alert rule"), NS(at=NOW, kind="comment", actor="sam", body="restic prune runs long")] links = [{"type": "alert_rule", "id": "r1", "label": "Alert rule Backup late", "url": "/alerts/history?rule=r1", "gone": False}, {"type": "anomaly", "id": "a1", "label": "An anomaly no longer kept", "url": None, "gone": True}] async with app.test_request_context("/tickets/12"): from quart import session session["user_role"] = "operator" mine = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage") resolved = await render_template("tickets/ticket.html", t=_ticket(status="resolved", resolved_by="sam", resolved_at=NOW, resolution="Moved prune to Sunday"), log=log, links=[], area_label="Storage") session["user_role"] = "viewer" viewer = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage") assert 'Alert rule Backup late' in mine assert 'An anomaly no longer kept' in mine assert "edited: title, linked alert rule" in mine and "restic prune runs long" in mine assert 'action="/tickets/12/comment"' in mine and 'formaction="/tickets/12/resolve"' in mine assert 'formaction="/tickets/12/reopen"' in resolved and "Moved prune to Sunday" in resolved assert "/tickets/12/comment" not in viewer and "/tickets/12/edit" not in viewer async def test_a_page_s_card_lists_its_open_tickets_and_offers_to_open_one(app): async with app.test_request_context("/tickets/card"): from quart import session session["user_role"] = "operator" card = await render_template("tickets/_card.html", tickets=[_ticket()], host="nas-1", subject="", back="/hosts/h1") empty = await render_template("tickets/_card.html", tickets=[], host="", subject="nas-1/restic", back="/hosts/h1") session["user_role"] = "viewer" nothing = await render_template("tickets/_card.html", tickets=[], host="nas-1", subject="", back="/") assert "Known issues (1 open)" in card and '' in card assert 'href="/tickets/new?host=nas-1&back=%2Fhosts%2Fh1"' in card assert "No open tickets." in empty and "subject=nas-1%2Frestic" in empty assert "