from __future__ import annotations from datetime import UTC, datetime import pytest from api.services import recently_on_scope as ros from api.services.markitect_templates import MarkitectUnavailable async def _create_domain(client, slug="digest", name="Digest Domain"): response = await client.post("/domains/", json={"slug": slug, "name": name}) assert response.status_code == 201, response.text return response.json() async def _create_topic(client, domain_slug="digest", slug="digest-topic", title="Digest Topic"): response = await client.post("/topics/", json={"slug": slug, "title": title, "domain": domain_slug}) assert response.status_code == 201, response.text return response.json() async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"): response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title}) assert response.status_code == 201, response.text return response.json() async def _create_task(client, workstream_id, title="Digest task"): response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) assert response.status_code == 201, response.text return response.json() @pytest.fixture def fake_markitect(monkeypatch): def _inspect(_template_path): return {"valid": True, "variables": []} def _render(_template_path, data): counts = data["source_counts"] return f"""--- type: recently_on_scope_digest domain_slug: "{data["domain"]["slug"]}" domain_name: "{data["domain"]["name"]}" range: "{data["window"]["range"]}" since: "{data["window"]["since"]}" until: "{data["window"]["until"]}" generated_at: "{data["generated_at"]}" template_version: "{data["template_version"]}" source_counts: progress_events: {counts["progress_events"]} decisions: {counts["decisions"]} workstreams: {counts["workstreams"]} tasks: {counts["tasks"]} repos: {counts["repos"]} attention_items: {counts["attention_items"]} --- # RecentlyOnScope - {data["domain"]["name"]} {data["progress_section"]} """ monkeypatch.setattr(ros, "inspect_markdown_template", _inspect) monkeypatch.setattr(ros, "render_markdown_template", _render) def test_resolve_window_defaults_to_one_hour(): now = datetime(2026, 5, 22, 12, 0, tzinfo=UTC) window = ros.resolve_window(now=now) assert window.range == "1h" assert window.since == datetime(2026, 5, 22, 11, 0, tzinfo=UTC) assert window.until == now assert ros.report_id_for(window) == "20260522T120000Z--1h" def test_resolve_window_exact_range_id(): since = datetime(2026, 5, 22, 10, 30, tzinfo=UTC) until = datetime(2026, 5, 22, 12, 0, tzinfo=UTC) window = ros.resolve_window("6h", since=since, until=until) assert window.exact is True assert ros.report_id_for(window) == "20260522T103000Z--20260522T120000Z" def test_resolve_window_rejects_ambiguous_duration(): with pytest.raises(ValueError): ros.resolve_window("hour") class TestRecentlyOnScopeRoutes: async def test_generate_list_and_read_digest(self, client, tmp_path, monkeypatch, fake_markitect): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) await _create_domain(client) topic = await _create_topic(client) workstream = await _create_workstream(client, topic["id"]) task = await _create_task(client, workstream["id"], title="Build digest source") await client.patch( f"/tasks/{task['id']}", json={"needs_human": True, "intervention_note": "Review the generated summary."}, ) progress = await client.post( "/progress/", json={ "topic_id": topic["id"], "workstream_id": workstream["id"], "task_id": task["id"], "event_type": "note", "summary": "Built digest source", "author": "codex", }, ) assert progress.status_code == 201, progress.text await _create_domain(client, slug="elsewhere", name="Elsewhere") other_topic = await _create_topic(client, domain_slug="elsewhere", slug="other-topic") other_progress = await client.post( "/progress/", json={"topic_id": other_topic["id"], "event_type": "note", "summary": "Do not include"}, ) assert other_progress.status_code == 201, other_progress.text response = await client.post("/domains/digest/recently-on-scope/", json={"range": "1d"}) assert response.status_code == 201, response.text body = response.json() assert body["domain_slug"] == "digest" assert body["range"] == "1d" assert body["source_counts"]["progress_events"] == 1 assert body["source_counts"]["attention_items"] == 1 assert "Built digest source" in body["markdown"] listed = await client.get("/domains/digest/recently-on-scope/") assert listed.status_code == 200 reports = listed.json() assert len(reports) == 1 assert reports[0]["id"] == body["id"] markdown = await client.get(f"/domains/digest/recently-on-scope/{body['id']}") assert markdown.status_code == 200 assert markdown.headers["content-type"].startswith("text/markdown") assert "RecentlyOnScope - Digest Domain" in markdown.text async def test_generate_rejects_bad_range(self, client, tmp_path, monkeypatch, fake_markitect): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) await _create_domain(client) response = await client.post("/domains/digest/recently-on-scope/", json={"range": "yesterday"}) assert response.status_code == 422 async def test_generate_reports_missing_markitect(self, client, tmp_path, monkeypatch): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) def _missing_markitect(_template_path): raise MarkitectUnavailable("MarkiTect unavailable") monkeypatch.setattr(ros, "inspect_markdown_template", _missing_markitect) await _create_domain(client) response = await client.post("/domains/digest/recently-on-scope/", json={"range": "1h"}) assert response.status_code == 503 async def test_missing_report_returns_404(self, client, tmp_path, monkeypatch): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) response = await client.get("/domains/digest/recently-on-scope/20260522T120000Z--1h") assert response.status_code == 404 async def test_hourly_batch_generates_only_domains_with_activity( self, client, tmp_path, monkeypatch, fake_markitect, ): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) await _create_domain(client) topic = await _create_topic(client) workstream = await _create_workstream(client, topic["id"]) task = await _create_task(client, workstream["id"], title="Batch source") progress = await client.post( "/progress/", json={ "topic_id": topic["id"], "workstream_id": workstream["id"], "task_id": task["id"], "event_type": "note", "summary": "Batch source changed", }, ) assert progress.status_code == 201, progress.text await _create_domain(client, slug="quiet", name="Quiet Domain") response = await client.post("/recently-on-scope/hourly", json={"range": "1d"}) assert response.status_code == 201, response.text body = response.json() assert [item["domain_slug"] for item in body["generated"]] == ["digest"] assert [item["domain_slug"] for item in body["skipped"]] == ["quiet"] assert body["failed"] == [] assert body["generated"][0]["source_counts"]["progress_events"] == 1 assert body["progress_event_id"] is not None events = await client.get("/progress/", params={"event_type": "recently_on_scope_hourly"}) assert events.status_code == 200, events.text assert events.json()[0]["detail"]["generated"][0]["domain_slug"] == "digest" async def test_hourly_batch_reports_no_active_domains( self, client, tmp_path, monkeypatch, fake_markitect, ): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) await _create_domain(client, slug="quiet", name="Quiet Domain") response = await client.post("/recently-on-scope/hourly", json={"range": "1d"}) assert response.status_code == 201, response.text body = response.json() assert body["generated"] == [] assert [item["domain_slug"] for item in body["skipped"]] == ["quiet"] assert body["failed"] == [] async def test_hourly_batch_is_idempotent_for_exact_window( self, client, tmp_path, monkeypatch, fake_markitect, ): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) await _create_domain(client) topic = await _create_topic(client) workstream = await _create_workstream(client, topic["id"]) await _create_task(client, workstream["id"], title="Idempotent source") payload = { "range": "1h", "since": "2000-01-01T00:00:00Z", "until": "2100-01-01T00:00:00Z", } first = await client.post("/recently-on-scope/hourly", json=payload) second = await client.post("/recently-on-scope/hourly", json=payload) assert first.status_code == 201, first.text assert second.status_code == 201, second.text assert first.json()["generated"][0]["id"] == "20000101T000000Z--21000101T000000Z" listed = await client.get("/domains/digest/recently-on-scope/") assert listed.status_code == 200 assert [item["id"] for item in listed.json()] == ["20000101T000000Z--21000101T000000Z"] async def test_hourly_batch_continues_after_domain_failure( self, client, tmp_path, monkeypatch, fake_markitect, ): monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) original_render = ros._render_report_from_data def flaky_render(data, window): if data["domain"]["slug"] == "broken": raise RuntimeError("render failed") return original_render(data, window) monkeypatch.setattr(ros, "_render_report_from_data", flaky_render) await _create_domain(client, slug="broken", name="Broken Domain") broken_topic = await _create_topic(client, domain_slug="broken", slug="broken-topic") broken_workstream = await _create_workstream(client, broken_topic["id"], slug="broken-ws") await _create_task(client, broken_workstream["id"], title="Broken source") await _create_domain(client, slug="good", name="Good Domain") good_topic = await _create_topic(client, domain_slug="good", slug="good-topic") good_workstream = await _create_workstream(client, good_topic["id"], slug="good-ws") await _create_task(client, good_workstream["id"], title="Good source") response = await client.post("/recently-on-scope/hourly", json={"range": "1d"}) assert response.status_code == 201, response.text body = response.json() assert [item["domain_slug"] for item in body["generated"]] == ["good"] assert [item["domain_slug"] for item in body["failed"]] == ["broken"] assert body["failed"][0]["error"] == "render failed"