"""Tests for #218 — paperclip_client.escalate_issue (loop-safe chair escalation). Verifies the primitive that replaces the fragile manual PATCH dance: - invalid severity is rejected before any DB work. - the happy path performs ONE atomic human-owned transition (``status='in_review'``, ``assignee_user_id=CHAIM_USER_ID``) and records a ``author_type='system'`` severity note carrying the reason. - **no wakeup / REST call is issued** — escalation hands off to the human, it must never re-invoke an agent (that is what caused the recovery loops). - a missing issue reports ``ok:False`` and writes no comment. Uses a fake asyncpg connection — never touches the live Paperclip DB. """ from __future__ import annotations import asyncio import os import sys from pathlib import Path import pytest os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip") sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/ pc = pytest.importorskip("paperclip_client", reason="web deps unavailable") class _FakeTxn: async def __aenter__(self): return None async def __aexit__(self, *exc): return False class _FakeConn: def __init__(self, row): self._row = row self.updates: list[tuple] = [] self.inserts: list[tuple] = [] self.closed = False def transaction(self): return _FakeTxn() async def fetchrow(self, query, *args): self.updates.append((query, args)) return self._row async def execute(self, query, *args): self.inserts.append((query, args)) async def close(self): self.closed = True @pytest.fixture def fake_db(monkeypatch): """Patch asyncpg.connect + guard that no wakeup/REST is issued.""" conns: list[_FakeConn] = [] row = {"id": "iss-uuid", "identifier": "CMP-141", "company_id": "cmp-uuid"} holder = {"row": row} async def _connect(_url): conn = _FakeConn(holder["row"]) conns.append(conn) return conn monkeypatch.setattr(pc.asyncpg, "connect", _connect) async def _forbidden(*a, **k): # any REST/wakeup would be a loop-risk raise AssertionError("escalate_issue must not issue a wakeup/REST call") monkeypatch.setattr(pc, "pc_request", _forbidden, raising=False) return {"conns": conns, "holder": holder} def test_invalid_severity_rejected_before_db(fake_db): result = asyncio.run(pc.escalate_issue("iss-1", "urgent", "boom")) assert result["ok"] is False assert "invalid severity" in result["error"] assert fake_db["conns"] == [] # never connected def test_happy_path_atomic_transition_and_system_note(fake_db): result = asyncio.run( pc.escalate_issue("iss-uuid", "high", "analyst wedged on protocol parse") ) assert result == { "ok": True, "id": "iss-uuid", "identifier": "CMP-141", "severity": "high", "status": "in_review", } conn = fake_db["conns"][0] # 1) one human-owned UPDATE with the chair user + in_review (update_sql, update_args) = conn.updates[0] assert "status='in_review'" in update_sql assert "assignee_agent_id=null" in update_sql assert update_args[0] == pc.CHAIM_USER_ID assert update_args[1] == "iss-uuid" # 2) a system-authored severity note carrying the reason (insert_sql, insert_args) = conn.inserts[0] assert "issue_comments" in insert_sql assert "'system'" in insert_sql assert "high" in insert_args[3] and "wedged on protocol parse" in insert_args[3] assert conn.closed is True def test_missing_issue_reports_not_found_and_writes_no_comment(fake_db): fake_db["holder"]["row"] = None result = asyncio.run(pc.escalate_issue("ghost", "medium", "nope")) assert result["ok"] is False and "not found" in result["error"] conn = fake_db["conns"][0] assert conn.inserts == [] # no comment on a non-existent issue