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
|