Files
hunjing/backend/tests/test_jingsheng.py
T

345 lines
14 KiB
Python

"""镜生域 M1 测试。
对应 rewrite-plan M1 验收:
- 启镜前无正式人物 ID(灵胚/试演阶段零 character 行、零 character.activated 事件);
- 并发启镜只有一个赢家(原子闸门 409);命台租约冲突被拒且留痕(seat.contested);
- 魂籍可验证、篡改与移植冒充均被拒(摘要+签名双校验)。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import timedelta
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng import bind_event_store
from app.kernel import Kernel, KernelEventLog
from app.models.character import Character
from app.models.jingsheng import Custodianship, IdentityRecord, RuntimeSeat
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
@pytest.fixture
async def jingsheng_client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
bind_event_store(event_store)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _create_embryo(client: AsyncClient, name: str = "试灵") -> dict:
resp = await client.post(
"/api/v1/embryo-drafts",
json={"source": "original", "name": name, "identity_baseline": {"锚点": "温润"}},
)
assert resp.status_code == 201
return resp.json()
async def _characters_count() -> int:
from sqlalchemy import func, select
async with AsyncSessionLocal() as session:
return int(await session.scalar(select(func.count(Character.id))))
async def _events_of_type(event_type: str) -> list[dict]:
store = DbEventStore(AsyncSessionLocal)
all_events = []
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.runtime import Event
rows = (
(await session.execute(select(Event).where(Event.type == event_type))).scalars().all()
)
all_events = [json.loads(r.payload_json) for r in rows]
_ = store
return all_events
async def _wait_run(client: AsyncClient, run_id: str) -> str:
for _ in range(300):
resp = await client.get(f"/api/v1/runs/{run_id}")
status = resp.json()["status"]
if status in ("completed", "failed", "interrupted", "cancelled"):
return status
await asyncio.sleep(0.02)
raise AssertionError("Run 未到达终态")
# ── 启镜前无正式人物 ──────────────────────────────────────────────
async def test_embryo_and_rehearsal_create_no_character(jingsheng_client: AsyncClient) -> None:
before = await _characters_count()
embryo = await _create_embryo(jingsheng_client)
patched = await jingsheng_client.patch(
f"/api/v1/embryo-drafts/{embryo['id']}", json={"description": "照心共塑"}
)
assert patched.status_code == 200
rehearsed = await jingsheng_client.post(
f"/api/v1/embryo-drafts/{embryo['id']}/rehearse", json={"prompt": "你是谁?"}
)
assert rehearsed.status_code == 200
run_id = rehearsed.json()["run_id"]
assert await _wait_run(jingsheng_client, run_id) == "completed"
run_detail = (await jingsheng_client.get(f"/api/v1/runs/{run_id}")).json()
assert run_detail["kind"] == "rehearsal"
embryo_now = (await jingsheng_client.get(f"/api/v1/embryo-drafts/{embryo['id']}")).json()
assert embryo_now["status"] == "previewing"
assert embryo_now["rehearsals"][0]["run_id"] == run_id
# 验收:启镜前零人物行、零 character.activated 事件
assert await _characters_count() == before
assert await _events_of_type("character.activated") == []
# ── 启镜与并发防护 ──────────────────────────────────────────────
async def test_activate_issues_identity_and_guards(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="镜心")
activated = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")
assert activated.status_code == 200
character_id = activated.json()["character_id"]
assert activated.json()["source"] == "original"
async with AsyncSessionLocal() as session:
from sqlalchemy import select
character = await session.get(Character, character_id)
assert character is not None and character.status == "active"
assert character.identity_baseline_json == json.dumps({"锚点": "温润"}, ensure_ascii=False)
cust = (
await session.execute(
select(Custodianship).where(Custodianship.character_id == character_id)
)
).scalar_one()
assert cust.custodian_scope == character.owner_scope
seat = (
await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
).scalar_one()
assert seat.epoch == 1 and seat.holder is None
record = (
await session.execute(
select(IdentityRecord).where(IdentityRecord.character_id == character_id)
)
).scalar_one()
assert record.version == 1
events = await _events_of_type("character.activated")
assert any(e.get("embryo_id") == embryo["id"] for e in events)
# 二次启镜被拒
again = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")
assert again.status_code == 409
assert "HJ-1103" in again.json()["error"]["code"]
async def test_concurrent_activation_single_winner(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="并发灵")
results = await asyncio.gather(
jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"),
jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"),
)
statuses = sorted(r.status_code for r in results)
assert statuses == [200, 409]
winner = next(r for r in results if r.status_code == 200)
loser = next(r for r in results if r.status_code == 409)
assert winner.json()["character_id"]
assert "HJ-1103" in loser.json()["error"]["code"]
# ── 魂籍:验证、篡改与冒充 ──────────────────────────────────────────────
async def test_identity_record_verify_and_tamper(jingsheng_client: AsyncClient) -> None:
embryo = await _create_embryo(jingsheng_client, name="魂籍灵")
character_id = (
(await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()[
"character_id"
]
)
exported = await jingsheng_client.post(f"/api/v1/characters/{character_id}/identity-records")
assert exported.status_code == 200
record_id = exported.json()["id"]
ok = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify"
)
assert ok.status_code == 200 and ok.json()["valid"] is True
# 篡改 payload:摘要不匹配
async with AsyncSessionLocal() as session:
record = await session.get(IdentityRecord, record_id)
payload = json.loads(record.payload_json)
payload["identity"]["name"] = "冒充者"
record.payload_json = json.dumps(payload, ensure_ascii=False)
await session.commit()
tampered = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify"
)
assert tampered.status_code == 422
# 移植冒充:把整条记录复制到另一人物名下,签名不匹配
other = await _create_embryo(jingsheng_client, name="另一灵")
other_character = (
(await jingsheng_client.post(f"/api/v1/embryo-drafts/{other['id']}/activate")).json()[
"character_id"
]
)
async with AsyncSessionLocal() as session:
original = await session.get(IdentityRecord, record_id)
session.add(
IdentityRecord(
character_id=other_character,
version=9, # 避开另一人物已有的 v1;冒充场景本身与版本号无关
payload_json=original.payload_json,
digest=original.digest,
signature=original.signature, # 原样复制——文件复制不能冒充
)
)
await session.commit()
async with AsyncSessionLocal() as session:
from sqlalchemy import select
forged = (
await session.execute(
select(IdentityRecord).where(
IdentityRecord.character_id == other_character,
IdentityRecord.version == 9,
)
)
).scalar_one()
forged_id = forged.id
forged_check = await jingsheng_client.post(
f"/api/v1/characters/{other_character}/identity-records/{forged_id}/verify"
)
assert forged_check.status_code == 422
# ── 命台租约:冲突留痕、过期接管 ──────────────────────────────────────────────
async def _activated_character(client: AsyncClient, name: str) -> str:
embryo = await _create_embryo(client, name=name)
return (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
async def test_seat_lease_conflict_and_takeover(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "命台灵")
first = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"}
)
assert first.status_code == 200 and first.json()["epoch"] == 1
conflict = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"}
)
assert conflict.status_code == 429
assert conflict.json()["error"]["code"] == "HJ-1101"
# 验收:抢占被拒且留痕
async with AsyncSessionLocal() as session:
from sqlalchemy import select
from app.models.runtime import Event
rows = (
await session.execute(select(Event).where(Event.type == "seat.contested"))
).scalars().all()
assert any(json.loads(r.payload_json).get("requester") == "device-B" for r in rows)
# 同持有者续期:不冲突
renew = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"}
)
assert renew.status_code == 200
# 租约过期后接管:epoch 递增
async with AsyncSessionLocal() as session:
seat = (
await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
).scalar_one()
seat.lease_expires_at = seat.lease_expires_at - timedelta(seconds=9999)
await session.commit()
takeover = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"}
)
assert takeover.status_code == 200 and takeover.json()["epoch"] == 2
# ── 死亡:冷静期、撤销与落定 ──────────────────────────────────────────────
async def test_death_calm_period_flow(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "将别灵")
requested = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request")
assert requested.status_code == 200
assert requested.json()["status"] == "death_pending"
early = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"}
)
assert early.status_code == 422 # 冷静期未满
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
character.calm_period_until = character.calm_period_until - timedelta(hours=100)
await session.commit()
wrong_phrase = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "错误名"}
)
assert wrong_phrase.status_code == 400
finalized = await jingsheng_client.post(
f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"}
)
assert finalized.status_code == 200
assert finalized.json()["status"] == "deceased"
# 死亡是身份闭合,不是数据删除:行仍在
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
assert character is not None and character.status == "deceased"
assert character.died_at is not None
events = await _events_of_type("character.deceased")
assert events
async def test_death_cancel_restores_active(jingsheng_client: AsyncClient) -> None:
character_id = await _activated_character(jingsheng_client, "回心灵")
await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request")
cancelled = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-cancel")
assert cancelled.status_code == 200 and cancelled.json()["status"] == "active"
events = await _events_of_type("character.death_cancelled")
assert events