"""P0-1 原子性:`PUT /settings/providers` 多凭据一请求,中途失败整体回滚。 不变量:仓储写方法只 `flush()`,端点末尾**统一一次** `commit()`。若某条 upsert 中途 抛错,端点绝不到达 `commit()` → 请求 session 回滚,DB 不留半更新(无部分提交)。 用可注入 fake:store 在第 N 次 upsert 抛错;fake session 记录 `commit()` 调用次数。 断言:① 请求返回 500(兜底信封);② `session.commit()` **从未被调用**(无半提交)。 """ from __future__ import annotations import os import uuid from typing import Any import httpx import pytest from cryptography.fernet import Fernet from fastapi import FastAPI from ww_api.services.credentials import StoredRouting class _ExplodingStore: """凭据 store fake:前 N 次 upsert 成功,第 fail_at 次(1-based)抛错。 模拟「多凭据一请求」里第 K 条写入失败——验证端点不会半提交。 """ def __init__(self, *, fail_at: int) -> None: self._fail_at = fail_at self._upsert_calls = 0 self.creds: dict[tuple[uuid.UUID, str], bytes] = {} async def list_credentials(self, owner_id: uuid.UUID) -> list[Any]: return [] async def list_routing(self) -> list[Any]: return [] async def get_credential(self, owner_id: uuid.UUID, provider: str) -> Any: return None async def upsert_credential( self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes ) -> None: self._upsert_calls += 1 if self._upsert_calls == self._fail_at: raise RuntimeError("simulated mid-request write failure") self.creds[(owner_id, provider)] = api_key_enc async def upsert_oauth_credential( self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes ) -> None: # pragma: no cover - 不用于本测试 raise NotImplementedError async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: return False async def upsert_routing(self, routing: StoredRouting) -> None: self.creds[(uuid.UUID(int=0), routing.tier)] = b"" class _RecordingSession: """fake AsyncSession:只记 commit/rollback 调用次数(端点统一提交的探针)。""" def __init__(self) -> None: self.commits = 0 self.rollbacks = 0 async def commit(self) -> None: self.commits += 1 async def rollback(self) -> None: self.rollbacks += 1 def _build_app(store: _ExplodingStore, session: _RecordingSession) -> FastAPI: os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from ww_config import get_settings get_settings.cache_clear() from ww_api.main import create_app from ww_api.services.provider_deps import get_credential_store from ww_db import get_session app = create_app() app.dependency_overrides[get_credential_store] = lambda: store app.dependency_overrides[get_session] = lambda: session return app @pytest.mark.asyncio async def test_upsert_providers_rolls_back_on_mid_request_failure() -> None: # Arrange:第 2 条凭据写入时抛错(前一条已 flush 到 session,但尚未 commit)。 store = _ExplodingStore(fail_at=2) session = _RecordingSession() app = _build_app(store, session) payload = { "credentials": [ {"provider": "deepseek", "api_key": "sk-a"}, {"provider": "kimi", "api_key": "sk-b"}, {"provider": "qwen", "api_key": "sk-c"}, ] } transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.put("/settings/providers", json=payload) # Assert:兜底信封(catch-all → 500 INTERNAL,不回显原始异常),且**从未提交**(无半提交)。 assert resp.status_code == 500 assert resp.json()["error"]["code"] == "INTERNAL" assert "simulated mid-request write failure" not in resp.text assert session.commits == 0 @pytest.mark.asyncio async def test_upsert_providers_commits_once_on_success() -> None: # Arrange:全部成功(fail_at 远大于条数)。 store = _ExplodingStore(fail_at=99) session = _RecordingSession() app = _build_app(store, session) payload = { "credentials": [ {"provider": "deepseek", "api_key": "sk-a"}, {"provider": "kimi", "api_key": "sk-b"}, ] } transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.put("/settings/providers", json=payload) # Assert:成功路径**恰好一次** commit(所有写入统一提交)。 assert resp.status_code == 200 assert session.commits == 1