""" Tests for runbook artifacts renderer (PR4): - render_release_evidence: creates file, contains key sections - render_post_review: creates file, contains TODO markers + auto-filled fields - API endpoints: POST /evidence, /post_review return 200 with path/bytes """ from __future__ import annotations import asyncio import json import time from pathlib import Path from unittest.mock import AsyncMock, patch import pytest # ── Fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture def tmp_rehearsal_docs(tmp_path): """tmp_path/docs/runbook with rehearsal checklist.""" docs_root = tmp_path / "docs" runbook_dir = docs_root / "runbook" runbook_dir.mkdir(parents=True) (runbook_dir / "rehearsal-v1-30min-checklist.md").write_text( "# Rehearsal v1 — 30-minute execution plan\n\n" "## 0–5 min — Preflight\n\nRun preflight.\n\n" "## 5–10 min — Restart\n\nRestart service.", encoding="utf-8", ) return docs_root def _fake_http_proc(): """Fake subprocess for script steps: exit_code=0, stdout=PASS.""" from unittest.mock import MagicMock proc = MagicMock() proc.returncode = 0 proc.kill = MagicMock() proc.wait = AsyncMock(return_value=None) async def _communicate(): return b"PASS\n", b"" proc.communicate = _communicate return proc async def _create_and_run_all(tmp_path, tmp_docs): """Create run, execute all auto steps (http_check + script via mocks), return run_id.""" import app.docs_index as docs_index_mod import app.runbook_runner as runner_mod import app.db as db_mod import os os.environ["SOFIIA_DATA_DIR"] = str(tmp_path / "sofiia-data") await docs_index_mod.rebuild_index(tmp_docs) out = await runner_mod.create_run( "runbook/rehearsal-v1-30min-checklist.md", operator_id="test-op", node_id="NODA2", sofiia_url="http://127.0.0.1:8002", ) run_id = out["run_id"] steps_total = out["steps_total"] # Execute all auto steps (http_check + script) with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_http: async def fake_get(url, **kwargs): if "audit" in str(url): return type("R", (), {"status_code": 401})() return type("R", (), {"status_code": 200})() mock_http.side_effect = fake_get with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec: mock_exec.return_value = _fake_http_proc() for _ in range(steps_total): step = await runner_mod.next_step(run_id, operator_id="test-op") if step is None: break if step.get("type") == "manual": await runner_mod.complete_step(run_id, step["step_index"], status="ok", notes="done") return run_id # ── Unit tests: renderer ────────────────────────────────────────────────────── def test_render_release_evidence_file_created(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """render_release_evidence creates a file at the expected path.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_release_evidence(run_id)) assert "evidence_path" in out assert "bytes" in out assert out["bytes"] > 0 path = Path(out["evidence_path"]) assert path.exists(), f"Expected file at {path}" def test_render_release_evidence_contains_key_sections(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """Evidence file contains Release metadata, Preflight, Smoke, and Sign-off sections.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_release_evidence(run_id)) content = Path(out["evidence_path"]).read_text(encoding="utf-8") assert "Release metadata" in content assert "Preflight" in content assert "Smoke" in content assert "Sign-off" in content assert run_id in content def test_render_release_evidence_includes_step_table(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """Evidence contains a steps summary table with action_types.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_release_evidence(run_id)) content = Path(out["evidence_path"]).read_text(encoding="utf-8") assert "http_check" in content or "script" in content or "manual" in content assert "All steps summary" in content def test_render_post_review_file_created(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """render_post_review creates post_review.md.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_post_review(run_id)) assert "path" in out path = Path(out["path"]) assert path.exists() assert out["bytes"] > 0 def test_render_post_review_has_todo_markers(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """Post-review has [TODO] markers for fields requiring manual input.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_post_review(run_id)) content = Path(out["path"]).read_text(encoding="utf-8") assert "[TODO]" in content assert "Release Metadata" in content assert "Preflight Outcome" in content assert "Smoke Results" in content assert "Action Items" in content def test_render_post_review_autofills_operator(sofiia_module, tmp_path, tmp_rehearsal_docs, monkeypatch): """Post-review auto-fills operator and run_id from DB.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) out = loop.run_until_complete(art_mod.render_post_review(run_id)) content = Path(out["path"]).read_text(encoding="utf-8") assert "test-op" in content assert run_id in content def test_render_evidence_404_for_unknown_run(sofiia_module, tmp_path, monkeypatch): """render_release_evidence raises ValueError for unknown run_id.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod import app.db as _db async def run(): await _db.init_db() return await art_mod.render_release_evidence("00000000-0000-0000-0000-000000000000") with pytest.raises(ValueError, match="Run not found"): loop.run_until_complete(run()) # ── API endpoint tests ──────────────────────────────────────────────────────── def test_evidence_endpoint_404_for_unknown_run(sofiia_client): """POST /evidence returns 404 for unknown run_id.""" r = sofiia_client.post("/api/runbooks/runs/00000000-0000-0000-0000-000000000000/evidence") assert r.status_code == 404 def test_post_review_endpoint_404_for_unknown_run(sofiia_client): """POST /post_review returns 404 for unknown run_id.""" r = sofiia_client.post("/api/runbooks/runs/00000000-0000-0000-0000-000000000000/post_review") assert r.status_code == 404 def test_evidence_endpoint_success(sofiia_module, sofiia_client, tmp_path, tmp_rehearsal_docs, monkeypatch): """POST /evidence returns 200 with evidence_path and bytes.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/evidence") assert r.status_code == 200, r.text data = r.json() assert "evidence_path" in data assert data.get("bytes", 0) > 0 assert run_id in data.get("evidence_path", "") def test_post_review_endpoint_success(sofiia_module, sofiia_client, tmp_path, tmp_rehearsal_docs, monkeypatch): """POST /post_review returns 200 with path and bytes.""" monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() import app.runbook_artifacts as art_mod run_id = loop.run_until_complete(_create_and_run_all(tmp_path, tmp_rehearsal_docs)) r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/post_review") assert r.status_code == 200, r.text data = r.json() assert "path" in data assert data.get("bytes", 0) > 0