idea198-civicswarm-privacy-.../tests/test_service.py

161 lines
6.4 KiB
Python

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"