"""Unit tests for the Finding schema + thread-metadata helpers.""" from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, patch import pytest from agent.reviewer_findings import ( SEVERITY_ORDER, Finding, append_finding, filter_findings_for_publish, list_findings, mutate_findings, new_finding, new_finding_id, replace_findings, resolve_review_head_sha, set_reviewer_thread_metadata, update_finding_fields, ) def _f(**overrides: Any) -> Finding: base = new_finding( severity="high", confidence="high", category="correctness", file="foo.py", start_line=10, end_line=10, description="boom", sha="abc123", ) base.update(overrides) # type: ignore[arg-type] return base def test_new_finding_id_format() -> None: fid = new_finding_id() assert fid.startswith("f_") assert len(fid) == len("f_") + 10 def test_new_finding_defaults() -> None: finding = _f() assert finding["status"] == "open" assert finding["side"] == "RIGHT" assert finding["first_seen_sha"] == "abc123" assert finding["last_confirmed_sha"] == "abc123" assert finding["github_review_id"] is None assert finding["github_review_comment_id"] is None assert finding["github_review_comment_ids"] == [] assert finding["github_review_thread_id"] is None assert finding["github_review_thread_ids"] == [] assert finding["github_review_run_id"] is None assert finding["github_thread_resolved"] is False assert finding["github_resolved_thread_ids"] == [] assert finding["last_human_reply_at"] is None assert finding["resolution_note"] is None assert finding["suggestion"] is None def test_severity_order_monotonic() -> None: assert ( SEVERITY_ORDER["low"] < SEVERITY_ORDER["medium"] < SEVERITY_ORDER["high"] < SEVERITY_ORDER["critical"] ) def test_filter_findings_for_publish_drops_below_threshold_and_resolved() -> None: findings = [ _f(id="f_a", severity="high", file="a.py", start_line=1, end_line=1), _f(id="f_b", severity="low", file="b.py"), _f(id="f_c", severity="critical", file="c.py", start_line=2, end_line=2), _f(id="f_d", severity="high", file="d.py", status="resolved"), ] surfaced = filter_findings_for_publish(findings, severity_threshold="medium", cap=10) assert [f["id"] for f in surfaced] == ["f_c", "f_a"] def test_filter_findings_for_publish_caps_results() -> None: findings = [_f(id=f"f_{i}", severity="high", file=f"f{i}.py") for i in range(20)] surfaced = filter_findings_for_publish(findings, severity_threshold="medium", cap=5) assert len(surfaced) == 5 @pytest.mark.asyncio async def test_list_findings_returns_empty_on_missing_metadata() -> None: fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): findings = await list_findings("tid") assert findings == [] @pytest.mark.asyncio async def test_list_findings_coerces_bad_entries() -> None: fake_client = AsyncMock() fake_client.threads.get.return_value = { "metadata": { "findings": [ {"id": "f_ok", "severity": "high", "file": "x.py"}, {"missing_id": True}, "not-a-dict", ] } } with patch("agent.reviewer_findings.get_client", return_value=fake_client): findings = await list_findings("tid") assert [f["id"] for f in findings] == ["f_ok"] @pytest.mark.asyncio async def test_replace_findings_calls_threads_update() -> None: fake_client = AsyncMock() findings = [_f(id="f_x")] with patch("agent.reviewer_findings.get_client", return_value=fake_client): await replace_findings("tid", findings) fake_client.threads.update.assert_awaited_once_with( thread_id="tid", metadata={"findings": findings} ) @pytest.mark.asyncio async def test_append_finding_appends_to_existing_list() -> None: existing = _f(id="f_a") new = _f(id="f_b") fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"findings": [existing]}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): result = await append_finding("tid", new) assert result["id"] == "f_b" args = fake_client.threads.update.await_args persisted = args.kwargs["metadata"]["findings"] assert [f["id"] for f in persisted] == ["f_a", "f_b"] @pytest.mark.asyncio async def test_mutate_findings_reads_latest_before_mutating() -> None: """mutate_findings must operate on the freshest persisted list, not a stale snapshot — the mutator receives whatever ``list_findings`` returns now.""" latest = [_f(id="f_fresh")] fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"findings": latest}} seen: list[str] = [] def _mutator(findings: list[Finding]) -> bool: seen.extend(f["id"] for f in findings) findings[0]["status"] = "resolved" return True with patch("agent.reviewer_findings.get_client", return_value=fake_client): result = await mutate_findings("tid", _mutator) assert seen == ["f_fresh"] assert result[0]["status"] == "resolved" fake_client.threads.update.assert_awaited_once() @pytest.mark.asyncio async def test_mutate_findings_skips_write_when_unchanged() -> None: """A no-op mutation must NOT persist, so it can never clobber a concurrent update that landed between the read and a would-be write.""" fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"findings": [_f(id="f_a")]}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): await mutate_findings("tid", lambda _findings: False) fake_client.threads.update.assert_not_called() @pytest.mark.asyncio async def test_update_finding_fields_mutates_only_target() -> None: a = _f(id="f_a", description="orig-a") b = _f(id="f_b", description="orig-b") fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"findings": [a, b]}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): updated = await update_finding_fields("tid", "f_b", {"status": "resolved"}) assert updated is not None assert updated["status"] == "resolved" persisted = fake_client.threads.update.await_args.kwargs["metadata"]["findings"] by_id = {f["id"]: f for f in persisted} assert by_id["f_a"]["status"] == "open" assert by_id["f_b"]["status"] == "resolved" @pytest.mark.asyncio async def test_update_finding_fields_returns_none_for_unknown_id() -> None: fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"findings": [_f(id="f_a")]}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): result = await update_finding_fields("tid", "f_missing", {"status": "resolved"}) assert result is None fake_client.threads.update.assert_not_called() @pytest.mark.asyncio async def test_set_reviewer_thread_metadata_includes_kind() -> None: fake_client = AsyncMock() with patch("agent.reviewer_findings.get_client", return_value=fake_client): await set_reviewer_thread_metadata("tid", watch=True, last_reviewed_sha="sha") metadata = fake_client.threads.update.await_args.kwargs["metadata"] assert metadata["kind"] == "reviewer" assert metadata["watch"] is True assert metadata["last_reviewed_sha"] == "sha" assert "pr" not in metadata assert "findings" not in metadata @pytest.mark.asyncio async def test_set_reviewer_thread_metadata_persists_head_sha() -> None: fake_client = AsyncMock() with patch("agent.reviewer_findings.get_client", return_value=fake_client): await set_reviewer_thread_metadata("tid", head_sha="newhead") metadata = fake_client.threads.update.await_args.kwargs["metadata"] assert metadata["head_sha"] == "newhead" @pytest.mark.asyncio async def test_resolve_review_head_sha_prefers_metadata_over_config() -> None: """A mid-run push records the live head in thread metadata; it must win over the stale head frozen in the run's config.""" fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {"head_sha": "metahead"}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): head = await resolve_review_head_sha("tid", {"head_sha": "confighead"}) assert head == "metahead" @pytest.mark.asyncio async def test_resolve_review_head_sha_falls_back_to_config_when_metadata_empty() -> None: fake_client = AsyncMock() fake_client.threads.get.return_value = {"metadata": {}} with patch("agent.reviewer_findings.get_client", return_value=fake_client): head = await resolve_review_head_sha("tid", {"head_sha": "confighead"}) assert head == "confighead" @pytest.mark.asyncio async def test_resolve_review_head_sha_falls_back_without_thread_id() -> None: fake_client = AsyncMock() with patch("agent.reviewer_findings.get_client", return_value=fake_client): head = await resolve_review_head_sha("", {"head_sha": "confighead"}) assert head == "confighead" fake_client.threads.get.assert_not_called() @pytest.mark.asyncio async def test_replace_findings_raises_domain_error_when_thread_missing() -> None: import httpx from langgraph_sdk.errors import NotFoundError from agent.reviewer_findings import ReviewerThreadMissingError not_found = NotFoundError( "thread tid not found", response=httpx.Response(404, request=httpx.Request("PATCH", "http://x")), body=None, ) fake_client = AsyncMock() fake_client.threads.update.side_effect = not_found with patch("agent.reviewer_findings.get_client", return_value=fake_client): with pytest.raises(ReviewerThreadMissingError) as excinfo: await replace_findings("tid", [_f(id="f_a")]) assert excinfo.value.thread_id == "tid" assert "not found" in str(excinfo.value) def _not_found(method: str = "GET") -> Exception: import httpx from langgraph_sdk.errors import NotFoundError return NotFoundError( "thread tid not found", response=httpx.Response(404, request=httpx.Request(method, "http://x")), body=None, ) @pytest.mark.asyncio async def test_get_thread_metadata_raises_domain_error_when_thread_missing() -> None: """A missing thread must surface as ReviewerThreadMissingError, not be swallowed into ``{}`` — that produced misleading tool results like "No finding found" instead of the do-not-retry contract.""" from agent.reviewer_findings import ReviewerThreadMissingError, get_thread_metadata fake_client = AsyncMock() fake_client.threads.get.side_effect = _not_found() with patch("agent.reviewer_findings.get_client", return_value=fake_client): with pytest.raises(ReviewerThreadMissingError): await get_thread_metadata("tid") @pytest.mark.asyncio async def test_get_thread_metadata_still_degrades_on_other_failures() -> None: from agent.reviewer_findings import get_thread_metadata fake_client = AsyncMock() fake_client.threads.get.side_effect = RuntimeError("transient") with patch("agent.reviewer_findings.get_client", return_value=fake_client): assert await get_thread_metadata("tid") == {} @pytest.mark.asyncio async def test_set_reviewer_thread_metadata_raises_domain_error_when_thread_missing() -> None: from agent.reviewer_findings import ( ReviewerThreadMissingError, set_reviewer_thread_metadata, ) fake_client = AsyncMock() fake_client.threads.update.side_effect = _not_found("PATCH") with patch("agent.reviewer_findings.get_client", return_value=fake_client): with pytest.raises(ReviewerThreadMissingError): await set_reviewer_thread_metadata("tid", last_reviewed_sha="sha")