from __future__ import annotations from pathlib import Path from fastapi.testclient import TestClient from civicswarm.api import create_app from civicswarm.service import CivicSwarmService def build_service(tmp_path: Path) -> CivicSwarmService: return CivicSwarmService.from_path(tmp_path / "civicswarm.sqlite") def seed_service(service: CivicSwarmService) -> int: proposal = service.create_proposal( city="Springfield", title="School street safety", body=( "Add protected bike lanes near schools.\n" "Install safer crossings for pedestrians.\n" "Preserve bus access for students and elders." ), geography="ward-3", tags=["transport", "schools", "safety"], ) service.register_resident("resident-a", geography="ward-3", interests=["transport", "safety"], lived_experience=["parent"], languages=["en"]) service.register_resident("resident-b", geography="ward-7", interests=["schools"], lived_experience=["bus rider"], languages=["es", "en"]) service.register_resident("resident-c", geography="ward-3", interests=["parks"], lived_experience=["elder"], languages=["en"]) service.add_comment(proposal["id"], "I support safer crossings for children and elders.", resident_key="resident-a", geography="ward-3") service.add_comment(proposal["id"], "We need bus access and safer sidewalks.", resident_key="resident-b", geography="ward-7") service.add_comment(proposal["id"], "I worry about delayed bus service.", resident_key="resident-c", geography="ward-3") service.submit_preference(proposal["id"], "resident-a", 0.9) service.submit_preference(proposal["id"], "resident-b", 0.4) service.submit_preference(proposal["id"], "resident-c", 0.1) return proposal["id"] def test_service_routes_comments_and_exports_brief(tmp_path: Path): service = build_service(tmp_path) proposal_id = seed_service(service) summary = service.summarize_comments(proposal_id) assert summary assert "en" in summary routed = service.route_proposal(proposal_id, top_n=2) assert routed["fragments"] assert routed["fragments"][0]["matches"] dashboard = service.build_dashboard(proposal_id) assert dashboard["preference_aggregate"]["count"] == 3 assert dashboard["consensus_pockets"] brief = service.export_brief(proposal_id) assert brief["proposal"]["title"] == "School street safety" assert brief["provenance"] assert brief["ledger"] def test_dashboard_surface_exposes_api(tmp_path: Path): app = create_app(tmp_path / "api.sqlite") client = __import__("fastapi.testclient", fromlist=["TestClient"]).TestClient(app) proposal = client.post( "/proposals", json={ "city": "Springfield", "title": "Library weekend hours", "body": "Keep the library open later on weekends for students and families.", "geography": "ward-1", "tags": ["libraries", "education"], }, ).json() client.post("/residents", json={"resident_key": "resident-x", "geography": "ward-1", "interests": ["libraries"], "lived_experience": ["student"], "languages": ["en"]}) client.post(f"/proposals/{proposal['id']}/comments", json={"text": "I support later hours.", "resident_key": "resident-x", "channel": "sms"}) brief = client.get(f"/proposals/{proposal['id']}/brief").json() assert brief["proposal"]["title"] == "Library weekend hours" assert brief["summary"] def test_private_preference_aggregation_supports_noise(tmp_path: Path): service = build_service(tmp_path) proposal_id = seed_service(service) clean = service.aggregate_preferences(proposal_id) noisy = service.aggregate_preferences(proposal_id, epsilon=0.75, seed=42) assert clean["count"] == 3 assert noisy["count"] >= 1 assert noisy["mean_score"] != clean["mean_score"] or noisy["count"] != clean["count"] def test_capture_batch_ingest_is_atomic_and_idempotent(tmp_path: Path): service = build_service(tmp_path) proposal_id = seed_service(service) batch = service.ingest_capture_batch( "ward-3-mobile-001", "sms", [ {"kind": "resident", "resident_key": "resident-d", "geography": "ward-3", "interests": ["schools"], "languages": ["en"]}, {"kind": "comment", "proposal_id": proposal_id, "text": "Please keep crossings safe for elders.", "resident_key": "resident-d", "channel": "sms"}, {"kind": "preference", "proposal_id": proposal_id, "resident_key": "resident-d", "score": 0.8, "channel": "sms"}, {"kind": "ledger", "proposal_id": proposal_id, "entry_kind": "facilitator_note", "payload": {"note": "captured at block party"}}, ], ) assert batch["batch_key"] == "ward-3-mobile-001" assert batch["status"] == "processed" assert len(batch["results"]) == 4 assert service.get_resident("resident-d")["geography"] == "ward-3" comments = service.list_comments(proposal_id) assert any(comment["metadata"].get("batch_key") == "ward-3-mobile-001" for comment in comments) assert service.aggregate_preferences(proposal_id)["count"] == 4 duplicate = service.ingest_capture_batch( "ward-3-mobile-001", "sms", [], ) assert duplicate["id"] == batch["id"] assert duplicate["results"] == batch["results"] assert len(service.list_comments(proposal_id)) == len(comments) def test_capture_batch_endpoint_surfaces_audit_payload(tmp_path: Path): app = create_app(tmp_path / "batch-api.sqlite") client = TestClient(app) proposal = client.post( "/proposals", json={ "city": "Springfield", "title": "Crosswalk renewal", "body": "Renew the busiest crosswalks near the library.", "geography": "ward-4", "tags": ["safety", "mobility"], }, ).json() response = client.post( "/capture-batches", json={ "batch_key": "ward-4-townhall-01", "source": "field", "items": [ {"kind": "comment", "proposal_id": proposal["id"], "text": "I support the renewal.", "channel": "field"}, ], }, ) assert response.status_code == 200 payload = response.json() assert payload["status"] == "processed" assert payload["results"][0]["kind"] == "comment" fetched = client.get("/capture-batches/ward-4-townhall-01").json() assert fetched["batch_key"] == "ward-4-townhall-01"