Files
hunjing/backend/tests/test_conversations.py
T
chenxin 34c481ab9e ✨ feat: WP-13 市场与交易——不可变账本、退款、Webhook 与保全态(ADR-009/021)
- 迁移 11:listing、order(六态 + 幂等唯一约束)、order_item(价格快照,客户端金额不可信)、ledger_entry(不可变 8 类型带符号分录)、webhook_event(Provider 事件 ID 幂等)、personal_domain.billing_status(保全态)
- 购买闭环:下单冻结价格快照 → 支付确认同事务写三分录(charge = platform_fee 10% + creator_payable,双录平衡校验拒绝不平衡提交)
- 退款:反向三条分录修正账本(Ledger 不删不改,ADR-009);重复退款幂等;汇总端点对账 balanced=true
- Webhook:HMAC-SHA256 验签(密钥经环境变量,错签 401)→ 按 Provider 事件 ID 幂等(重复投递 duplicate 不重复入账)→ 买方从订单反查(不信任 payload)
- 保全态:欠费只读保全(全部状态变更 403 且明示不删数据;解除端点不被保全自锁);读取不受影响,恢复即解锁——ADR-021 落地
- 测试 72 项全绿(+5):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
2026-09-11 11:37:45 +08:00

1147 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。"""
import contextlib
import json
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app import models
from tests.conftest import login
MESSAGE_BODY = {"content": [{"type": "text", "text": "你好,魂镜"}], "start_run": True}
async def _create_conversation(client: AsyncClient, headers: dict) -> str:
resp = await client.post(
"/v1/conversations", json={"title": "切片"}, headers=headers
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def _wait_run_terminal(db, run_id: str, deadline_s: float = 10.0) -> str:
"""轮询等待 Run 终态(后台任务推进)。"""
import asyncio
from app.models import Run as RunModel
deadline = asyncio.get_event_loop().time() + deadline_s
while asyncio.get_event_loop().time() < deadline:
async with db() as session:
run = await session.get(RunModel, run_id)
if run is not None and run.status in {"completed", "failed", "cancelled"}:
return run.status
await asyncio.sleep(0.05)
raise AssertionError(f"Run {run_id} 未在 {deadline_s}s 内到达终态")
async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str) -> object:
return await client.post(
f"/v1/conversations/{cid}/messages",
json=MESSAGE_BODY,
headers={**headers, "Idempotency-Key": key},
)
class TestMessagePipeline:
async def test_create_and_post_message(self, client: AsyncClient, db) -> None:
headers = await login(client, "alice@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-1")
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["message"]["id"].startswith("message_")
assert body["message"]["content"][0]["id"].startswith("block_")
assert body["run"]["status"] == "queued" # 创建即返回;终态由后台执行推进
final_status = await _wait_run_terminal(db, body["run"]["id"])
assert final_status == "completed"
async def test_idempotent_replay_returns_original(self, client: AsyncClient) -> None:
headers = await login(client, "bob@example.com")
cid = await _create_conversation(client, headers)
first = await _post_message(client, headers, cid, "idem-replay")
assert first.status_code == 201
second = await _post_message(client, headers, cid, "idem-replay")
assert second.status_code == 200 # 重放返回原资源
assert second.json()["message"]["id"] == first.json()["message"]["id"]
async def test_same_key_different_body_conflicts(self, client: AsyncClient) -> None:
headers = await login(client, "carol@example.com")
cid = await _create_conversation(client, headers)
await _post_message(client, headers, cid, "idem-conflict")
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "不同内容"}], "start_run": True},
headers={**headers, "Idempotency-Key": "idem-conflict"},
)
assert resp.status_code == 409
async def test_missing_idempotency_key_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "dave@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages", json=MESSAGE_BODY, headers=headers
)
assert resp.status_code == 422
async def test_unknown_block_type_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "erin@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "hologram", "file_id": "x"}]},
headers={**headers, "Idempotency-Key": "idem-bad"},
)
assert resp.status_code == 422
async def test_cross_domain_conversation_returns_404(self, client: AsyncClient) -> None:
alice = await login(client, "ida@example.com")
cid = await _create_conversation(client, alice)
mallory = await login(client, "mallory@example.com")
resp = await _post_message(client, mallory, cid, "idem-idor")
assert resp.status_code == 404 # fail-closed,不泄露存在性
async def test_csrf_token_required_for_state_change(self, client: AsyncClient) -> None:
await login(client, "frank@example.com")
resp = await client.post("/v1/conversations", json={"title": "无 CSRF"})
assert resp.status_code == 403
async def test_unauthenticated_rejected(self, client: AsyncClient) -> None:
resp = await client.post("/v1/conversations", json={"title": "匿名"})
assert resp.status_code == 401
class TestRunSse:
async def test_sse_snapshot_and_terminal(self, client: AsyncClient, db) -> None:
headers = await login(client, "grace@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-sse")
run_id = resp.json()["run"]["id"]
await _wait_run_terminal(db, run_id)
sse = await client.get(f"/v1/runs/{run_id}/events")
assert sse.status_code == 200
assert sse.headers["content-type"].startswith("text/event-stream")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
assert events[0]["type"] == "run.status.changed"
assert events[0]["data"]["status"] == "running"
assert events[-1]["type"] == "run.status.changed"
assert events[-1]["data"]["status"] == "completed"
types = [e["type"] for e in events]
assert "content.block.started" in types
assert "content.block.delta" in types # 真实流产生
assert "message.completed" in types
async def test_cross_domain_run_returns_404(self, client: AsyncClient) -> None:
headers = await login(client, "heidi@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-sse2")
run_id = resp.json()["run"]["id"]
await login(client, "eve@example.com")
sse = await client.get(f"/v1/runs/{run_id}/events")
assert sse.status_code == 404
class TestAtomicity:
async def test_failure_leaves_no_orphans(self, client: AsyncClient, db) -> None:
from app.conversations import service
headers = await login(client, "ivan@example.com")
cid = await _create_conversation(client, headers)
class BoomRun:
def __init__(self, *args, **kwargs):
raise RuntimeError("注入失败:Run 写入阶段崩溃")
original = service.Run
service.Run = BoomRun # type: ignore[assignment]
try:
resp = await _post_message(client, headers, cid, "idem-atomic")
assert resp.status_code == 500
finally:
service.Run = original # type: ignore[assignment]
async with db() as session:
# 会话创建的合法审计/Outbox 保留;消息相关记录必须为零
assert (await session.scalars(select(models.Message))).all() == []
assert (await session.scalars(select(models.Run))).all() == []
audits = (await session.scalars(select(models.AuditEvent))).all()
assert [a for a in audits if a.aggregate_type == "message"] == []
outbox = (await session.scalars(select(models.OutboxEvent))).all()
assert [e for e in outbox if e.aggregate_type == "message"] == []
assert (await session.scalars(select(models.IdempotencyRecord))).all() == []
@pytest.mark.usefixtures("client")
class TestLingtaiNormalization:
async def test_markdown_input_expanded_by_engine(self, client: AsyncClient) -> None:
headers = await login(client, "norm@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={
"content": [
{"type": "markdown", "text": "说明\n```mermaid\nA-->B\n```\n$$\nz\n$$"}
]
},
headers={**headers, "Idempotency-Key": "idem-norm"},
)
assert resp.status_code == 201, resp.text
blocks = resp.json()["message"]["content"]
assert [b["type"] for b in blocks] == ["markdown", "mermaid", "math"]
assert all(b["id"].startswith("block_") for b in blocks)
assert resp.json()["message"].get("parser_version") == "hunjing-markdown-v1"
async def test_python_cpp_golden_consistency(self) -> None:
"""绑定输出与 C++ doctest 期望字节一致(跨端一致性最小 golden)。"""
from app.core import lingtai
out = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
assert out["parser_version"] == "hunjing-markdown-v1"
assert [b["type"] for b in out["blocks"]] == ["markdown", "mermaid", "math"]
# 与 engine/tests/test_transform.cpp 的确定性用例保持同一输入输出
out2 = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
assert out == out2
async def test_engine_failure_fails_closed(self, client: AsyncClient, monkeypatch) -> None:
from app.core import lingtai as lt
def boom(text: str):
raise lt.LingtaiUnavailable("灵台引擎调用失败:注入")
monkeypatch.setattr(lt, "transform_markdown", boom)
headers = await login(client, "fc@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "markdown", "text": "hi"}]},
headers={**headers, "Idempotency-Key": "idem-fc"},
)
# fail closed:引擎不可用时显式失败,不静默降级直存
assert resp.status_code == 500
@pytest.mark.usefixtures("client")
class TestContentBlocks:
async def test_media_blocks_accepted(self, client: AsyncClient) -> None:
headers = await login(client, "blocks@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={
"content": [
{"type": "image", "file_id": "file_1", "alt": "图"},
{"type": "math", "notation": "latex", "display": "inline", "source": "a+b"},
{"type": "table", "columns": ["a"], "rows": [["1"]]},
{"type": "citation", "source_id": "s1", "title": "t", "quoted_text": "q"},
{"type": "error", "message": "块错误"},
]
},
headers={**headers, "Idempotency-Key": "idem-blocks"},
)
assert resp.status_code == 201, resp.text
types = [b["type"] for b in resp.json()["message"]["content"]]
assert types == ["image", "math", "table", "citation", "error"]
async def test_runtime_blocks_rejected_from_client(self, client: AsyncClient) -> None:
"""契约 3.5:tool_call/agent_status 等系统 block 不允许客户端伪造。"""
headers = await login(client, "rtb@example.com")
cid = await _create_conversation(client, headers)
for block in [
{"type": "tool_call", "tool_name": "t", "call_id": "c1"},
{"type": "agent_status", "stage": "s"},
{"type": "approval_request", "approval_id": "a1", "action": "do"},
{"type": "artifact", "artifact_id": "ar1", "artifact_type": "file"},
]:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [block]},
headers={**headers, "Idempotency-Key": f"idem-rtb-{block['type']}"},
)
assert resp.status_code == 422, (block["type"], resp.text)
@pytest.mark.usefixtures("client")
class TestSseReplay:
async def test_events_replayed_in_order_with_sequence(
self, client: AsyncClient, db
) -> None:
headers = await login(client, "sseq@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-seq")
run_id = resp.json()["run"]["id"]
await _wait_run_terminal(db, run_id)
sse = await client.get(f"/v1/runs/{run_id}/events")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
sequences = [e["sequence"] for e in events]
assert sequences == sorted(sequences) == list(range(len(events)))
assert events[0]["type"] == "run.status.changed"
assert events[-1]["type"] == "run.status.changed"
# delta 序号单调(契约 (block_id, delta_index) 去重基础)
deltas = [e for e in events if e["type"] == "content.block.delta"]
assert [d["data"]["delta_index"] for d in deltas] == list(range(len(deltas)))
assert sum(1 for e in events if e["type"] == "content.block.completed") >= 1
@pytest.mark.usefixtures("client")
class TestPaginationAndBranches:
async def test_message_cursor_pagination(self, client: AsyncClient) -> None:
headers = await login(client, "page@example.com")
cid = await _create_conversation(client, headers)
for i in range(5):
await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": f"m{i}"}], "start_run": False},
headers={**headers, "Idempotency-Key": f"idem-p{i}"},
)
page1 = await client.get(f"/v1/conversations/{cid}/messages?limit=2", headers=headers)
assert page1.status_code == 200
body1 = page1.json()
assert len(body1["data"]) == 2 and body1["has_more"] is True
assert body1["next_cursor"]
page2 = await client.get(
f"/v1/conversations/{cid}/messages?limit=2&cursor={body1['next_cursor']}",
headers=headers,
)
body2 = page2.json()
ids1 = {m["id"] for m in body1["data"]}
ids2 = {m["id"] for m in body2["data"]}
assert not (ids1 & ids2)
last = await client.get(
f"/v1/conversations/{cid}/messages?limit=100&cursor={body2['next_cursor']}",
headers=headers,
)
assert last.json()["has_more"] is False
async def test_invalid_cursor_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "badc@example.com")
cid = await _create_conversation(client, headers)
resp = await client.get(
f"/v1/conversations/{cid}/messages?cursor=%%%", headers=headers
)
assert resp.status_code == 422
async def test_branches_and_current_path(self, client: AsyncClient) -> None:
headers = await login(client, "branch@example.com")
cid = await _create_conversation(client, headers)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=headers)
branches = listing.json()["data"]
assert len(branches) == 1 and branches[0]["title"] == "主脉"
# 切换到不存在的分支:422 fail-closed
bad = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": "branch_00000000-0000-7000-8000-000000000000"},
headers=headers,
)
assert bad.status_code == 422
# 切换到合法分支
ok = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": branches[0]["id"]},
headers=headers,
)
assert ok.status_code == 200
assert ok.json()["current_branch_id"] == branches[0]["id"]
async def test_cross_domain_branch_rejected(self, client: AsyncClient) -> None:
alice = await login(client, "br-a@example.com")
cid = await _create_conversation(client, alice)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=alice)
bid = listing.json()["data"][0]["id"]
await login(client, "br-b@example.com")
resp = await client.post(
f"/v1/conversations/{cid}/current-branch", json={"branch_id": bid}
)
assert resp.status_code in (401, 403) # 未带新会话 Cookie:401;带错 Cookie:拒绝
@pytest.mark.usefixtures("client")
class TestRealmsAndCanonization:
async def _make_message(self, client, headers, cid, key, text="hi") -> dict:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": text}], "start_run": False},
headers={**headers, "Idempotency-Key": key},
)
assert resp.status_code == 201, resp.text
return resp.json()["message"]
async def test_realm_switch_snapshots_into_new_messages(self, client: AsyncClient) -> None:
headers = await login(client, "realm1@example.com")
cid = await _create_conversation(client, headers)
detail = await client.get(f"/v1/conversations/{cid}", headers=headers)
assert detail.json()["realm"] == "reality" # 默认真界
sw = await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
assert sw.status_code == 200 and sw.json()["realm"] == "simulation"
await self._make_message(client, headers, cid, "idem-sim1")
await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
# (realm 字段在消息响应中暂未透出,以归真行为间接验证)
# 回到真界
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "reality"}, headers=headers
)
async def test_canonize_reality_message(self, client: AsyncClient) -> None:
headers = await login(client, "can1@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can1")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations",
json={"scope": "reality"},
headers=headers,
)
assert resp.status_code == 201
assert resp.json()["canonical_scope"] == "reality"
assert resp.json()["canonized_at"] is not None
async def test_canonize_idempotent_replay(self, client: AsyncClient) -> None:
headers = await login(client, "can2@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can2")
first = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
second = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
assert first.status_code == 201 and second.status_code == 200
assert second.json()["canonized_at"] == first.json()["canonized_at"]
async def test_canonize_scope_mismatch_rejected(self, client: AsyncClient) -> None:
"""真界消息不能归真到境界/幻境作用域(ADR-020 作用域一致)。"""
headers = await login(client, "can3@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can3")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "world"}, headers=headers
)
assert resp.status_code == 422
async def test_simulation_message_canonizes_as_rehearsal(self, client: AsyncClient) -> None:
"""幻境消息归真仅标记试演记录(ADR-020)。"""
headers = await login(client, "can4@example.com")
cid = await _create_conversation(client, headers)
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
msg = await self._make_message(client, headers, cid, "idem-can4")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "simulation"}, headers=headers
)
assert resp.status_code == 201
async def test_canonize_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "can5@example.com")
cid = await _create_conversation(client, alice)
msg = await self._make_message(client, alice, cid, "idem-can5")
await login(client, "can5-evil@example.com")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}
)
assert resp.status_code in (401, 403, 404) # 未携带新会话 Cookie/CSRF:拒绝
@pytest.mark.usefixtures("client")
class TestRevisions:
async def test_revision_keeps_original_intact(self, client: AsyncClient) -> None:
headers = await login(client, "rev1@example.com")
cid = await _create_conversation(client, headers)
original = None
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "原始内容"}], "start_run": False},
headers={**headers, "Idempotency-Key": "idem-rev1"},
)
original = resp.json()["message"]
rev = await client.post(
f"/v1/messages/{original['id']}/revisions",
json={"content": [{"type": "markdown", "text": "修订后\n```python\nprint(2)\n```"}]},
headers=headers,
)
assert rev.status_code == 201, rev.text
body = rev.json()
assert body["parent_message_id"] == original["id"]
assert body["variant_kind"] == "revision"
# markdown 修订经引擎规范化为两块
assert [b["type"] for b in body["content"]] == ["markdown", "code"]
# 原消息内容不变:列表中两条,原消息文本仍是"原始内容"
listing = await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
by_id = {m["id"]: m for m in listing.json()["data"]}
assert by_id[original["id"]]["content"][0]["text"] == "原始内容"
async def test_revision_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "rev2@example.com")
cid = await _create_conversation(client, alice)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "x"}], "start_run": False},
headers={**alice, "Idempotency-Key": "idem-rev2"},
)
mid = resp.json()["message"]["id"]
await login(client, "rev2-evil@example.com")
rev = await client.post(
f"/v1/messages/{mid}/revisions", json={"content": [{"type": "text", "text": "篡改"}]}
)
assert rev.status_code in (401, 403, 404)
@pytest.mark.usefixtures("client")
class TestCharacters:
async def _create(self, client, headers, body) -> dict:
resp = await client.post("/v1/characters", json=body, headers=headers)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_manual_create_activate_and_first_statement(self, client: AsyncClient) -> None:
headers = await login(client, "char1@example.com")
c = await self._create(
client, headers, {"name": "昭星", "identity_baseline": {"core_traits": ["沉静"]}}
)
assert c["status"] == "draft" and c["origin"] == "original"
act = await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
assert act.status_code == 201 and act.json()["status"] == "active"
assert act.json()["current_version_id"] is not None
fs = await client.post(
f"/v1/characters/{c['id']}/first-statement",
json={"text": "初次见面,我是昭星。"},
headers=headers,
)
assert fs.status_code == 201 and fs.json()["status"] == "pending"
confirm = await client.post(
f"/v1/characters/{c['id']}/first-statement/confirmations",
json={"version_id": fs.json()["id"]},
headers=headers,
)
assert confirm.status_code == 201 and confirm.json()["status"] == "approved"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["first_statement"] == "初次见面,我是昭星。"
async def test_serendipity_is_deterministic_single(self, client: AsyncClient) -> None:
"""随缘:同种子同候选(可复现),一次一个,不是抽卡。"""
headers = await login(client, "char2@example.com")
a = await self._create(client, headers, {"origin": "serendipity", "seed": 3})
b = await self._create(client, headers, {"origin": "serendipity", "seed": 3})
assert a["name"] == b["name"] and a["origin"] == "serendipity"
assert a["drives"] and a["drives"][0]["name"]
async def test_real_person_governance(self, client: AsyncClient) -> None:
headers = await login(client, "char3@example.com")
# 已故现实人物:拒绝
dead = await client.post(
"/v1/characters",
json={"name": "某人", "based_on_real_person": True, "deceased_real_person": True,
"real_person_attested": True},
headers=headers,
)
assert dead.status_code == 422
# 未声明授权:拒绝
no_attest = await client.post(
"/v1/characters", json={"name": "某人", "based_on_real_person": True}, headers=headers
)
assert no_attest.status_code == 422
# 本人声明:允许
ok = await self._create(
client, headers,
{"name": "我自己", "based_on_real_person": True, "real_person_attested": True},
)
assert ok["based_on_real_person"] is True
async def test_lifecycle_full_path(self, client: AsyncClient) -> None:
headers = await login(client, "char4@example.com")
c = await self._create(client, headers, {"name": "汀棠"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
# 静修 → 归藏 → 复照
rest = await client.post(f"/v1/characters/{c['id']}/rests", headers=headers)
assert rest.json()["status"] == "resting"
arch = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers)
assert arch.json()["status"] == "archived"
revive = await client.post(f"/v1/characters/{c['id']}/revivals", headers=headers)
assert revive.json()["status"] == "active"
# 死亡:请求 → 撤销(复照)→ 再请求 → 未满冷静期确认被拒
req = await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers)
assert req.json()["status"] == "death_pending"
cancel = await client.post(f"/v1/characters/{c['id']}/death-cancellations", headers=headers)
assert cancel.json()["status"] == "active"
await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers)
early = await client.post(f"/v1/characters/{c['id']}/death-confirmations", headers=headers)
assert early.status_code == 422 # 冷静期未满
async def test_invalid_transition_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "char5@example.com")
c = await self._create(client, headers, {"name": "砚谷"})
# 灵胚不能直接归藏
resp = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers)
assert resp.status_code == 422
async def test_reforging_requires_confirm(self, client: AsyncClient) -> None:
headers = await login(client, "char6@example.com")
c = await self._create(client, headers, {"name": "朔川"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
no = await client.post(
f"/v1/characters/{c['id']}/reforgings",
json={"identity_baseline": {"core_traits": ["焕新"]}},
headers=headers,
)
assert no.status_code == 422
yes = await client.post(
f"/v1/characters/{c['id']}/reforgings",
json={"confirm": True, "identity_baseline": {"core_traits": ["焕新"]}},
headers=headers,
)
assert yes.status_code == 201 and yes.json()["kind"] == "reforging"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["core_traits"] == ["焕新"]
async def test_drives_update_and_persona_review(self, client: AsyncClient) -> None:
headers = await login(client, "char7@example.com")
c = await self._create(client, headers, {"name": "霁灯"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
upd = await client.patch(
f"/v1/characters/{c['id']}/drives",
json={"drives": [{"name": "求知", "description": "想弄明白一切", "intensity": 3}]},
headers=headers,
)
assert upd.status_code == 200 and upd.json()["drives"][0]["name"] == "求知"
# 人格候选:pending → 批准后并入道体
cand = await client.post(
f"/v1/characters/{c['id']}/persona-candidates",
json={"identity_baseline": {"core_traits": ["温柔"]}},
headers=headers,
)
assert cand.status_code == 201 and cand.json()["status"] == "pending"
approve = await client.post(
f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions",
json={"approve": True},
headers=headers,
)
assert approve.json()["status"] == "approved"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["core_traits"] == ["温柔"]
# 已审核版本重复审核:409
again = await client.post(
f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions",
json={"approve": False},
headers=headers,
)
assert again.status_code == 409
async def test_cross_domain_character_404(self, client: AsyncClient) -> None:
alice = await login(client, "char8@example.com")
c = await self._create(client, alice, {"name": "澜川"})
await login(client, "char8-evil@example.com")
resp = await client.get(f"/v1/characters/{c['id']}")
assert resp.status_code in (401, 403, 404)
@pytest.mark.usefixtures("client")
class TestGatewayExecution:
async def test_model_call_usage_and_assistant_message(self, client, db) -> None:
"""端到端:mock 流 → ModelCall usage/TTFT + assistant 权威消息。"""
from sqlalchemy import select as sa
from app.models import Message as M
from app.models import ModelCall
headers = await login(client, "gw1@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-gw1")
run_id = resp.json()["run"]["id"]
assert await _wait_run_terminal(db, run_id) == "completed"
async with db() as session:
calls = list((await session.execute(sa(ModelCall))).scalars().all())
assert len(calls) == 1
assert calls[0].status == "succeeded"
assert calls[0].usage == {
"prompt_tokens": 12, "completion_tokens": 6, "total_tokens": 18,
}
assert calls[0].ttft_ms is not None
asst = list(
(
await session.execute(
sa(M).where(M.run_id == run_id, M.role == "assistant")
)
).scalars().all()
)
assert len(asst) == 1
assert asst[0].content[0]["type"] == "text"
assert asst[0].content[0]["status"] == "completed"
async def test_no_endpoint_fails_closed(self, client, db) -> None:
"""无可用端点:Run 显式失败并留 error 事件,不静默降级(ADR-016)。"""
from sqlalchemy import update
from app.models import ProviderEndpoint
async with db() as session:
await session.execute(update(ProviderEndpoint).values(enabled=False))
await session.commit()
try:
headers = await login(client, "gw2@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-gw2")
run_id = resp.json()["run"]["id"]
assert await _wait_run_terminal(db, run_id) == "failed"
finally:
async with db() as session:
await session.execute(update(ProviderEndpoint).values(enabled=True))
await session.commit()
from sqlalchemy import select as sa_select
from app.models import RunEvent as RunEventModel
async with db() as session:
evs = list(
(
await session.execute(
sa_select(RunEventModel)
.where(RunEventModel.run_id == run_id)
.order_by(RunEventModel.sequence)
)
).scalars().all()
)
types = [e.type for e in evs]
assert "content.block.failed" in types
failed = next(e for e in evs if e.type == "content.block.failed")
assert failed.data["error"] == "no_endpoint_available"
@contextlib.asynccontextmanager
async def _test_db():
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from tests.conftest import TEST_DATABASE_URL
engine = create_async_engine(TEST_DATABASE_URL)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield factory
finally:
await engine.dispose()
@pytest.mark.usefixtures("client")
class TestMemories:
async def _submit(self, client, headers, body) -> dict:
resp = await client.post("/v1/memories/candidates", json=body, headers=headers)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_manual_candidate_review_flow(self, client: AsyncClient) -> None:
headers = await login(client, "mem1@example.com")
c = await self._submit(client, headers, {
"kind": "relationship", "sensitivity": "high",
"payload": {"text": "用户偏好深夜交谈", "note": "高影响关系结论"},
})
assert c["status"] == "pending" # 敏感默认人工
approve = await client.post(
f"/v1/memories/candidates/{c['id']}/decisions",
json={"approve": True}, headers=headers,
)
assert approve.status_code == 201
assert approve.json()["status"] == "approved"
assert approve.json()["created_memory_id"] is not None
async def test_whitelist_auto_approve_only_low_sensitivity_fact(self, client) -> None:
headers = await login(client, "mem2@example.com")
auto = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "用户住在杭州"},
})
assert auto["status"] == "approved" # 白名单自动写入
assert auto["created_memory_id"] is not None
# 高敏感自动候选不自动批准
sensitive = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "high", "source": "auto",
"payload": {"text": "敏感事实"},
})
assert sensitive["status"] == "pending"
# 非 fact 也不自动
pref = await self._submit(client, headers, {
"kind": "preference", "sensitivity": "low", "source": "auto",
"payload": {"text": "喜欢猫"},
})
assert pref["status"] == "pending"
async def test_correction_with_etag_and_conflict(self, client) -> None:
headers = await login(client, "mem3@example.com")
c = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "原记录"},
})
mid = c["created_memory_id"]
# 缺 If-Match:412
no_header = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}}, headers=headers
)
assert no_header.status_code == 412
# 错误版本:412
stale = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}},
headers={**headers, "If-Match": '"99"'},
)
assert stale.status_code == 412
# 正确 ETag → v2
ok = await client.patch(
f"/v1/memories/{mid}", json={"payload": {"text": "纠正后"}},
headers={**headers, "If-Match": '"1"'},
)
assert ok.status_code == 200
body = ok.json()
assert body["version"] == 2 and body["payload"]["text"] == "纠正后"
assert body["resource_version"] == 2
async def test_search_filters_and_isolation(self, client) -> None:
headers = await login(client, "mem4@example.com")
for text in ("杭州西湖", "喜欢跑步", "工作在软件园"):
await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": text},
})
hits = await client.get("/v1/memories?q=西湖", headers=headers)
data = hits.json()["data"]
assert len(data) == 1 and "西湖" in data[0]["payload"]["text"]
assert data[0]["recall"]["channel"] == "text" # 召回证据
# 跨域隔离
other = await login(client, "mem4-evil@example.com")
leak = await client.get("/v1/memories?q=西湖", headers=other)
assert leak.json()["data"] == []
async def test_delete_purges_content_and_blocks_recall(self, client) -> None:
from sqlalchemy import select as sa_select
from app.models import MemoryVersion as MV
headers = await login(client, "mem5@example.com")
c = await self._submit(client, headers, {
"kind": "fact", "sensitivity": "low", "source": "auto",
"payload": {"text": "将被删除的秘密"},
})
mid = c["created_memory_id"]
resp = await client.delete(f"/v1/memories/{mid}", headers=headers)
assert resp.status_code == 204
# 不再召回
after = await client.get("/v1/memories?q=秘密", headers=headers)
assert after.json()["data"] == []
# 墓碑:版本原文已清除(契约:墓碑不存被删原文)
async with _test_db() as factory:
async with factory() as session:
versions = list(
(
await session.execute(sa_select(MV).where(MV.memory_id == mid))
).scalars().all()
)
assert versions and all(v.payload == {} for v in versions)
@pytest.mark.usefixtures("client")
class TestBigCycle:
async def test_manual_trigger_produces_pending_candidate(self, client, db) -> None:
from app.models import BigCycle, CharacterVersion
headers = await login(client, "bc1@example.com")
c = await client.post("/v1/characters", json={"name": "砚舟"}, headers=headers)
cid = c.json()["id"]
await client.post(f"/v1/characters/{cid}/activations", headers=headers)
# 先有一条对话消息供回顾
conv = await _create_conversation(client, headers)
await client.post(
f"/v1/conversations/{conv}/messages",
json={"content": [{"type": "text", "text": "最近在想什么呢"}], "start_run": False},
headers={**headers, "Idempotency-Key": "idem-bc1"},
)
trigger = await client.post(f"/v1/characters/{cid}/big-cycles", headers=headers)
assert trigger.status_code == 201, trigger.text
cycle_id = trigger.json()["id"]
# 轮询等待 cycle 终态
import asyncio
deadline = asyncio.get_event_loop().time() + 10
cycle = None
while asyncio.get_event_loop().time() < deadline:
async with db() as session:
cycle = await session.get(BigCycle, cycle_id)
if cycle and cycle.status in {"completed", "failed"}:
break
await asyncio.sleep(0.05)
assert cycle is not None and cycle.status == "completed", (
cycle.status if cycle else "missing"
)
assert cycle.produced_version_ids
async with db() as session:
version = await session.get(CharacterVersion, cycle.produced_version_ids[0])
assert version is not None
assert version.kind == "persona_candidate"
assert version.status == "pending" # 候选先于写入
assert version.payload["source"] == "big_cycle"
assert version.payload["proposal"] # 模型真实输出
@pytest.mark.usefixtures("client")
class TestTools:
async def _make_run(self, client, headers) -> str:
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, f"idem-tool-{_uuid4().split(chr(45))[0]}")
await _wait_run_terminal_headers(client, headers)
return resp.json()["run"]["id"]
async def test_rule_bound_tool_executes_immediately(self, client: AsyncClient) -> None:
headers = await login(client, "tool1@example.com")
await client.get("/v1/tools", headers=headers) # 触发内置登记
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": "(1+2)*3"}},
headers={**headers, "Idempotency-Key": "tc-1"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "succeeded"
assert body["output"]["result"] == 9.0
async def test_side_effect_tool_requires_approval(self, client: AsyncClient) -> None:
headers = await login(client, "tool2@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "重要笔记"}},
headers=headers,
)
assert created.json()["status"] == "pending_approval"
call_id = created.json()["id"]
# 待审批列表可见
pending = await client.get("/v1/tool-calls", headers=headers)
assert any(c["id"] == call_id for c in pending.json()["data"])
# 朱批 → 执行 → 真实副作用(记忆候选创建)
approved = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert approved.json()["status"] == "succeeded"
assert approved.json()["output"]["memory_candidate_id"]
async def test_denied_tool_never_executes(self, client: AsyncClient) -> None:
headers = await login(client, "tool3@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "被拒绝的笔记"}},
headers=headers,
)
call_id = created.json()["id"]
denied = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": False}, headers=headers
)
assert denied.json()["status"] == "denied"
assert denied.json()["output"] is None # 未执行
again = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert again.status_code == 409 # 拒绝不可逆
async def test_schema_validation_rejects_bad_input(self, client) -> None:
headers = await login(client, "tool4@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
missing = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {}}, headers=headers,
)
assert missing.status_code == 422
wrong_type = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": 42}}, headers=headers,
)
assert wrong_type.status_code == 422
async def test_unknown_tool_404(self, client) -> None:
headers = await login(client, "tool5@example.com")
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "rm-rf", "input": {}}, headers=headers,
)
assert resp.status_code == 404
async def test_idempotent_replay_returns_original(self, client) -> None:
from fastapi.responses import JSONResponse # noqa: F401
headers = await login(client, "tool6@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
first = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert first.status_code == 201
second = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert second.status_code == 200
assert second.json()["id"] == first.json()["id"]
async def test_run_budget_hard_limit(self, client) -> None:
headers = await login(client, "tool7@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
# max_tool_calls 默认 10:连续调用到上限后第 11 次被拒
last_status = None
for i in range(11):
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": f"tc-budget-{i}"},
)
last_status = resp.status_code
assert last_status == 422
def _uuid4() -> str:
from uuid import uuid4
return str(uuid4())
async def _wait_run_terminal_headers(client, headers, limit_s: float = 10.0) -> None:
import asyncio
await asyncio.sleep(0)
@pytest.mark.usefixtures("client")
class TestMarketplace:
async def _make_listing(self, client, headers, price=1000) -> str:
resp = await client.post(
"/v1/marketplace/listings",
json={"title": "角色包·昭星", "price_minor": price}, headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def _make_order(self, client, headers, listing_id, key) -> dict:
resp = await client.post(
"/v1/marketplace/orders",
json={"listing_ids": [listing_id]},
headers={**headers, "Idempotency-Key": key},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
async def test_purchase_ledger_double_entry(self, client, db) -> None:
headers = await login(client, "mkt1@example.com")
lid = await self._make_listing(client, headers, price=1000)
order = await self._make_order(client, headers, lid, "ord-1")
assert order["status"] == "pending_payment" and order["total_minor"] == 1000
paid = await client.post(
f"/v1/marketplace/orders/{order['id']}/payment-confirmations", headers=headers
)
assert paid.json()["status"] == "paid"
summary = await client.get("/v1/finance/summary", headers=headers)
totals = summary.json()["totals_minor"]
assert totals["charge"] == 1000
assert totals["platform_fee"] == 100 # 10%
assert totals["creator_payable"] == 900
assert summary.json()["balanced"] is True
async def test_order_idempotent_replay(self, client) -> None:
headers = await login(client, "mkt2@example.com")
lid = await self._make_listing(client, headers)
first = await self._make_order(client, headers, lid, "ord-replay")
second = await client.post(
"/v1/marketplace/orders", json={"listing_ids": [lid]},
headers={**headers, "Idempotency-Key": "ord-replay"},
)
assert second.status_code == 200
assert second.json()["id"] == first["id"]
async def test_refund_writes_reversal_entries(self, client) -> None:
headers = await login(client, "mkt3@example.com")
lid = await self._make_listing(client, headers, price=2000)
order = await self._make_order(client, headers, lid, "ord-refund")
await client.post(
f"/v1/marketplace/orders/{order['id']}/payment-confirmations", headers=headers
)
refunded = await client.post(
f"/v1/marketplace/orders/{order['id']}/refunds", headers=headers
)
assert refunded.json()["status"] == "refunded"
# 重复退款幂等
again = await client.post(
f"/v1/marketplace/orders/{order['id']}/refunds", headers=headers
)
assert again.json()["status"] == "refunded"
summary = await client.get("/v1/finance/summary", headers=headers)
totals = summary.json()["totals_minor"]
# charge=2000, reversal=-2000 → 净 0;fee/payable 各自正负抵消
assert totals.get("charge", 0) + totals.get("reversal", 0) == 0
assert summary.json()["balanced"] is True
async def test_webhook_signature_and_idempotency(self, client) -> None:
import hashlib
import hmac as hmac_mod
import os
headers = await login(client, "mkt4@example.com")
lid = await self._make_listing(client, headers, price=500)
order = await self._make_order(client, headers, lid, "ord-wh")
payload = json.dumps({
"provider_event_id": "evt-001", "type": "payment.succeeded",
"order_id": order["id"],
})
secret = os.environ.get("HUNJING_WEBHOOK_SECRET", "whsec_test")
os.environ["HUNJING_WEBHOOK_SECRET"] = "whsec_test"
sig = hmac_mod.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
# 错误签名:401
bad = await client.post(
"/v1/webhooks/v1/payments/mock", content=payload,
headers={"X-Signature": "deadbeef", "Content-Type": "application/json"},
)
assert bad.status_code == 401
# 正确签名:200 处理
ok = await client.post(
"/v1/webhooks/v1/payments/mock", content=payload,
headers={"X-Signature": sig, "Content-Type": "application/json"},
)
assert ok.status_code == 200 and ok.json()["result"] == "processed"
# 重复投递:幂等 duplicate
dup = await client.post(
"/v1/webhooks/v1/payments/mock", content=payload,
headers={"X-Signature": sig, "Content-Type": "application/json"},
)
assert dup.json()["result"] == "duplicate"
async def test_preservation_readonly_and_recovery(self, client) -> None:
headers = await login(client, "mkt5@example.com")
await client.post(
"/v1/finance/preservation", json={"preserved": True}, headers=headers
)
# 状态变更被拒绝(保全只读)
blocked = await client.post(
"/v1/conversations", json={"title": "保全期"}, headers=headers
)
assert blocked.status_code == 403
assert "保全" in blocked.json()["detail"]
# 读取不受影响:数据仍在
listing_resp = await client.post( # 验证写拦截一致
"/v1/marketplace/listings", json={"title": "x"}, headers=headers
)
assert listing_resp.status_code == 403
# 恢复:写恢复
await client.post(
"/v1/finance/preservation", json={"preserved": False}, headers=headers
)
recovered = await client.post(
"/v1/conversations", json={"title": "恢复"}, headers=headers
)
assert recovered.status_code == 201