muse-agent-example/tests/端到端/test_交付归档与恢复.py
zizi 9e6f1c4481 R2 改造交付:新版模块化单体全量成果
- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具)
- 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺
- 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口
- 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影)
- 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威)
- R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
2026-09-15 12:47:42 +08:00

299 lines
11 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.

"""两作品经浏览器带走完整备份,再在隔离新库读取决定并续接交付。"""
import json
import os
import socket
import subprocess
import sys
import time
from contextlib import contextmanager
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from psycopg.conninfo import conninfo_to_dict, make_conninfo
from muse.交付连载.接口 import 交付材料, 定稿请求, 章节版本
from muse.作品规划.接口 import 档案保存
from muse.元数据.接口 import 元数据服务, 导入内置结构
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
from muse.启动 import 构建
from muse.基础设施.备份恢复 import 创建备份, 恢复备份, 解包备份
from muse.正式变更.接口 import 作者动作
from muse.正文写作.接口 import 候选定位, 文本节点, 正文草稿, 段落
from muse.编排.定稿交付 import 发起定稿导出
from muse.资源加载 import 加载清单
from muse.配置 import 应用配置, 数据库引用
pytestmark = [pytest.mark.数据库, pytest.mark.浏览器, pytest.mark.timeout(150)]
根 = Path(__file__).resolve().parents[2]
@contextmanager
def 启动工作台(config, directory, base):
with (directory / "server.log").open("ab") as log:
process = subprocess.Popen(
[sys.executable, "-I", "-m", "muse", "服务", str(config)],
cwd=directory,
stdout=log,
stderr=subprocess.STDOUT,
)
try:
for _ in range(100):
if process.poll() is not None:
pytest.fail("验收工作台提前退出,请查看server.log")
try:
if httpx.get(base + "/ready", trust_env=False, timeout=1).status_code == 200:
break
except httpx.TransportError:
pass
time.sleep(0.1)
else:
pytest.fail("验收工作台未就绪")
yield
finally:
process.terminate()
try:
process.wait(10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(5)
pytest.fail("验收工作台未正常停止")
def 写应用配置(path, reference, password, base, backup_root):
path.write_text(
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
+ json.dumps(reference.位置)
+ '\n["资源"]\n"发布身份"='
+ json.dumps(加载清单()["构建身份"])
+ '\n["文件"]\n"备份目录"='
+ json.dumps(str(backup_root))
+ '\n["HTTP"]\n"口令文件"='
+ json.dumps(str(password))
+ '\n"作者ID"="restore-author"\n"端口"='
+ base.rsplit(":", 1)[1]
+ '\n"公开地址"='
+ json.dumps(base)
+ '\n"允许来源"=['
+ json.dumps(base)
+ "]\n"
)
path.chmod(0o600)
def test_两作品浏览器下载后恢复决定原文并续跑原交付__29d001(应用测试库, 新空目标库, tmp_path):
if os.environ.get("MUSE_RUN_BROWSER") != "1":
pytest.skip("需显式启用浏览器验收")
pools = 应用测试库
with pools[用途.维护].连接() as conn, conn.transaction():
导入内置结构(元数据服务(conn))
author = 调用身份("restore-author", None, 用途.生产, 内容用途.生成)
app = 构建(应用配置(pools[用途.生产].引用, 加载清单()["构建身份"]))
works, body, delivery = app.要求作品(), app.要求正文(), app.要求交付()
expected = []
for number in range(2):
wid = f"restore-work-{number}"
works.保存档案(
author,
wid,
档案保存(
wid,
0,
{"名称": f"合成恢复作品{number}"},
works.新档案结构(author, wid, "work_core", 1),
),
)
chapters = []
for chapter in range(2):
cid = f"{wid}-chapter-{chapter}"
works.添加章节(author, cid, wid, cid, f"第{chapter + 1}章", 预期目录版本=chapter)
body.保存人工(
author,
cid + "-body",
cid,
0,
正文草稿(
(
段落("p1", (文本节点(f"作品{number}章{chapter}原文🙂e\u0301"),)),
段落("empty", ()),
段落("p2", (文本节点("保留末句。"),)),
)
),
)
proposed = body.创建人工候选(
author,
cid + "-candidate",
cid,
1,
正文草稿(
(
段落("p1", (文本节点(f"作品{number}章{chapter}确认正文🙂e\u0301"),)),
段落("empty", ()),
段落("p2", (文本节点("保留末句。"),)),
)
),
)
candidate = body.读取候选(author, proposed["results"][0]["candidate_id"])
location = 候选定位(
candidate["candidate_id"],
1,
candidate["candidate_hash"],
candidate["document_id"],
1,
)
review = body.打开候选审阅(author, location)
receipt = body.决定候选(
author,
cid + "-adopt",
location,
作者动作.采纳,
review["review_id"],
review["review_hash"],
("document",),
)
current = body.读取正文(author, cid)
chapters.append(章节版本(cid, current["revision"], current["document_hash"]))
expected.append(
{
"chapter": cid,
"body": current,
"location": location,
"review": review,
"receipt": receipt,
}
)
did = str(uuid4())
delivery.冻结定稿(
author,
wid + "-freeze",
定稿请求(
did,
wid,
2,
tuple(chapters),
交付材料(f"作品{number}", "合成作者", "简介", "梗概", "人物"),
),
)
queued = 发起定稿导出(app, author, wid + "-export", did, ["txt", "epub"])
expected[-1].update(delivery_id=did, task_id=queued["task_id"])
backups, raw, drafts = (tmp_path / n for n in ("backups", "raw", "drafts"))
for directory in (backups, raw, drafts):
directory.mkdir(mode=0o700)
(raw / "evidence.bin").write_bytes(b"\x00\xffraw-evidence")
(drafts / "草稿.md").write_text("仅用于验收的私人草稿。\n")
archive = 创建备份(
应用配置(
pools[用途.维护].引用,
加载清单()["构建身份"],
运行用途=用途.维护,
原文暂存=str(raw),
探索草稿=str(drafts),
),
backups / "完整备份",
)
broken = backups / "不完整备份"
broken.mkdir(mode=0o700)
(broken / "manifest.json").write_text("{}")
(broken / "manifest.json").chmod(0o600)
password = tmp_path / "password"
password.write_text("synthetic-backup-browser")
password.chmod(0o600)
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
base = f"http://127.0.0.1:{sock.getsockname()[1]}"
config = tmp_path / "source.toml"
写应用配置(config, pools[用途.生产].引用, password, base, backups)
output = Path(os.environ.get("MUSE_BROWSER_ARTIFACTS", str(tmp_path / "browser")))
output.mkdir(parents=True, exist_ok=True)
downloaded = output / "download.tar"
env = {
**os.environ,
"MUSE_WORKBENCH_URL": base,
"MUSE_AUTHOR_PASSWORD_FILE": str(password),
"MUSE_BACKUP_ID": archive["backup_id"],
"MUSE_BACKUP_DOWNLOAD_PATH": str(downloaded),
"MUSE_BROWSER_EXECUTABLE": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
}
with 启动工作台(config, tmp_path, base), (output / "浏览器.json").open("w") as report:
run = subprocess.run(
[
"pnpm",
"exec",
"playwright",
"test",
"备份恢复.spec.ts",
"--reporter=json",
"--output",
str(output / "产物"),
],
cwd=根 / "web",
env=env,
stdout=report,
stderr=subprocess.STDOUT,
timeout=90,
)
assert run.returncode == 0, "浏览器未通过,见浏览器.json"
unpacked = tmp_path / "download-unpacked"
解包备份(downloaded, unpacked, archive["backup_id"])
restored_raw, restored_drafts = tmp_path / "restored-raw", tmp_path / "restored-drafts"
restored = 恢复备份(
unpacked,
应用配置(
新空目标库.工厂.引用,
加载清单()["构建身份"],
运行用途=用途.维护,
原文暂存=str(restored_raw),
探索草稿=str(restored_drafts),
),
archive["backup_id"],
)
assert restored["status"] == "verified"
params = conninfo_to_dict(Path(新空目标库.工厂.引用.位置).read_text())
params["user"] = "muse_app"
reference = tmp_path / "restored-reference"
reference.write_text(make_conninfo(**params))
reference.chmod(0o600)
restored_reference = 数据库引用("受控存储", str(reference))
restored_app = 构建(应用配置(restored_reference, 加载清单()["构建身份"]))
for item in expected:
assert restored_app.要求正文().读取正文(author, item["chapter"]) == item["body"]
review = item["review"]
assert (
restored_app.要求正文().决定候选(
author,
item["chapter"] + "-adopt",
item["location"],
作者动作.采纳,
review["review_id"],
review["review_hash"],
("document",),
)
== item["receipt"]
)
写应用配置(config, restored_reference, password, base, backups)
with (
启动工作台(config, tmp_path, base),
httpx.Client(
base_url=base,
headers={"origin": base},
trust_env=False,
) as client,
):
assert (
client.post("/api/v1/session", json={"password": password.read_text()}).status_code
== 200
)
for item in expected:
if "task_id" not in item:
continue
path = "/api/v1/deliveries/" + item["delivery_id"]
result = client.post(path + "/exports/" + item["task_id"] + "/advance")
assert result.status_code == 200, result.text
assert result.json()["state"] == "completed"
assert client.get(path + "/files/epub").content[:2] == b"PK"
assert (restored_raw / "evidence.bin").read_bytes() == (raw / "evidence.bin").read_bytes()
assert (restored_drafts / "草稿.md").read_bytes() == (drafts / "草稿.md").read_bytes()