"""End-to-end vertical slice against real PostgreSQL + Redis. Run with ``make up && make migrate`` first, or let ``scripts/smoke.sh`` do it. Skipped automatically when the infrastructure is not reachable, so ``make test`` stays fast and hermetic. The LLM provider is faked here — ``scripts/smoke.sh`` is the one that proves the real DeepSeek path. """ from __future__ import annotations import os import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from decimal import Decimal from typing import Any import pytest def _infra_available() -> bool: return bool(os.getenv("MEIDA_RUN_INTEGRATION")) pytestmark = [ pytest.mark.integration, pytest.mark.skipif( not _infra_available(), reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running", ), ] class FakeProvider: """替换真实 provider:集成用例验证配额与计量链路,不该花钱也不该受网络影响。""" name = "deepseek" default_model = "deepseek-v4-flash" async def complete(self, request: Any) -> Any: from packages.llm.types import ProviderResponse, Usage return ProviderResponse( text="建议白衬衫配西裤。", model="deepseek-v4-flash", usage=Usage(tokens_in=800, tokens_out=120, tokens_cached=600), ) async def aclose(self) -> None: return None @asynccontextmanager async def fake_client(app: Any) -> AsyncIterator[Any]: """起 app lifespan + ASGI 客户端,并把 deepseek 换成 FakeProvider。""" import httpx transport = httpx.ASGITransport(app=app) async with ( httpx.AsyncClient(transport=transport, base_url="http://test") as http, app.router.lifespan_context(app), ): app.state.llm_gateway._providers["deepseek"] = FakeProvider() yield http @pytest.fixture async def client() -> Any: from apps.api.main import create_app from packages.shared.config import get_settings async with fake_client(create_app(get_settings())) as http: yield http async def test_chat_slice_reserves_commits_and_records_cost(client: Any) -> None: token_resp = await client.post( "/api/v1/auth/dev-token", json={"device_key": f"it-{uuid.uuid4().hex[:8]}"} ) assert token_resp.json()["code"] == 0 headers = {"Authorization": f"Bearer {token_resp.json()['data']['access_token']}"} before = (await client.get("/api/v1/quota", headers=headers)).json()["data"] chat = await client.post("/api/v1/agent/chat", json={"message": "面试穿什么"}, headers=headers) body = chat.json() assert body["code"] == 0 assert body["data"]["reply"] assert body["data"]["prompt_version"] == "v1" assert Decimal(body["data"]["usage"]["cost_cents"]) > 0 after = (await client.get("/api/v1/quota", headers=headers)).json()["data"] assert after["used"] == before["used"] + 1 ledger = (await client.get("/api/v1/quota/ledger", headers=headers)).json()["data"] assert ledger[0]["tx_type"] == "consume" assert ledger[0]["status"] == "committed" async def test_alias_path_v1_matches_api_v1(client: Any) -> None: assert (await client.get("/healthz")).status_code == 200 token = await client.post("/v1/auth/dev-token", json={"device_key": "alias-check"}) assert token.json()["code"] == 0 async def test_unauthenticated_requests_get_the_unified_envelope(client: Any) -> None: resp = await client.post("/api/v1/agent/chat", json={"message": "hi"}) assert resp.status_code == 401 assert resp.json()["code"] == 40100 async def test_block_all_budget_rejects_and_refunds_the_reservation( monkeypatch: pytest.MonkeyPatch, ) -> None: """T-P1-25b:reserve 之后才触发熔断 → gateway 拒绝 → refund → 净扣减 = 0。 这是四档降级(架构 §13.3)的验收路径:把 block_all 档的 allow 名单清空 (连纯文本对话也拦),让 agent_chat 走 100% 档的既有 60003 语义,从而 断言配额三段式在「先预扣、后熔断」时的补偿行为。 """ import json from apps.api.main import create_app from packages.llm.budget import DailyBudgetGuard, InMemoryCostCounter from packages.shared.config import get_settings from packages.shared.redis_client import get_redis monkeypatch.setenv("LLM_DAILY_BUDGET_CENTS", "1") # 预算 1 分钱 monkeypatch.setenv( "LLM_BUDGET_TIERS_JSON", json.dumps( [ {"threshold": 0.6, "name": "warn", "blocked": []}, {"threshold": 0.8, "name": "degrade_secondary", "blocked": ["hd_export"]}, {"threshold": 0.9, "name": "degrade_generation", "blocked": ["hairstyle"]}, # allow 清空:agent_chat 也被 block_all 拦下(演练配置) {"threshold": 1.0, "name": "block_all", "blocked": "*", "allow": []}, ] ), ) get_settings.cache_clear() try: app = create_app(get_settings()) async with fake_client(app) as http: redis = get_redis() # 注入当日成本到预算以上(键格式照 DailyBudgetGuard.current_key()) key_helper = DailyBudgetGuard(InMemoryCostCounter(), daily_budget_cents=Decimal("1")) cost_key = key_helper.current_key() await redis.incrbyfloat(cost_key, 5) token_resp = await http.post( "/api/v1/auth/dev-token", json={"device_key": f"it-{uuid.uuid4().hex[:8]}"} ) headers = {"Authorization": f"Bearer {token_resp.json()['data']['access_token']}"} resp = await http.post( "/api/v1/agent/chat", json={"message": "面试穿什么"}, headers=headers ) assert resp.json()["code"] == 60003 # LLM_DAILY_BUDGET_EXCEEDED # 被拒请求的配额净扣减 = 0:consume(-1, refunded) + refund(+1, committed) rows = (await http.get("/api/v1/quota/ledger", headers=headers)).json()["data"] consume = [r for r in rows if r["tx_type"] == "consume"] refunds = [r for r in rows if r["tx_type"] == "refund"] assert len(consume) == 1 and consume[0]["status"] == "refunded" assert len(refunds) == 1 and refunds[0]["delta"] == 1 assert sum(r["delta"] for r in (*consume, *refunds)) == 0 await redis.delete(cost_key) finally: # 恢复全局 settings 缓存,避免本用例的环境覆盖渗进其它用例 monkeypatch.undo() get_settings.cache_clear()