- 迁移 6:character(生命周期六态检查约束、起源、现实人物治理标记、道体基线、内驱力)+ character_version(baseline/first_statement/drive_revision/reforging/persona_candidate 五类不可变版本与审核态) - 创建:手动与随缘(确定性词库组合,同种子同候选、一次一个——不是抽卡);现实人物治理(已故拒绝、未声明授权拒绝) - 启镜(draft→active + 首个 baseline 版本)与初照自陈(候选→确认归真并入道体) - 生命周期端点:静修/归藏/复照(归档可恢复、复照不抹历史)+ 死亡两步确认(7 天冷静期可撤销、未满确认 422);非法状态迁移 422 - 铸魂(显式 confirm 强确认,直接更新道体)与人格候选(pending→审核批准才并入道体,不静默覆盖) - 内驱力编辑:写当前值并落 drive_revision 版本(可追溯,ADR-018) - 测试 52 项全绿(+8):状态机全路径、随缘确定性、现实人物三例、铸魂确认、候选审核与重复审核 409、跨域 fail-closed
610 lines
29 KiB
Python
610 lines
29 KiB
Python
"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。"""
|
||
|
||
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 _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) -> 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"] == "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) -> 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"]
|
||
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"] == "completed"
|
||
assert events[-1]["type"] == "message.completed"
|
||
|
||
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) -> 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"]
|
||
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"] == "message.completed"
|
||
# 用户消息两个 block:text 与(无 markdown 时)content.block.completed 数对应
|
||
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)
|