"""SSE `Accept` 契约测试(文档 §3.2 请求头表 + §3.5 状态码 + §6.4 主要错误)。 权威规则:`Accept` **非必填**,"默认 `application/json`;SSE 为 `text/event-stream`", `SSE_NOT_ACCEPTABLE` 是 SSE 接口的主要错误之一(406)。 覆盖两条容易写错的分支: 1. **未携带** `Accept` 必须放行——把"未携带"当成"不接受"会把所有默认客户端挡在门外; 2. `q=0` 属于显式拒绝,不能被通配符掩盖。 校验顺序另在 `stream_agent_run_events` 中做了断言:可见性(RUN_NOT_FOUND)先行, 否则 406/404 的差异就等于一次运行存在性枚举。 """ from typing import Any import pytest from fastapi.testclient import TestClient from app.api.controllers.agent_runs import accepts_event_stream from app.api.dependencies.auth import build_request_context from app.core.contracts import RequestContext from app.core.errors import RunNotFoundError from app.main import create_app from app.service.run_query_service import RunSnapshot EVENTS_PATH = "/api/v1/agent-runs/run-1/events" async def resolve_context() -> RequestContext: return RequestContext(user_id="1", trace_id="trace-1", permissions=("agent:run",)) def app_with_query(monkeypatch: pytest.MonkeyPatch, query: Any) -> TestClient: application = create_app() application.dependency_overrides[build_request_context] = resolve_context monkeypatch.setattr("app.api.controllers.agent_runs.RunQueryService", lambda: query) return TestClient(application) class StubQuery: """`get` 返回终态快照、`watch` 立即收流,避免测试连接挂住。""" def __init__(self, status: str = "succeeded") -> None: self.status = status async def get(self, run_id: str, _ctx: RequestContext) -> RunSnapshot: return RunSnapshot(run_id=run_id, trace_id="trace-1", status=self.status, agent_type="customer_service", session_id="s", result=None, error_code=None, created_at="2026-01-01T00:00:00Z", completed_at="2026-01-01T00:00:01Z") async def watch(self, initial: RunSnapshot, _ctx: RequestContext): yield initial class MissingQuery: async def get(self, _run_id: str, _ctx: RequestContext) -> RunSnapshot: raise RunNotFoundError("运行不存在或不可见") async def watch(self, initial: RunSnapshot, _ctx: RequestContext): yield initial @pytest.mark.parametrize( "accept", [None, "", "*/*", "text/*", "text/event-stream", "text/event-stream;q=0.8", "application/json, text/event-stream"], ) def test_acceptable_or_absent_accept_is_allowed(accept: str | None) -> None: assert accepts_event_stream(accept) is True @pytest.mark.parametrize( "accept", ["application/json", "text/html", "application/json, text/html", "*/*;q=0", "text/event-stream;q=0", "text/*;q=0, application/json"], ) def test_explicitly_unacceptable_accept_is_rejected(accept: str) -> None: assert accepts_event_stream(accept) is False @pytest.mark.parametrize("accept", [None, "*/*", "text/event-stream"]) def test_missing_wildcard_and_exact_accept_are_allowed_by_endpoint( monkeypatch: pytest.MonkeyPatch, accept: str | None ) -> None: headers = {} if accept is None else {"Accept": accept} with app_with_query(monkeypatch, StubQuery()) as client: response = client.get(EVENTS_PATH, headers=headers) assert response.status_code == 200 assert response.headers["content-type"].startswith("text/event-stream") def test_json_only_accept_is_rejected_with_documented_code( monkeypatch: pytest.MonkeyPatch ) -> None: with app_with_query(monkeypatch, StubQuery()) as client: response = client.get(EVENTS_PATH, headers={"Accept": "application/json"}) assert response.status_code == 406 assert response.json()["error"]["code"] == "SSE_NOT_ACCEPTABLE" assert response.json()["error"]["retryable"] is False assert response.json()["error"]["field_errors"] == [] def test_visibility_is_checked_before_accept( monkeypatch: pytest.MonkeyPatch ) -> None: """不可见运行的非法 Accept 也必须得到 `RUN_NOT_FOUND`,不泄露存在性差异。""" cases = [{"Accept": "application/json"}, {}] for headers in cases: with app_with_query(monkeypatch, MissingQuery()) as client: response = client.get(EVENTS_PATH, headers=headers) assert response.status_code == 404 assert response.json()["error"]["code"] == "RUN_NOT_FOUND" def test_unacceptable_accept_does_not_start_a_stream( monkeypatch: pytest.MonkeyPatch ) -> None: """拒绝必须发生在响应头发送前:文档 §6.4 要求此时返回统一 JSON 错误。""" with app_with_query(monkeypatch, StubQuery()) as client: response = client.get(EVENTS_PATH, headers={"Accept": "application/json"}) assert response.headers["content-type"].startswith("application/json")