blob: 9f3f53646e22e4ba98a21935224e830ba4db0927 [file]
"""Integration tests for /api/api and /api/list endpoints."""
from __future__ import annotations
import json
import re
async def test_api_is_public_and_valid_openapi(app):
client = app.test_client()
response = await client.get("/api/api")
assert response.status_code == 200
assert response.headers["Content-Type"].startswith("application/json")
assert "Cache-Control" in response.headers
assert "max-age=300" in response.headers["Cache-Control"]
body = await response.get_json()
assert re.match(r"^3\.", body["openapi"]), body["openapi"]
# /api/list and /api/api must appear in the served document.
assert "/api/list" in body["paths"]
assert "/api/api" in body["paths"]
async def test_api_document_advertises_pydantic_schemas(app):
"""The /api document must surface our Pydantic models so external
integrators can see request and response shapes. See SPEC section 9.9."""
client = app.test_client()
response = await client.get("/api/api")
body = await response.get_json()
components = body.get("components", {}).get("schemas", {})
# The full ResponseOption discriminated union must be reachable from
# /api/list via ListResponse -> Question -> response_option.
assert "Question" in components, components.keys()
assert "VoteOption" in components
assert "LazyConsensusOption" in components
assert "FreeTextOption" in components
list_responses = body["paths"]["/api/list"]["get"]["responses"]
# 200 references ListResponse (inlined or via $ref both acceptable).
schema_200 = list_responses["200"]["content"]["application/json"]["schema"]
assert schema_200.get("title") == "ListResponse" or "ListResponse" in str(schema_200)
# 401 references our error model.
schema_401 = list_responses["401"]["content"]["application/json"]["schema"]
assert schema_401.get("title") == "AuthenticationRequired" or "AuthenticationRequired" in str(
schema_401
)
async def test_list_unauthenticated_returns_401_json(app):
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
assert response.status_code == 401
body = await response.get_json()
assert body["error"] == "authentication_required"
assert "login_url" in body
async def test_list_unauthenticated_html_redirects_to_oauth(app):
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "text/html"})
assert response.status_code in (301, 302, 303, 307, 308)
assert "/api/auth" in response.headers.get("Location", "")
async def test_list_returns_seeded_questions_for_authenticated_user(
app, stub_session, seed_questions
):
seed_questions(app, count=2)
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
assert response.status_code == 200
body = await response.get_json()
assert body["user"] == "alice"
assert len(body["pending"]) == 2
first = body["pending"][0]
assert first["project_id"] == "seapony"
assert first["status"] == "open"
# viewer_is_binding flips true because the seeded user is on 'seapony'.
assert first["viewer_is_binding"] is True
# time_remaining_seconds is server-stamped and positive (closes_at in
# the future per the fixture).
assert first["time_remaining_seconds"] > 0
async def test_list_filters_private_question_caller_cannot_see(app, stub_session, seed_questions):
# Public question on seapony (visible) + private question on a project
# the user is not on (invisible).
seed_questions(app, count=1, project_id="seapony", is_private=0)
seed_questions(app, count=1, project_id="infra", is_private=1, request_id="req_secret")
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
body = await response.get_json()
project_ids = {q["project_id"] for q in body["pending"]}
assert project_ids == {"seapony"}
async def test_list_omits_resolved_and_removed_questions(app, stub_session, seed_questions):
seed_questions(app, count=1, status="open")
seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
request_id="resolved_req",
)
seed_questions(
app,
count=1,
status="removed",
outcome="withdrawn",
request_id="removed_req",
)
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
body = await response.get_json()
statuses = {q["status"] for q in body["pending"]}
assert statuses == {"open"}
async def test_list_returns_empty_array_not_null(app, stub_session):
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
body = await response.get_json()
assert body["pending"] == []
assert isinstance(body["pending"], list)
assert body["recent"] == []
assert isinstance(body["recent"], list)
async def test_list_recent_includes_resolved_and_removed_within_window(
app, stub_session, seed_questions
):
"""SPEC §9.1: `recent` surfaces non-open questions updated in the last 14 days."""
from datetime import UTC, datetime, timedelta
def _iso(dt):
return dt.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
now = datetime.now(UTC)
fresh = _iso(now - timedelta(days=2))
stale = _iso(now - timedelta(days=20))
[open_id] = seed_questions(app, count=1, status="open", request_id="open_req")
[resolved_id] = seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
request_id="resolved_req",
updated_at=fresh,
)
[removed_id] = seed_questions(
app,
count=1,
status="removed",
outcome="withdrawn",
request_id="removed_req",
updated_at=fresh,
)
[ancient_id] = seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
request_id="ancient_req",
updated_at=stale,
)
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
assert response.status_code == 200
body = await response.get_json()
# `pending` still contains only open questions.
pending_ids = {q["question_id"] for q in body["pending"]}
assert pending_ids == {open_id}
# `recent` carries the open + the two fresh-but-closed questions, but
# not the 20-day-old one.
recent_ids = {q["question_id"] for q in body["recent"]}
assert open_id in recent_ids
assert resolved_id in recent_ids
assert removed_id in recent_ids
assert ancient_id not in recent_ids
# Status markers are carried per row so the UI can render an
# "open vs closed" indicator without a per-id round trip.
by_id = {q["question_id"]: q for q in body["recent"]}
assert by_id[open_id]["status"] == "open"
assert by_id[resolved_id]["status"] == "resolved"
assert by_id[resolved_id]["outcome"] == "approved"
assert by_id[removed_id]["status"] == "removed"
assert by_id[removed_id]["outcome"] == "withdrawn"
async def test_publist_is_public_and_excludes_private(app, seed_questions):
"""SPEC §9.13: /api/publist must be reachable without auth and must
omit every is_private=1 row regardless of status."""
from datetime import UTC, datetime, timedelta
def _iso(dt):
return dt.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
fresh = _iso(datetime.now(UTC) - timedelta(days=1))
[open_pub] = seed_questions(app, count=1, status="open", request_id="open_pub")
[open_priv] = seed_questions(app, count=1, status="open", is_private=1, request_id="open_priv")
[resolved_pub] = seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
request_id="resolved_pub",
updated_at=fresh,
)
[resolved_priv] = seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
is_private=1,
request_id="resolved_priv",
updated_at=fresh,
)
client = app.test_client()
response = await client.get("/api/publist")
assert response.status_code == 200
body = await response.get_json()
ids = {q["question_id"] for q in body["questions"]}
assert open_pub in ids
assert resolved_pub in ids
# Both private rows must be filtered out even though one is still open.
assert open_priv not in ids
assert resolved_priv not in ids
async def test_publist_excludes_closed_outside_14d_window(app, seed_questions):
from datetime import UTC, datetime, timedelta
def _iso(dt):
return dt.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
stale = _iso(datetime.now(UTC) - timedelta(days=30))
[open_pub] = seed_questions(app, count=1, status="open", request_id="open_pub_a")
[ancient_pub] = seed_questions(
app,
count=1,
status="resolved",
outcome="approved",
request_id="ancient_pub",
updated_at=stale,
)
client = app.test_client()
response = await client.get("/api/publist")
body = await response.get_json()
ids = {q["question_id"] for q in body["questions"]}
assert open_pub in ids
assert ancient_pub not in ids
async def test_publist_no_auth_required(app):
"""Anonymous (no stub_session) callers must still get a 200."""
client = app.test_client()
response = await client.get("/api/publist", headers={"Accept": "application/json"})
assert response.status_code == 200
body = await response.get_json()
assert body == {"questions": []}
async def test_publist_viewer_is_binding_is_false_for_anonymous(app, seed_questions):
"""No session means no committees, so every row reports
viewer_is_binding=False even when the row's is_binding=1."""
seed_questions(app, count=1, status="open", is_binding=1)
client = app.test_client()
response = await client.get("/api/publist")
body = await response.get_json()
assert body["questions"]
assert all(q["viewer_is_binding"] is False for q in body["questions"])
# ---------------------------------------------------------------------------
# /api/publist caching (SPEC §9.13)
# ---------------------------------------------------------------------------
async def test_publist_caches_within_ttl(app, seed_questions, monkeypatch):
"""A second call within the TTL must return the cached body even if
the underlying table grows between the two requests."""
import cap_backend.routes.questions as routes
fake_clock = {"now": 1000.0}
monkeypatch.setattr(routes.time, "monotonic", lambda: fake_clock["now"])
seed_questions(app, count=1, status="open", request_id="cache_first")
client = app.test_client()
first = await client.get("/api/publist")
first_body = await first.get_json()
first_ids = {q["question_id"] for q in first_body["questions"]}
assert len(first_ids) == 1
assert first.headers["Cache-Control"] == "public, max-age=30"
# Insert a new row that *would* be visible if the handler refreshed.
seed_questions(app, count=1, status="open", request_id="cache_second")
# Advance only 5 seconds — well within the default 30s TTL — and
# confirm the cached body is served unchanged.
fake_clock["now"] += 5
second = await client.get("/api/publist")
second_body = await second.get_json()
assert {q["question_id"] for q in second_body["questions"]} == first_ids
async def test_publist_refreshes_after_ttl(app, seed_questions, monkeypatch):
"""Crossing the TTL boundary must trigger a refresh."""
import cap_backend.routes.questions as routes
fake_clock = {"now": 1000.0}
monkeypatch.setattr(routes.time, "monotonic", lambda: fake_clock["now"])
seed_questions(app, count=1, status="open", request_id="ttl_first")
client = app.test_client()
await client.get("/api/publist")
seed_questions(app, count=1, status="open", request_id="ttl_second")
# Jump past the default 30s TTL.
fake_clock["now"] += 31
refreshed = await client.get("/api/publist")
body = await refreshed.get_json()
assert len({q["question_id"] for q in body["questions"]}) == 2
async def test_publist_ttl_zero_disables_cache(app, settings, seed_questions):
"""settings.server.publist_cache_seconds == 0 must bypass the cache."""
settings.server.publist_cache_seconds = 0
seed_questions(app, count=1, status="open", request_id="ttl_zero_a")
client = app.test_client()
first = await client.get("/api/publist")
first_body = await first.get_json()
assert len(first_body["questions"]) == 1
assert first.headers["Cache-Control"] == "no-store"
seed_questions(app, count=1, status="open", request_id="ttl_zero_b")
second = await client.get("/api/publist")
second_body = await second.get_json()
assert len(second_body["questions"]) == 2
async def test_publist_cache_control_reflects_configured_ttl(app, settings):
"""The Cache-Control max-age must match the configured TTL."""
settings.server.publist_cache_seconds = 120
client = app.test_client()
response = await client.get("/api/publist")
assert response.headers["Cache-Control"] == "public, max-age=120"
async def test_list_recent_respects_private_acl(app, as_user, seed_questions):
"""Private questions outside the caller's reach must not bleed into `recent`."""
from datetime import UTC, datetime, timedelta
from cap_backend.auth import AuthenticatedUser
def _iso(dt):
return dt.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
fresh = _iso(datetime.now(UTC) - timedelta(days=1))
as_user(AuthenticatedUser(uid="outsider", committees=("other",)))
[hidden_id] = seed_questions(
app,
count=1,
project_id="seapony",
is_private=1,
status="resolved",
outcome="approved",
request_id="hidden_req",
updated_at=fresh,
)
[visible_id] = seed_questions(
app,
count=1,
project_id="other",
is_private=0,
status="resolved",
outcome="approved",
request_id="visible_req",
updated_at=fresh,
)
client = app.test_client()
response = await client.get("/api/list", headers={"Accept": "application/json"})
assert response.status_code == 200
body = await response.get_json()
recent_ids = {q["question_id"] for q in body["recent"]}
assert hidden_id not in recent_ids
assert visible_id in recent_ids
async def test_api_response_is_cached_across_requests(app):
client = app.test_client()
r1 = await client.get("/api/api")
r2 = await client.get("/api/api")
assert r1.status_code == r2.status_code == 200
assert await r1.get_data() == await r2.get_data()
async def test_api_response_includes_service_version(app):
client = app.test_client()
response = await client.get("/api/api")
body = json.loads(await response.get_data())
assert "version" in body["info"]