aps-agent/tests/golden/test_branch_tree.py

130 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 项目 -> 会话 -> 分支三级树黄金测试(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