130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 项目 -> 会话 -> 分支三级树黄金测试(plan.md §4.x / 矩阵 51 行)
|
|||
|
|
# 覆盖:BranchStore 生命周期(fork/rename/switch/discard/merge)、持久化、
|
|||
|
|
# gateway 分支 API + 审计、主干保护。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from server.state.branches import (
|
|||
|
|
BranchConflictError,
|
|||
|
|
BranchStore,
|
|||
|
|
)
|
|||
|
|
from tests.auth_provider import install_test_auth
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture()
|
|||
|
|
def secure_app(tmp_path, monkeypatch):
|
|||
|
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db"))
|
|||
|
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
|||
|
|
monkeypatch.setenv("APS_BRANCH_DIR", str(tmp_path / "branches"))
|
|||
|
|
from server.db.database import reset_engine
|
|||
|
|
from server.state import store as world_store
|
|||
|
|
|
|||
|
|
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
|
|||
|
|
world_store._stores.clear()
|
|||
|
|
reset_engine()
|
|||
|
|
from server.gateway.app import create_app
|
|||
|
|
app = create_app()
|
|||
|
|
yield app
|
|||
|
|
reset_engine()
|
|||
|
|
world_store._stores.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _login(client: TestClient, username: str = "planner") -> None:
|
|||
|
|
r = client.post("/api/auth/login", json={
|
|||
|
|
"method": "password", "username": username, "password": "test",
|
|||
|
|
})
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_branch_store_lifecycle(tmp_path: Path):
|
|||
|
|
"""分支树生命周期:fork/rename/switch/merge/discard + 主干保护 + 持久化。"""
|
|||
|
|
path = str(tmp_path / "branches.json")
|
|||
|
|
bs = BranchStore(path)
|
|||
|
|
bs.ensure_session("s1")
|
|||
|
|
t = bs.tree("s1")
|
|||
|
|
root_id = t["treeRoot"]
|
|||
|
|
assert t["activeNode"] == root_id
|
|||
|
|
|
|||
|
|
b1 = bs.fork("s1", "方案A", checkpoint_id="ckpt-1")
|
|||
|
|
assert b1["parentId"] == root_id
|
|||
|
|
assert bs.active("s1") == b1["id"]
|
|||
|
|
|
|||
|
|
bs.rename("s1", b1["id"], "方案A-改")
|
|||
|
|
assert bs._branch("s1", b1["id"])["name"] == "方案A-改"
|
|||
|
|
|
|||
|
|
bs.switch("s1", root_id)
|
|||
|
|
assert bs.active("s1") == root_id
|
|||
|
|
|
|||
|
|
merged = bs.merge("s1", b1["id"], root_id)
|
|||
|
|
assert merged["source"]["status"] == "merged"
|
|||
|
|
assert bs.active("s1") == root_id
|
|||
|
|
|
|||
|
|
with pytest.raises(BranchConflictError):
|
|||
|
|
bs.discard("s1", root_id) # 主干不可丢弃
|
|||
|
|
|
|||
|
|
b2 = bs.fork("s1", "临时分支")
|
|||
|
|
bs.switch("s1", root_id)
|
|||
|
|
bs.discard("s1", b2["id"])
|
|||
|
|
assert bs._branch("s1", b2["id"])["status"] == "discarded"
|
|||
|
|
|
|||
|
|
# 持久化:重新加载
|
|||
|
|
bs2 = BranchStore(path)
|
|||
|
|
t2 = bs2.tree("s1")
|
|||
|
|
assert len(t2["branches"]) == 3 # root + b1(merged) + b2(discarded)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_branch_api_full_flow(secure_app):
|
|||
|
|
"""gateway 分支 API:建会话 -> 读树 -> fork -> switch -> merge -> discard + 审计。"""
|
|||
|
|
client = TestClient(secure_app)
|
|||
|
|
_login(client)
|
|||
|
|
|
|||
|
|
created = client.post("/api/projects", json={"id": "proj-br", "name": "分支项目"})
|
|||
|
|
assert created.status_code == 200
|
|||
|
|
sid = created.json()["session"]["id"]
|
|||
|
|
|
|||
|
|
tree = client.get(f"/api/sessions/{sid}/branches")
|
|||
|
|
assert tree.status_code == 200
|
|||
|
|
root_id = tree.json()["tree"]["treeRoot"]
|
|||
|
|
|
|||
|
|
forked = client.post(f"/api/sessions/{sid}/branches", json={"name": "方案B"})
|
|||
|
|
assert forked.status_code == 200
|
|||
|
|
bid = forked.json()["branch"]["id"]
|
|||
|
|
|
|||
|
|
switched = client.post(f"/api/sessions/{sid}/branches/{bid}/switch")
|
|||
|
|
assert switched.status_code == 200
|
|||
|
|
|
|||
|
|
merged = client.post(f"/api/sessions/{sid}/branches/{bid}/merge", json={"targetId": root_id})
|
|||
|
|
assert merged.status_code == 200
|
|||
|
|
assert merged.json()["source"]["status"] == "merged"
|
|||
|
|
|
|||
|
|
f2 = client.post(f"/api/sessions/{sid}/branches", json={"name": "临时"})
|
|||
|
|
b2 = f2.json()["branch"]["id"]
|
|||
|
|
client.post(f"/api/sessions/{sid}/branches/{root_id}/switch")
|
|||
|
|
discarded = client.post(f"/api/sessions/{sid}/branches/{b2}/discard")
|
|||
|
|
assert discarded.status_code == 200
|
|||
|
|
assert discarded.json()["branch"]["status"] == "discarded"
|
|||
|
|
|
|||
|
|
# 分支操作写审计
|
|||
|
|
audit = client.get("/api/gov/audit?limit=50").json()["events"]
|
|||
|
|
branch_audits = [e for e in audit if e["action"].startswith("branch.")]
|
|||
|
|
actions = {e["action"] for e in branch_audits}
|
|||
|
|
assert {"branch.fork", "branch.switch", "branch.merge", "branch.discard"} <= actions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_branch_api_rejects_discard_active(tmp_path, secure_app):
|
|||
|
|
"""API 层:丢弃当前活动分支被拒(409)。"""
|
|||
|
|
client = TestClient(secure_app)
|
|||
|
|
_login(client)
|
|||
|
|
created = client.post("/api/projects", json={"id": "proj-x", "name": "项目X"})
|
|||
|
|
sid = created.json()["session"]["id"]
|
|||
|
|
forked = client.post(f"/api/sessions/{sid}/branches", json={"name": "活动分支"})
|
|||
|
|
bid = forked.json()["branch"]["id"]
|
|||
|
|
r = client.post(f"/api/sessions/{sid}/branches/{bid}/discard")
|
|||
|
|
assert r.status_code == 409
|