from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, call, patch import pytest def _make_dispatch(**overrides): defaults = { "id": "dispatch-001", "governance_event_id": "event-001", "event_type": "knowledge_degradation", "executor_type": "hermes_kb_growth_healthcheck", "decision_context": { "workflow": { "impact": { "stale_count": 1450, "total_count": 1867, "stale_ratio": 0.777, "threshold": 0.2, "stale_days": 7, }, "stage_by_dispatch_status": { "pending": "queued_kb_healthcheck", "executing": "draft_km_updates", "succeeded": "stale_ratio_recheck", }, }, "ownership": { "lead_agent": "Hermes", "human_owner": "KM owner / SRE owner", }, }, } defaults.update(overrides) return SimpleNamespace(**defaults) def test_km_review_payload_is_review_only(): """Hermes worker 只能產生 REVIEW 草稿,不可直接 approve/publish KM。""" from src.jobs.hermes_kb_growth_worker import _build_km_review_entry_payload from src.models.knowledge import EntrySource, EntryStatus, EntryType payload = _build_km_review_entry_payload(_make_dispatch()) assert payload.entry_type == EntryType.AUTO_RUNBOOK assert payload.source == EntrySource.AI_EXTRACTED assert payload.status == EntryStatus.REVIEW assert "agent:Hermes" in payload.tags assert "needs_owner_review" in payload.tags assert "dispatch:dispatch-001" in payload.tags assert "governance_event:event-001" in payload.tags assert "writes_km_without_approval=false" in payload.content def test_review_context_keeps_succeeded_at_owner_review_stage(): """dispatch succeeded 代表 worker 完成草稿,不代表 KM 劣化已解決。""" from src.jobs.hermes_kb_growth_worker import _build_review_context context = _make_dispatch().decision_context updated = _build_review_context( context, dispatch_id="dispatch-001", governance_event_id="event-001", km_entry_id="km-001", ) workflow = updated["workflow"] assert workflow["current_stage"] == "waiting_owner_review" assert workflow["stage_by_dispatch_status"]["succeeded"] == "waiting_owner_review" assert workflow["kb_draft_entry_id"] == "km-001" assert workflow["writes_km_without_approval"] is False assert updated["next_action"] == "owner_review_km_draft" assert updated["worker_result"]["status"] == "draft_created" def test_knowledge_tag_filter_is_json_column_compatible(): """knowledge_entries.tags 是 JSON 欄位時不可使用 json @> text。""" from src.repositories.knowledge_repository import _json_string_array_has_tag compiled = str(_json_string_array_has_tag("dispatch:abc-123")) assert "CAST(knowledge_entries.tags AS VARCHAR)" in compiled assert "@>" not in compiled @pytest.mark.asyncio async def test_run_once_advances_pending_dispatch_to_review_draft(): """pending dispatch 應推進到 succeeded + waiting_owner_review read model。""" from src.jobs.hermes_kb_growth_worker import run_hermes_kb_growth_once pending = _make_dispatch() dispatched = _make_dispatch() executing = _make_dispatch() km_entry = SimpleNamespace(id="km-001") transition_mock = AsyncMock(side_effect=[dispatched, executing, _make_dispatch()]) with ( patch( "src.jobs.hermes_kb_growth_worker.list_pending_by_executor", new=AsyncMock(return_value=[pending]), ) as list_pending, patch( "src.jobs.hermes_kb_growth_worker.transition_status", new=transition_mock, ), patch( "src.jobs.hermes_kb_growth_worker._create_or_get_km_review_draft", new=AsyncMock(return_value=km_entry), ) as create_draft, patch( "src.jobs.hermes_kb_growth_worker.update_decision_context", new=AsyncMock(), ) as update_context, ): result = await run_hermes_kb_growth_once(limit=5) assert result == {"scanned": 1, "processed": 1, "skipped": 0, "failed": 0} list_pending.assert_awaited_once_with("hermes_kb_growth_healthcheck", limit=5) create_draft.assert_awaited_once_with(executing) transition_mock.assert_has_awaits([ call("dispatch-001", "pending", "dispatched"), call("dispatch-001", "dispatched", "executing"), call("dispatch-001", "executing", "succeeded"), ]) update_context.assert_awaited_once() updated_context = update_context.call_args.args[1] assert updated_context["workflow"]["current_stage"] == "waiting_owner_review" assert updated_context["worker_result"]["km_draft_entry_id"] == "km-001"