M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)
This commit is contained in:
1 parent
c090893e63
commit
1cd0069d65
24 files changed
+1508
-9
No files matched your search
@@ -0,0 +1,344 @@
|
||||
"""镜生域 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
|
||||
Reference in new issue
Block a user