277 lines
8.3 KiB
Python
277 lines
8.3 KiB
Python
"""Unit tests for Phase 3 Notifications and Human-Attention Console (#648)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from webui.app import create_app
|
|
from webui.notifications import (
|
|
ATTENTION_HUMAN_REQUIRED,
|
|
ATTENTION_OPERATOR,
|
|
ATTENTION_ROUTINE,
|
|
CATEGORY_AUTH,
|
|
CATEGORY_BLOCKER,
|
|
CATEGORY_LEASE,
|
|
CATEGORY_SYSTEM,
|
|
CATEGORY_VALIDATION,
|
|
CATEGORY_WORKFLOW,
|
|
NotificationItem,
|
|
NotificationSnapshot,
|
|
classify_attention_event,
|
|
load_notifications_snapshot,
|
|
snapshot_to_dict,
|
|
)
|
|
from webui.notification_views import render_notifications_page
|
|
from webui.project_registry import load_registry
|
|
from webui.queue_loader import QueueItem, QueueSnapshot
|
|
from webui.lease_loader import CollisionWarning, LeaseSnapshot
|
|
from webui.system_health import DependencyProbe, SystemHealthSnapshot, VersionInfo, StaleRuntime
|
|
|
|
|
|
def test_classify_attention_event_rules():
|
|
# 1. Critical escalation boundaries -> human-required
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_AUTH, "Auth error", "Unauthorized access attempt", is_auth_failure=True
|
|
)
|
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
|
assert req_human is True
|
|
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_SYSTEM, "Hard stop", "Hard stop triggered", is_hard_stop=True
|
|
)
|
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
|
assert req_human is True
|
|
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_VALIDATION, "Validation Error", "Report validation failed", is_validation_failure=True
|
|
)
|
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
|
assert req_human is True
|
|
|
|
# 2. Operational issues -> operator
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_BLOCKER, "PR Blocked", "Merge conflict detected", is_blocker=True
|
|
)
|
|
assert att_cls == ATTENTION_OPERATOR
|
|
assert req_human is False
|
|
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_LEASE, "Lease Expired", "Session lease expired", is_stale=True
|
|
)
|
|
assert att_cls == ATTENTION_OPERATOR
|
|
assert req_human is False
|
|
|
|
# 3. Routine workflow transitions -> routine
|
|
att_cls, req_human = classify_attention_event(
|
|
CATEGORY_WORKFLOW, "PR Active", "PR in review"
|
|
)
|
|
assert att_cls == ATTENTION_ROUTINE
|
|
assert req_human is False
|
|
|
|
|
|
def test_notification_snapshot_aggregation():
|
|
reg = load_registry()
|
|
proj_id = reg.projects[0].id if reg.projects else "gitea-tools"
|
|
|
|
mock_queue = QueueSnapshot(
|
|
project_id=proj_id,
|
|
repo_label="org/repo",
|
|
prs=(
|
|
QueueItem(
|
|
number=101,
|
|
title="Blocked PR",
|
|
badges=("blocked",),
|
|
extra={},
|
|
),
|
|
QueueItem(
|
|
number=102,
|
|
title="Normal PR",
|
|
badges=("in-review",),
|
|
extra={},
|
|
),
|
|
),
|
|
issues=(),
|
|
pr_pagination=None,
|
|
issue_pagination=None,
|
|
)
|
|
|
|
mock_leases = LeaseSnapshot(
|
|
project_id=proj_id,
|
|
repo_label="org/repo",
|
|
issue_lock=None,
|
|
claim_inventory={},
|
|
reviewer_leases=(
|
|
{
|
|
"pr_number": 101,
|
|
"status": "expired",
|
|
"is_expired": True,
|
|
},
|
|
),
|
|
duplicate_prs=(
|
|
CollisionWarning(
|
|
kind="duplicate_pr",
|
|
message="Multiple open PRs for issue #101",
|
|
issue_number=101,
|
|
pr_numbers=(101, 103),
|
|
),
|
|
),
|
|
duplicate_branches=(),
|
|
collision_history=(),
|
|
fetch_error=None,
|
|
)
|
|
|
|
mock_version = VersionInfo(
|
|
git_sha="abc1234",
|
|
git_describe="v1.0.0",
|
|
control_plane_schema_version=1,
|
|
python_version="3.11",
|
|
known=True,
|
|
)
|
|
|
|
mock_stale = StaleRuntime(
|
|
daemon_head="abc1234",
|
|
checkout_head="abc1234",
|
|
remote_head="abc1234",
|
|
stale=False,
|
|
determinable=True,
|
|
mutation_safe=True,
|
|
reasons=(),
|
|
)
|
|
|
|
mock_health = SystemHealthSnapshot(
|
|
status="degraded",
|
|
ready=False,
|
|
readiness_complete=True,
|
|
readiness_reasons=("Auth failure",),
|
|
service="webui",
|
|
mode="test",
|
|
version=mock_version,
|
|
started_at="2026-07-25T00:00:00Z",
|
|
uptime_seconds=100.0,
|
|
timestamp="2026-07-25T00:00:00Z",
|
|
deep_probes_requested=True,
|
|
dependencies=(
|
|
DependencyProbe(
|
|
name="auth_service",
|
|
kind="auth",
|
|
status="unauthorized",
|
|
detail="Token expired",
|
|
required=True,
|
|
),
|
|
),
|
|
mcp_namespaces=(),
|
|
stale_runtime=mock_stale,
|
|
probe_errors=(),
|
|
)
|
|
|
|
snapshot = load_notifications_snapshot(
|
|
proj_id,
|
|
load_queue=lambda _id: mock_queue,
|
|
load_leases=lambda **_kwargs: mock_leases,
|
|
load_health=lambda **_kwargs: mock_health,
|
|
)
|
|
|
|
assert snapshot.project_id == proj_id
|
|
assert snapshot.total_count == 5
|
|
assert snapshot.human_required_count >= 1 # auth probe failure
|
|
assert snapshot.operator_count >= 3 # blocked PR + expired lease + duplicate PR collision
|
|
assert snapshot.routine_count >= 1 # normal PR
|
|
|
|
# Inbox items should include operator and human-required items only
|
|
inbox_classes = {item.attention_class for item in snapshot.inbox_items}
|
|
assert ATTENTION_ROUTINE not in inbox_classes
|
|
assert ATTENTION_OPERATOR in inbox_classes
|
|
assert ATTENTION_HUMAN_REQUIRED in inbox_classes
|
|
|
|
|
|
def test_snapshot_to_dict_and_redaction():
|
|
item = NotificationItem(
|
|
id="notif-1",
|
|
attention_class=ATTENTION_HUMAN_REQUIRED,
|
|
category=CATEGORY_AUTH,
|
|
title="Auth Error",
|
|
summary="Failed auth header: Bearer secret_token_12345",
|
|
work_kind="system",
|
|
work_number=None,
|
|
project_id="test-proj",
|
|
repo_label="org/repo",
|
|
created_at="2026-07-25T16:00:00Z",
|
|
requires_human=True,
|
|
)
|
|
snap = NotificationSnapshot(
|
|
project_id="test-proj",
|
|
repo_label="org/repo",
|
|
items=(item,),
|
|
human_required_count=1,
|
|
operator_count=0,
|
|
routine_count=0,
|
|
total_count=1,
|
|
)
|
|
|
|
data = snapshot_to_dict(snap)
|
|
assert data["project_id"] == "test-proj"
|
|
assert data["human_required_count"] == 1
|
|
assert len(data["inbox_items"]) == 1
|
|
|
|
# Redaction test
|
|
summary = data["inbox_items"][0]["summary"]
|
|
assert "secret_token_12345" not in summary
|
|
assert "<redacted>" in summary or "Bearer" in summary
|
|
|
|
|
|
def test_notifications_html_views():
|
|
item = NotificationItem(
|
|
id="notif-1",
|
|
attention_class=ATTENTION_HUMAN_REQUIRED,
|
|
category=CATEGORY_AUTH,
|
|
title="Critical Auth Failure",
|
|
summary="Auth failure details",
|
|
work_kind="issue",
|
|
work_number=42,
|
|
project_id="test-proj",
|
|
repo_label="org/repo",
|
|
created_at="2026-07-25T16:00:00Z",
|
|
requires_human=True,
|
|
)
|
|
snap = NotificationSnapshot(
|
|
project_id="test-proj",
|
|
repo_label="org/repo",
|
|
items=(item,),
|
|
human_required_count=1,
|
|
operator_count=0,
|
|
routine_count=0,
|
|
total_count=1,
|
|
)
|
|
|
|
html = render_notifications_page(snap, filter_class="inbox")
|
|
assert "Notifications & Attention Inbox" in html or "Notifications & Attention Inbox" in html
|
|
assert "Critical Auth Failure" in html
|
|
assert "HUMAN REQUIRED" in html
|
|
assert "Human Required" in html
|
|
|
|
|
|
def test_notifications_app_routes():
|
|
app = create_app()
|
|
client = TestClient(app)
|
|
|
|
# 1. HTML Route
|
|
res = client.get("/notifications")
|
|
assert res.status_code == 200
|
|
assert "Notifications" in res.text
|
|
assert "Attention Inbox" in res.text
|
|
|
|
# 2. API Route /api/v1/notifications
|
|
res_api = client.get("/api/v1/notifications")
|
|
assert res_api.status_code == 200
|
|
json_data = res_api.json()
|
|
assert "human_required_count" in json_data
|
|
assert "operator_count" in json_data
|
|
assert "routine_count" in json_data
|
|
assert "inbox_items" in json_data
|
|
|
|
# 3. Compatibility Alias /api/notifications
|
|
res_alias = client.get("/api/notifications")
|
|
assert res_alias.status_code == 200
|
|
assert res_alias.json()["project_id"] == json_data["project_id"]
|