Files
account-manager/tests/test_v1_compatibility.py
chendelian 4fe1351e1e
All checks were successful
技能自动化发布 / release (push) Successful in 9s
test(compat): add v1 compatibility suite; docs: add V1_COMPATIBILITY.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:52:22 +08:00

333 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""v1 CLI compatibility — subprocess-only, mirrors monitor-competitor-rates calling patterns."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor, wait
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
MAIN_PY = os.path.join(_scripts, "main.py")
def _cli_env(roots: dict) -> dict:
e = {**os.environ}
e["JIANGCHANG_DATA_ROOT"] = roots["data_root"]
e["JIANGCHANG_USER_ID"] = roots["user_id"]
e["CLAW_DATA_ROOT"] = roots["data_root"]
e["CLAW_USER_ID"] = roots["user_id"]
return e
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess:
inp = stdin.encode("utf-8") if stdin is not None else None
return subprocess.run(
[sys.executable, MAIN_PY, *args],
input=inp,
capture_output=True,
env=env,
cwd=os.path.dirname(_scripts),
)
def _parse_last_json(stdout: str) -> dict:
lines = [ln for ln in (stdout or "").strip().split("\n") if ln.strip()]
assert lines, "stdout had no non-empty lines"
return json.loads(lines[-1])
def run_cli_json_last(env: dict, args: list[str], *, stdin: str | None = None) -> dict:
"""Parse **last** stdout line as JSON (monitor `_parse_stdout_json` semantics)."""
r = _run_cli(env, args, stdin=stdin)
assert r.returncode == 0, (r.stdout, r.stderr)
return _parse_last_json(r.stdout.decode("utf-8"))
@pytest.fixture()
def iso_cli_env():
tmp = tempfile.mkdtemp(prefix="v1_compat_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
uid = "compat_user"
yield {"data_root": data_root, "user_id": uid, "tmp_dir": tmp}
shutil.rmtree(tmp, ignore_errors=True)
def _add_monitor_account(env: dict) -> dict:
out = run_cli_json_last(
env,
[
"account",
"add-web",
"--platform",
"maersk",
"--label",
"monitor-test",
"--environment",
"simulator",
"--role",
"booking",
"--tenant-id",
"tenant-test",
],
)
assert out.get("success") is True
return out["data"]
class TestV1PickWeb:
def test_pick_web_returns_v1_shape(self, iso_cli_env):
env = _cli_env(iso_cli_env)
add_data = _add_monitor_account(env)
aid = add_data["id"]
assert isinstance(aid, int)
pick = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--purpose",
"maersk_sim_rpa",
"--ttl-sec",
"1800",
],
)
for key in ("id", "platform_key", "environment", "role", "profile_dir", "lease_token"):
assert key in pick, f"pick-web 缺少 v1 字段 {key}"
assert isinstance(pick["id"], int)
assert isinstance(pick["lease_token"], str) and len(pick["lease_token"]) > 0
assert pick["platform_key"] == "maersk"
assert pick["environment"] == "simulator"
assert pick["role"] == "booking"
for v2_key in ("auth_strategy", "session_persistent", "device_fingerprint"):
assert v2_key in pick, f"v2 字段 {v2_key} 应在 pick-web 返回中"
assert len(pick) >= 10
def test_pick_web_no_account_returns_legacy_error_code(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
],
)
assert proc.returncode == 0
payload = _parse_last_json(proc.stdout.decode("utf-8"))
assert payload.get("success") is False
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_pick_web_lease_race_returns_legacy_error_shape(self, iso_cli_env):
"""Parallel picks share one account — second outcome may be NO_ACCOUNT or LEASE_CONFLICT."""
env = _cli_env(iso_cli_env)
_add_monitor_account(env)
args = [
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"3600",
]
def _pick():
return _run_cli(env, args)
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_pick)
f2 = ex.submit(_pick)
wait([f1, f2])
proc_a = f1.result()
proc_b = f2.result()
payloads = []
for proc in (proc_a, proc_b):
assert proc.returncode == 0
payloads.append(_parse_last_json(proc.stdout.decode("utf-8")))
codes = []
successes = 0
for p in payloads:
if p.get("success") is False:
codes.append(p["error"]["code"])
elif "lease_token" in p:
successes += 1
assert successes >= 1
assert "ERROR:LEASE_CONFLICT" in codes or "ERROR:NO_ACCOUNT" in codes
class TestV1LeaseAndMarks:
def test_lease_release_returns_success_json(self, iso_cli_env):
env = _cli_env(iso_cli_env)
_add_monitor_account(env)
pick = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"900",
],
)
tok = pick["lease_token"]
rel = run_cli_json_last(env, ["lease", "release", tok])
assert rel.get("success") is True
assert rel["data"]["status"] == "released"
def test_mark_used_returns_success(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
out = run_cli_json_last(env, ["account", "mark-used", str(aid)])
assert out.get("success") is True
assert int(out["data"]["id"]) == aid
def test_mark_session_accepts_v1_status_values(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
for status in ("likely_valid", "needs_login", "unknown"):
out = run_cli_json_last(
env,
["account", "mark-session", str(aid), "--session-status", status],
)
assert out.get("success") is True
assert out["data"]["session_status"] == status
def test_mark_error_accepts_long_message(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
long_msg = "x" * 600
out = run_cli_json_last(
env,
[
"account",
"mark-error",
str(aid),
"--code",
"ERR_TEST",
"--message",
long_msg,
],
)
assert out.get("success") is True
assert out["data"]["error_code"] == "ERR_TEST"
class TestV1EndToEnd:
def test_full_monitor_sequence_end_to_end(self, iso_cli_env):
env = _cli_env(iso_cli_env)
aid = _add_monitor_account(env)["id"]
pick1 = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--purpose",
"maersk_sim_rpa",
"--ttl-sec",
"900",
],
)
tok = pick1["lease_token"]
assert pick1["id"] == aid
run_cli_json_last(env, ["account", "mark-used", str(aid)])
run_cli_json_last(
env,
["account", "mark-session", str(aid), "--session-status", "likely_valid"],
)
run_cli_json_last(env, ["lease", "release", tok])
pick2 = run_cli_json_last(
env,
[
"account",
"pick-web",
"--platform",
"maersk",
"--environment",
"simulator",
"--role",
"booking",
"--lease",
"--holder",
"monitor-competitor-rates",
"--ttl-sec",
"900",
],
)
assert "lease_token" in pick2
acc = run_cli_json_last(env, ["account", "get", str(aid)])
assert acc.get("session_status") == "likely_valid"
class TestStdoutIsolation:
def test_stderr_does_not_pollute_stdout_last_line(self, iso_cli_env):
env = _cli_env(iso_cli_env)
proc = _run_cli(env, ["account", "export-credentials", "--plaintext"])
assert proc.returncode == 0
assert b"[export] WARNING" in proc.stderr
lines = [ln for ln in proc.stdout.decode("utf-8").strip().split("\n") if ln.strip()]
payload = json.loads(lines[-1])
assert isinstance(payload, list)