feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)

This commit is contained in:
host committed 2026-10-27 15:53:50 +08:00
1 parent 29efc6c61f
commit e0cecd70aa
18 files changed
+3474 -27

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
"""运行时审批队列模块单测:cloud/approvals.py。
不跑真实模型、不联网:内存 SQLite。覆盖批准放行、拒绝拦截、
回执留痕、二次决定不覆盖、超时默认拒绝、非法输入、回包映射。
"""
import sqlite3
import sys
import unittest
from pathlib import Path
try:
from cloud.approvals import (
command_response,
create_approval,
decide_approval,
elicitation_response,
ensure_approvals_table,
expire_overdue,
get_approval,
list_approvals,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.approvals import (
command_response,
create_approval,
decide_approval,
elicitation_response,
ensure_approvals_table,
expire_overdue,
get_approval,
list_approvals,
)
class ApprovalsModuleTestCase(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
self.conn.row_factory = sqlite3.Row
ensure_approvals_table(self.conn)
self.conn.commit()
def tearDown(self):
self.conn.close()
def _mk(self, **kw):
kw.setdefault("task_id", "task-1")
kw.setdefault("project_id", "proj-1")
kw.setdefault("summary", "MCP 服务请求表单输入")
return create_approval(self.conn, **kw)
def test_create_defaults_to_pending_with_timeout(self):
item = self._mk()
self.assertEqual(item["status"], "pending")
self.assertEqual(item["kind"], "elicitation")
self.assertIsNone(item["decided_by"])
self.assertIsNone(item["decision"])
self.assertIsNotNone(item["expires_at"])
self.assertGreater(item["expires_at"], item["created_at"])
pending = list_approvals(self.conn, task_id="task-1",
status="pending")
self.assertEqual(len(pending), 1)
def test_approve_releases_with_receipt(self):
item = self._mk()
status, decided = decide_approval(
self.conn, item["id"], "approved", "user-9")
self.assertEqual(status, "decided")
self.assertEqual(decided["status"], "approved")
self.assertEqual(decided["decision"], "approved")
self.assertEqual(decided["decided_by"], "user-9")
self.assertIsNotNone(decided["decided_at"])
self.assertEqual(elicitation_response(decided["decision"]),
{"action": "accept", "content": {}})
self.assertEqual(command_response(decided["decision"]),
{"decision": "accept"})
def test_deny_blocks_with_receipt(self):
item = self._mk()
status, decided = decide_approval(
self.conn, item["id"], "denied", "user-9")
self.assertEqual(status, "decided")
self.assertEqual(decided["status"], "denied")
self.assertEqual(decided["decision"], "denied")
self.assertEqual(decided["decided_by"], "user-9")
self.assertEqual(elicitation_response(decided["decision"]),
{"action": "decline"})
self.assertEqual(command_response(decided["decision"]),
{"decision": "decline"})
def test_second_decision_never_overwrites_first(self):
item = self._mk()
decide_approval(self.conn, item["id"], "approved", "user-9")
status, kept = decide_approval(
self.conn, item["id"], "denied", "user-7")
self.assertEqual(status, "already_decided")
self.assertEqual(kept["status"], "approved")
self.assertEqual(kept["decided_by"], "user-9")
def test_expire_overdue_defaults_to_deny_not_allow(self):
item = self._mk(now=1000.0, timeout_seconds=60)
self.assertEqual(item["expires_at"], 1060.0)
count = expire_overdue(self.conn, now=1061.0)
self.assertEqual(count, 1)
done = get_approval(self.conn, item["id"])
self.assertEqual(done["status"], "expired")
self.assertEqual(done["decision"], "denied")
self.assertEqual(done["decided_by"], "system-timeout")
self.assertEqual(elicitation_response(done["decision"]),
{"action": "decline"})
status, kept = decide_approval(
self.conn, item["id"], "approved", "user-9")
self.assertEqual(status, "already_decided")
self.assertEqual(kept["status"], "expired")
def test_not_yet_due_is_not_expired(self):
self._mk(now=1000.0, timeout_seconds=3600)
self.assertEqual(expire_overdue(self.conn, now=2000.0), 0)
self.assertEqual(len(list_approvals(self.conn, status="pending")),
1)
def test_decide_missing_is_not_found(self):
status, item = decide_approval(self.conn, "nope", "approved",
"user-9")
self.assertEqual(status, "not_found")
self.assertIsNone(item)
def test_invalid_inputs_raise_value_error(self):
with self.assertRaises(ValueError):
self._mk(kind="bogus")
with self.assertRaises(ValueError):
self._mk(summary=" ")
with self.assertRaises(ValueError):
self._mk(summary="x" * 2001)
with self.assertRaises(ValueError):
self._mk(timeout_seconds=-1)
with self.assertRaises(ValueError):
decide_approval(self.conn, "x", "maybe", "user-9")
def test_ensure_is_idempotent_and_keeps_rows(self):
item = self._mk()
ensure_approvals_table(self.conn)
self.conn.commit()
self.assertEqual(get_approval(self.conn, item["id"])["id"],
item["id"])
def test_response_mapping_never_defaults_to_allow(self):
for weird in ("expired", "bogus", None, ""):
self.assertEqual(elicitation_response(weird),
{"action": "decline"})
self.assertEqual(command_response(weird),
{"decision": "decline"})
if __name__ == "__main__":
unittest.main()
+222
View File
@@ -0,0 +1,222 @@
"""运行时审批队列服务端单测:审批端点。
不跑真实模型、不联网:本地临时端口 + 临时 SQLite,与
test_peer_messages_server.py 同款骨架。覆盖创建、列表、批准、
拒绝、回执、二次决定 409、过期扫荡、权限、校验、跨任务隔离。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.approvals import ensure_approvals_table
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.approvals import ensure_approvals_table
from cloud.auth import AuthStore
from cloud.server import create_server
class ApprovalsServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user(
"outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create_task(self, project_id):
status, task = self._req(
"POST", "/tasks",
{"project_id": project_id, "prompt": "做事"}, self.owner_key)
self.assertEqual(status, 201, task)
return task
def _create_approval(self, task_id, **extra):
body = {"summary": "MCP 服务请求表单输入"}
body.update(extra)
return self._req(
"POST", "/tasks/%s/approvals" % task_id, body, self.owner_key)
def _insert_expired_row(self, task_id, project_id):
conn = sqlite3.connect(self.db_path)
try:
ensure_approvals_table(conn)
conn.execute(
"INSERT INTO task_approvals(id, task_id, project_id,"
" kind, summary, detail, status, requested_by,"
" decided_by, decided_at, decision,"
" created_at, updated_at, expires_at)"
" VALUES(?, ?, ?, 'elicitation', '过期项', NULL,"
" 'pending', NULL, NULL, NULL, NULL, ?, ?, ?)",
("expired-1", task_id, project_id,
time.time() - 7200, time.time() - 7200,
time.time() - 3600))
conn.commit()
finally:
conn.close()
def test_create_list_approve_receipt(self):
task = self._create_task(self.project["id"])
status, body = self._create_approval(task["id"])
self.assertEqual(status, 201, body)
item = body["approval"]
self.assertEqual(item["status"], "pending")
self.assertEqual(item["kind"], "elicitation")
status, body = self._req(
"GET", "/tasks/%s/approvals" % task["id"],
None, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["approvals"]), 1)
status, body = self._req(
"POST", "/tasks/%s/approvals/%s/approve" % (
task["id"], item["id"]), {}, self.owner_key)
self.assertEqual(status, 200, body)
decided = body["approval"]
self.assertEqual(decided["status"], "approved")
self.assertEqual(decided["decision"], "approved")
self.assertEqual(decided["decided_by"], self.owner_id)
self.assertIsNotNone(decided["decided_at"])
status, body = self._req(
"GET", "/tasks/%s/approvals" % task["id"],
None, self.owner_key)
self.assertEqual(body["approvals"][0]["status"], "approved")
def test_deny_and_second_decision_conflicts(self):
task = self._create_task(self.project["id"])
_, body = self._create_approval(task["id"])
item = body["approval"]
status, body = self._req(
"POST", "/tasks/%s/approvals/%s/deny" % (
task["id"], item["id"]), {}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["approval"]["status"], "denied")
self.assertEqual(body["approval"]["decided_by"], self.owner_id)
status, body = self._req(
"POST", "/tasks/%s/approvals/%s/approve" % (
task["id"], item["id"]), {}, self.owner_key)
self.assertEqual(status, 409, body)
self.assertEqual(body["approval"]["status"], "denied")
def test_list_sweeps_overdue_to_expired_deny(self):
task = self._create_task(self.project["id"])
self._insert_expired_row(task["id"], self.project["id"])
status, body = self._req(
"GET", "/tasks/%s/approvals" % task["id"],
None, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["approvals"]), 1)
swept = body["approvals"][0]
self.assertEqual(swept["status"], "expired")
self.assertEqual(swept["decision"], "denied")
self.assertEqual(swept["decided_by"], "system-timeout")
def test_validation_and_not_found(self):
task = self._create_task(self.project["id"])
base = "/tasks/%s/approvals" % task["id"]
status, _ = self._req("POST", base, {}, self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"POST", base, {"summary": "x", "kind": "bogus"},
self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"POST", base, {"summary": "x", "timeout_seconds": 10},
self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"POST", base, {"summary": "x", "timeout_seconds": 90000},
self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"GET", "/tasks/nope/approvals", None, self.owner_key)
self.assertEqual(status, 404)
status, _ = self._req(
"POST", base, {"summary": "x"}, None)
self.assertEqual(status, 401)
def test_forbidden_for_outsider(self):
task = self._create_task(self.project["id"])
status, body = self._req(
"POST", "/tasks/%s/approvals" % task["id"],
{"summary": "x"}, self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/tasks/%s/approvals" % task["id"],
None, self.outsider_key)
self.assertEqual(status, 403)
_, body = self._create_approval(task["id"])
item = body["approval"]
status, _ = self._req(
"POST", "/tasks/%s/approvals/%s/approve" % (
task["id"], item["id"]), {}, self.outsider_key)
self.assertEqual(status, 403)
def test_cross_task_approval_id_is_not_found(self):
task_a = self._create_task(self.project["id"])
task_b = self._create_task(self.project["id"])
_, body = self._create_approval(task_a["id"])
item = body["approval"]
status, _ = self._req(
"POST", "/tasks/%s/approvals/%s/approve" % (
task_b["id"], item["id"]), {}, self.owner_key)
self.assertEqual(status, 404)
status, body = self._req(
"GET", "/tasks/%s/approvals" % task_b["id"],
None, self.owner_key)
self.assertEqual(body["approvals"], [])
if __name__ == "__main__":
unittest.main()
+232
View File
@@ -0,0 +1,232 @@
"""自动化与分诊收件箱模块单测(cloud/automations.py)。
不跑真实模型、不联网:临时 SQLite 文件 + 模块函数直调。
覆盖:建表幂等、创建校验与同名规则、到期边界、claim 抢单
防重、取消语义、产出记录、分诊四段聚合。
"""
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
try:
from cloud.automations import (
cancel_automation,
claim_due_automation,
create_automation,
due_automations,
ensure_automations_tables,
get_automation,
is_due,
list_automation_runs,
list_automations,
record_automation_run,
triage_inbox,
)
from cloud.peer_messages import ensure_followups_table
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.automations import (
cancel_automation,
claim_due_automation,
create_automation,
due_automations,
ensure_automations_tables,
get_automation,
is_due,
list_automation_runs,
list_automations,
record_automation_run,
triage_inbox,
)
from cloud.peer_messages import ensure_followups_table
class AutomationsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
self.conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT,"
" prompt TEXT, status TEXT, created_at DOUBLE PRECISION,"
" updated_at DOUBLE PRECISION, error_text TEXT)")
ensure_automations_tables(self.conn)
ensure_followups_table(self.conn)
self.conn.commit()
self.now = 1700000000.0
self.project = "proj-1"
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def _make(self, name="daily", interval=3600, **kw):
kw.setdefault("project_id", self.project)
return create_automation(
self.conn, kw.pop("project_id"), name, "做每日检查",
"user-1", interval, now=self.now, **kw)
def test_ensure_is_idempotent(self):
ensure_automations_tables(self.conn)
ensure_automations_tables(self.conn)
self.conn.commit()
rows = self.conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
" AND name IN ('automations', 'automation_runs')").fetchall()
self.assertEqual(len(rows), 2)
def test_create_fields_and_due_immediately(self):
auto = self._make()
self.assertEqual(auto["status"], "active")
self.assertEqual(auto["interval_seconds"], 3600)
self.assertIsNone(auto["last_run_at"])
self.assertEqual(auto["next_run_at"], self.now)
self.assertTrue(auto["due"])
self.assertTrue(is_due(auto, self.now))
got = get_automation(self.conn, auto["id"])
self.assertEqual(got["name"], "daily")
self.assertIsNone(get_automation(self.conn, "missing"))
def test_create_validation(self):
for name, prompt, interval in (
("", "x", 3600),
(" ", "x", 3600),
("n", "", 3600),
("n", "x", 59),
("n", "x", True),
("n", "x", 3600.5),
("x" * 101, "x", 3600),
("n", "x" * 20001, 3600)):
with self.assertRaises(ValueError, msg=(name, interval)):
create_automation(
self.conn, self.project, name, prompt, "user-1",
interval, now=self.now)
def test_duplicate_active_name_rejected_reusable_after_cancel(self):
self._make(name="dup")
with self.assertRaises(ValueError):
self._make(name="dup")
autos = list_automations(self.conn, self.project, self.now)
self.assertEqual(len(autos), 1)
ok, cancelled = cancel_automation(
self.conn, autos[0]["id"], self.now)
self.assertTrue(ok)
self.assertEqual(cancelled["status"], "cancelled")
again = self._make(name="dup")
self.assertEqual(again["status"], "active")
def test_due_boundary_matches_host_rundue_rule(self):
auto = self._make(interval=3600)
self.conn.execute(
"UPDATE automations SET last_run_at = ? WHERE id = ?",
(self.now - 3599, auto["id"]))
self.conn.commit()
self.assertEqual(due_automations(
self.conn, self.project, self.now), [])
self.conn.execute(
"UPDATE automations SET last_run_at = ? WHERE id = ?",
(self.now - 3600, auto["id"]))
self.conn.commit()
due = due_automations(self.conn, self.project, self.now)
self.assertEqual([d["id"] for d in due], [auto["id"]])
def test_claim_is_the_duplicate_gate(self):
auto = self._make(interval=3600)
self.assertTrue(claim_due_automation(
self.conn, auto["id"], self.now))
# 同一间隔内第二次抢单必须失败:这就是防重复执行。
self.assertFalse(claim_due_automation(
self.conn, auto["id"], self.now))
self.assertFalse(claim_due_automation(
self.conn, auto["id"], self.now + 3599))
# 下个间隔到了才能再抢。
self.assertTrue(claim_due_automation(
self.conn, auto["id"], self.now + 3600))
self.assertFalse(claim_due_automation(
self.conn, "missing", self.now))
ok, _ = cancel_automation(self.conn, auto["id"], self.now)
self.assertTrue(ok)
self.assertFalse(claim_due_automation(
self.conn, auto["id"], self.now + 7200))
def test_cancel_semantics(self):
ok, auto = cancel_automation(self.conn, "missing", self.now)
self.assertFalse(ok)
self.assertIsNone(auto)
made = self._make()
ok, auto = cancel_automation(self.conn, made["id"], self.now)
self.assertTrue(ok)
self.assertEqual(auto["status"], "cancelled")
self.assertFalse(auto["due"])
ok, auto = cancel_automation(self.conn, made["id"], self.now)
self.assertFalse(ok)
self.assertEqual(auto["status"], "cancelled")
def test_record_run_and_list_runs(self):
auto = self._make()
run = record_automation_run(
self.conn, auto["id"], self.project, task_id="task-1",
status="created", detail="claimed", now=self.now)
self.assertEqual(run["task_id"], "task-1")
self.assertEqual(run["status"], "created")
runs = list_automation_runs(self.conn, auto["id"])
self.assertEqual(len(runs), 1)
with self.assertRaises(ValueError):
record_automation_run(
self.conn, "missing", self.project, now=self.now)
def test_triage_inbox_four_sections(self):
auto = self._make()
other = self._make(name="weekly", interval=86400)
cancel_automation(self.conn, other["id"], self.now)
self.conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" created_at, updated_at, error_text)"
" VALUES('t-fail', ?, '坏任务', 'failed', ?, ?, 'boom')",
(self.project, self.now, self.now))
self.conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" created_at, updated_at, error_text)"
" VALUES('t-run', ?, '跑中任务', 'running', ?, ?, NULL)",
(self.project, self.now, self.now))
self.conn.execute(
"INSERT INTO task_followups(id, task_id, project_id, body,"
" mode, status, created_by, sender_task_id, created_at,"
" updated_at) VALUES('f-1', 't-run', ?, '请看一下',"
" 'queued', 'pending', 'user-1', NULL, ?, ?)",
(self.project, self.now, self.now))
self.conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" created_at, updated_at, error_text)"
" VALUES('t-done', ?, '已完成', 'completed', ?, ?, NULL)",
(self.project, self.now, self.now))
record_automation_run(
self.conn, auto["id"], self.project, task_id="t-done",
status="created", detail="", now=self.now)
self.conn.commit()
inbox = triage_inbox(self.conn, self.project, self.now)
self.assertEqual(
[d["id"] for d in inbox["automations_due"]], [auto["id"]])
self.assertEqual(
[t["id"] for t in inbox["failed_tasks"]], ["t-fail"])
self.assertEqual(
[f["id"] for f in inbox["pending_followups"]], ["f-1"])
self.assertEqual(len(inbox["recent_runs"]), 1)
self.assertEqual(inbox["recent_runs"][0]["task_status"],
"completed")
self.assertEqual(inbox["counts"], {
"automations_due": 1,
"failed_tasks": 1,
"pending_followups": 1,
"recent_runs": 1,
})
if __name__ == "__main__":
unittest.main()
+238
View File
@@ -0,0 +1,238 @@
"""自动化与分诊收件箱服务端单测:POST/GET /projects/{id}/
automations、POST /projects/{id}/automations/{aid}/cancel、
GET /projects/{id}/triage。
不跑真实模型、不联网:本地临时端口 + 临时 SQLite,与
test_peer_messages_server.py 同款骨架。覆盖鉴权、权限、
校验、取消语义(404/409)、分诊聚合与审计。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.automations import record_automation_run
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.automations import record_automation_run
from cloud.server import create_server
class AutomationServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user(
"outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
status, self.project2 = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _autos_url(self, project_id=None):
return "/projects/%s/automations" % (
project_id or self.project["id"])
def _create_auto(self, project_id=None, **extra):
body = {"name": "daily", "prompt": "做每日检查"}
body.update(extra)
return self._req(
"POST", self._autos_url(project_id), body, self.owner_key)
def _audit_actions(self, project_id):
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT action FROM audit_log WHERE project_id = ?",
(project_id,)).fetchall()
finally:
conn.close()
return [r[0] for r in rows]
def _create_task(self, project_id):
status, task = self._req(
"POST", "/tasks",
{"project_id": project_id, "prompt": "做事"}, self.owner_key)
self.assertEqual(status, 201, task)
return task
def _set_status(self, task_id, status):
conn = sqlite3.connect(self.db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
def test_create_and_list(self):
status, auto = self._create_auto(interval_seconds=3600)
self.assertEqual(status, 201, auto)
self.assertEqual(auto["status"], "active")
self.assertEqual(auto["interval_seconds"], 3600)
self.assertTrue(auto["due"])
self.assertIn("automation_created",
self._audit_actions(self.project["id"]))
status, body = self._req(
"GET", self._autos_url(), None, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["automations"]), 1)
self.assertEqual(body["due_count"], 1)
self.assertEqual(body["project_id"], self.project["id"])
def test_create_defaults_and_validation(self):
status, auto = self._create_auto()
self.assertEqual(status, 201, auto)
self.assertEqual(auto["interval_seconds"], 86400)
for body in (
{"prompt": "x"},
{"name": "n"},
{"name": "n", "prompt": "x", "interval_seconds": 30},
{"name": "n", "prompt": "x",
"interval_seconds": "3600"}):
status, _ = self._req(
"POST", self._autos_url(), body, self.owner_key)
self.assertEqual(status, 400, body)
status, _ = self._create_auto()
self.assertEqual(status, 400)
def test_auth_and_permission(self):
status, _ = self._req(
"POST", self._autos_url(),
{"name": "n", "prompt": "x"}, None)
self.assertEqual(status, 401)
status, _ = self._req(
"GET", self._autos_url(), None, None)
self.assertEqual(status, 401)
status, _ = self._req(
"POST", self._autos_url(),
{"name": "n", "prompt": "x"}, self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", self._autos_url(), None, self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"POST", "/projects/nope/automations",
{"name": "n", "prompt": "x"}, self.owner_key)
self.assertEqual(status, 404)
def test_cancel_semantics(self):
_, auto = self._create_auto()
url = "%s/%s/cancel" % (self._autos_url(), auto["id"])
status, body = self._req("POST", url, {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "cancelled")
self.assertFalse(body["due"])
self.assertIn("automation_cancelled",
self._audit_actions(self.project["id"]))
status, _ = self._req("POST", url, {}, self.owner_key)
self.assertEqual(status, 409)
status, _ = self._req(
"POST", "%s/missing/cancel" % self._autos_url(), {},
self.owner_key)
self.assertEqual(status, 404)
# 别的项目的自动化 id 在本项目下取消也一律 404。
_, other = self._create_auto(project_id=self.project2["id"])
status, _ = self._req(
"POST", "%s/%s/cancel" % (self._autos_url(), other["id"]),
{}, self.owner_key)
self.assertEqual(status, 404)
status, _ = self._req(
"POST", url, {}, self.outsider_key)
self.assertEqual(status, 403)
def test_triage_inbox(self):
_, auto = self._create_auto()
task = self._create_task(self.project["id"])
self._set_status(task["id"], "failed")
runner = self._create_task(self.project["id"])
self._set_status(runner["id"], "running")
status, _ = self._req(
"POST", "/tasks/%s/followups" % runner["id"],
{"body": "请看一下", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
conn = sqlite3.connect(self.db_path)
try:
record_automation_run(
conn, auto["id"], self.project["id"],
task_id=task["id"], status="created", detail="")
finally:
conn.close()
status, body = self._req(
"GET", "/projects/%s/triage" % self.project["id"], None,
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(
[d["id"] for d in body["automations_due"]], [auto["id"]])
self.assertEqual(
[t["id"] for t in body["failed_tasks"]], [task["id"]])
self.assertEqual(len(body["pending_followups"]), 1)
self.assertEqual(
body["pending_followups"][0]["task_id"], runner["id"])
self.assertEqual(len(body["recent_runs"]), 1)
self.assertEqual(body["recent_runs"][0]["task_status"],
"failed")
self.assertEqual(body["counts"]["automations_due"], 1)
status, _ = self._req(
"GET", "/projects/%s/triage" % self.project["id"], None,
self.outsider_key)
self.assertEqual(status, 403)
status, _ = self._req(
"GET", "/projects/%s/triage" % self.project["id"], None,
None)
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()
+223
View File
@@ -0,0 +1,223 @@
"""并发上限单测(caps)。
覆盖:配置解析优先级与默认值、认领门的未满 / 已满 / 释放后
恢复、启动中任务计入占用、CAS 抢输后占位不泄漏、缺 key 门
提前返回后占位释放、双线程同时认领只赢一个。
只用注入的假环境与本地 SQLite,不跑真实模型、不联网。
"""
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.caps import (
can_claim,
effective_capacity,
resolve_capacity,
running_count,
)
from cloud.server import init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.caps import (
can_claim,
effective_capacity,
resolve_capacity,
running_count,
)
from cloud.server import init_db
from cloud.worker import Worker
def _insert_task(db_path, task_id, project_id="p1"):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', 0, 'tester', ?, ?, NULL)",
(task_id, project_id, "do work", now, now),
)
conn.commit()
finally:
conn.close()
def _status(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT status FROM tasks WHERE id = ?",
(task_id,)).fetchone()
finally:
conn.close()
return row[0] if row else None
class CapsResolveTestCase(unittest.TestCase):
def test_explicit_beats_env(self):
env = {"CLOUD_WORKER_CAPACITY": "3"}
self.assertEqual(resolve_capacity(2, env=env), 2)
def test_env_used_when_no_explicit(self):
env = {"CLOUD_WORKER_CAPACITY": "3"}
self.assertEqual(resolve_capacity(None, env=env), 3)
def test_default_is_1(self):
self.assertEqual(resolve_capacity(None, env={}), 1)
self.assertEqual(resolve_capacity(), 1)
def test_invalid_values_fall_back_to_1(self):
self.assertEqual(resolve_capacity(0, env={}), 1)
self.assertEqual(resolve_capacity(-2, env={}), 1)
self.assertEqual(resolve_capacity("abc", env={}), 1)
self.assertEqual(resolve_capacity(None, env={
"CLOUD_WORKER_CAPACITY": "abc"}), 1)
self.assertEqual(resolve_capacity(None, env={
"CLOUD_WORKER_CAPACITY": "0"}), 1)
self.assertEqual(resolve_capacity(None, env={
"CLOUD_WORKER_CAPACITY": " "}), 1)
def test_effective_capacity(self):
self.assertEqual(effective_capacity(2), 2)
self.assertEqual(effective_capacity(0), 1)
self.assertEqual(effective_capacity(None), 1)
def test_can_claim_boundaries(self):
self.assertTrue(can_claim(0, 1))
self.assertFalse(can_claim(1, 1))
self.assertTrue(can_claim(1, 2))
self.assertFalse(can_claim(2, 2))
def test_running_count_counts_starting_entries(self):
# 空条目 = 已认领未起进程的启动中任务,同样占一个槽。
self.assertEqual(running_count({"a": {}, "b": {"proc": 1}}), 2)
self.assertEqual(running_count({}), 0)
class WorkerClaimGateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, **kwargs):
kwargs.setdefault("env", self.env)
kwargs.setdefault("retry_backoff_seconds", 0)
kwargs.setdefault("max_retries", 0)
return Worker(self.db_path, "/fake/host.py", **kwargs)
def test_default_capacity_is_1(self):
worker = self._worker()
self.assertEqual(worker.capacity, 1)
def test_env_capacity_and_explicit_priority(self):
env = {"CLOUD_WORKER_CAPACITY": "3"}
self.assertEqual(self._worker(env=env).capacity, 3)
self.assertEqual(self._worker(env=env, capacity=2).capacity, 2)
def test_full_worker_refuses_claim_and_task_stays_queued(self):
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
worker = self._worker(capacity=1)
self.assertTrue(worker._claim("A"))
self.assertEqual(_status(self.db_path, "A"), "running")
self.assertFalse(worker._claim("B"))
self.assertEqual(_status(self.db_path, "B"), "queued")
self.assertEqual(set(worker._running_snapshot()), {"A"})
def test_release_restores_claim(self):
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
worker = self._worker(capacity=1)
self.assertTrue(worker._claim("A"))
self.assertFalse(worker._claim("B"))
worker._running_clear("A")
self.assertTrue(worker._claim("B"))
self.assertEqual(_status(self.db_path, "B"), "running")
def test_starting_task_counts_toward_capacity(self):
_insert_task(self.db_path, "B")
worker = self._worker(capacity=1)
# 空条目模拟“已认领、执行器还没起进程”的启动中任务。
worker._running_set("STARTING")
self.assertEqual(worker._running_count(), 1)
self.assertFalse(worker._claim("B"))
self.assertEqual(_status(self.db_path, "B"), "queued")
worker._running_clear("STARTING")
self.assertTrue(worker._claim("B"))
def test_capacity_2_allows_two_blocks_third(self):
for task_id in ("A", "B", "C"):
_insert_task(self.db_path, task_id)
worker = self._worker(capacity=2)
self.assertTrue(worker._claim("A"))
self.assertTrue(worker._claim("B"))
self.assertFalse(worker._claim("C"))
self.assertEqual(_status(self.db_path, "C"), "queued")
worker._running_clear("A")
self.assertTrue(worker._claim("C"))
def test_lost_cas_race_releases_slot(self):
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
first = self._worker(capacity=1)
second = self._worker(capacity=1)
self.assertTrue(first._claim("A"))
# second 的 CAS 抢输:返回 False,且它自己的占位已清,
# 不能留下一个空槽把自己堵死。
self.assertFalse(second._claim("A"))
self.assertEqual(second._running_snapshot(), {})
self.assertTrue(second._claim("B"))
def test_two_threads_claim_only_one_wins(self):
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
worker = self._worker(capacity=1)
barrier = threading.Barrier(2, timeout=5)
results = {}
def _try(task_id):
barrier.wait()
results[task_id] = worker._claim(task_id)
threads = [threading.Thread(target=_try, args=(t,))
for t in ("A", "B")]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(sorted(results.values()), [False, True])
self.assertEqual(worker._running_count(), 1)
def test_missing_key_gate_releases_slot(self):
_insert_task(self.db_path, "A")
_insert_task(self.db_path, "B")
# env 里无 key:run_one 在缺 key 门提前返回 failed,
# 占位必须随之释放,否则 capacity=1 的 worker 会被堵死。
worker = self._worker(env={}, capacity=1)
result = worker.run_one()
self.assertTrue(result["dispatched"])
self.assertEqual(result["status"], "failed")
self.assertEqual(_status(self.db_path, "A"), "failed")
self.assertEqual(worker._running_snapshot(), {})
self.assertTrue(worker._claim("B"))
self.assertEqual(_status(self.db_path, "B"), "running")
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
"""并发上限服务端面单测(/workers 占用展示)。
覆盖:GET /workers 行里含 capacity 与 running;心跳上报 running
后列表可见;心跳未给 running 时保留旧值;非法 running 400;
未认证 401;/workers 读权限口径是任何已认证用户(与既有
_workers_list 一致,不按项目成员过滤)。
只用标准库 http.client,不跑真实模型,不联网。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
class CapsServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.viewer_id, self.viewer_key = self.auth.create_user("viewer")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _workers(self, key):
status, body = self._req("GET", "/workers", key=key)
self.assertEqual(status, 200, body)
return {w["worker_id"]: w for w in body["workers"]}
def test_workers_list_shows_capacity_and_running(self):
status, body = self._req(
"POST", "/workers/heartbeat",
{"worker_id": "w1", "executor": "local",
"capacity": 4, "running": 2},
self.owner_key)
self.assertEqual(status, 200, body)
# 读权限:任何已认证用户可读(viewer 也能看到基础设施行)。
workers = self._workers(self.viewer_key)
self.assertEqual(workers["w1"]["capacity"], 4)
self.assertEqual(workers["w1"]["running"], 2)
def test_heartbeat_without_running_preserves_old(self):
self._req("POST", "/workers/heartbeat",
{"worker_id": "w1", "capacity": 2, "running": 1},
self.owner_key)
status, body = self._req(
"POST", "/workers/heartbeat", {"worker_id": "w1"},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["running"], 1)
self.assertEqual(self._workers(self.owner_key)["w1"]["running"], 1)
def test_heartbeat_invalid_running_400(self):
status, _ = self._req(
"POST", "/workers/heartbeat",
{"worker_id": "w1", "running": "abc"}, self.owner_key)
self.assertEqual(status, 400)
status, _ = self._req(
"POST", "/workers/heartbeat",
{"worker_id": "w1", "running": -1}, self.owner_key)
self.assertEqual(status, 400)
def test_workers_requires_auth(self):
status, _ = self._req("GET", "/workers")
self.assertEqual(status, 401)
def test_heartbeat_registers_unknown_worker_with_defaults(self):
status, body = self._req(
"POST", "/workers/heartbeat", {"worker_id": "new1"},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["capacity"], 1)
self.assertEqual(body["running"], 0)
if __name__ == "__main__":
unittest.main()
+6 -3
View File
@@ -240,12 +240,13 @@ class TaskExecutorFilterTestCase(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def _docker_worker(self):
def _docker_worker(self, capacity=None):
return Worker(
self.db_path, "host.py", config_path=None, repo=None,
executor=DockerExecutor(runner=lambda cmd: _result()),
env={"XCM_API_KEY": "test-placeholder"}, env_key=None,
task_timeout=1800, max_retries=0, retry_backoff_seconds=0)
task_timeout=1800, max_retries=0, retry_backoff_seconds=0,
capacity=capacity)
def test_claim_rejects_other_pool_task(self):
worker = self._docker_worker()
@@ -260,7 +261,9 @@ class TaskExecutorFilterTestCase(unittest.TestCase):
self.assertEqual(status, "queued")
def test_claim_accepts_unassigned_and_same_pool(self):
worker = self._docker_worker()
# 本测试连续认领两个任务, 测的是执行器过滤不是上限;
# caps 默认上限 1 会挡第二单, 故显式给 capacity=2。
worker = self._docker_worker(capacity=2)
self.assertTrue(worker._claim("t-none", executor="docker"))
self.assertTrue(worker._claim("t-docker", executor="docker"))
+134
View File
@@ -0,0 +1,134 @@
"""自动代码评审模块单测:cloud/review.py。
不跑真实模型、不联网:纯 sqlite 内存库 + 协议目标构造。
覆盖四种 ReviewTarget、非法组合、登记/队列/取单条/写回
状态机(findings 写回默认 completed、显式 failed 可覆盖)。
"""
import sqlite3
import sys
import unittest
from pathlib import Path
try:
from cloud.review import build_review_target
from cloud.review import create_review
from cloud.review import ensure_reviews_table
from cloud.review import get_review
from cloud.review import list_project_reviews
from cloud.review import list_reviews
from cloud.review import update_review
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.review import build_review_target
from cloud.review import create_review
from cloud.review import ensure_reviews_table
from cloud.review import get_review
from cloud.review import list_project_reviews
from cloud.review import list_reviews
from cloud.review import update_review
class ReviewTargetTestCase(unittest.TestCase):
def test_four_targets(self):
self.assertEqual(
build_review_target("uncommittedChanges"),
{"type": "uncommittedChanges"})
self.assertEqual(
build_review_target("baseBranch", branch="main"),
{"type": "baseBranch", "branch": "main"})
self.assertEqual(
build_review_target("commit", sha="abc123"),
{"type": "commit", "sha": "abc123"})
self.assertEqual(
build_review_target("custom", instructions="look closely"),
{"type": "custom", "instructions": "look closely"})
def test_target_validation(self):
with self.assertRaises(ValueError):
build_review_target("baseBranch")
with self.assertRaises(ValueError):
build_review_target("baseBranch", branch=" ")
with self.assertRaises(ValueError):
build_review_target("commit")
with self.assertRaises(ValueError):
build_review_target("custom")
with self.assertRaises(ValueError):
build_review_target("nope")
def test_whitespace_trimmed(self):
self.assertEqual(
build_review_target("baseBranch", branch=" main "),
{"type": "baseBranch", "branch": "main"})
class ReviewStoreTestCase(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
ensure_reviews_table(self.conn)
def tearDown(self):
self.conn.close()
def test_create_and_list(self):
first = create_review(
self.conn, "task-a", "proj-a", "uncommittedChanges",
"owner-1")
self.assertEqual(first["status"], "requested")
self.assertIsNone(first["findings"])
self.assertEqual(first["task_id"], "task-a")
second = create_review(
self.conn, "task-a", "proj-a", "baseBranch", "owner-1",
branch="main")
self.assertEqual(second["target_type"], "baseBranch")
self.assertEqual(second["branch"], "main")
queue = list_reviews(self.conn, "task-a")
self.assertEqual(
[r["id"] for r in queue], [first["id"], second["id"]])
self.assertEqual(list_reviews(self.conn, "task-other"), [])
proj = list_project_reviews(self.conn, "proj-a")
self.assertEqual(len(proj), 2)
def test_create_invalid_target_writes_nothing(self):
with self.assertRaises(ValueError):
create_review(
self.conn, "task-a", "proj-a", "commit", "owner-1")
self.assertEqual(list_reviews(self.conn, "task-a"), [])
def test_get_missing_returns_none(self):
self.assertIsNone(get_review(self.conn, "no-such-id"))
def test_findings_writeback_defaults_completed(self):
record = create_review(
self.conn, "task-a", "proj-a", "custom", "owner-1",
instructions="check it")
updated = update_review(
self.conn, record["id"], findings="two issues found")
self.assertEqual(updated["status"], "completed")
self.assertEqual(updated["findings"], "two issues found")
self.assertEqual(
get_review(self.conn, record["id"])["status"], "completed")
def test_explicit_failed_status_kept(self):
record = create_review(
self.conn, "task-a", "proj-a", "uncommittedChanges",
"owner-1")
updated = update_review(
self.conn, record["id"], status="failed",
findings="reviewer crashed")
self.assertEqual(updated["status"], "failed")
def test_update_validation(self):
record = create_review(
self.conn, "task-a", "proj-a", "uncommittedChanges",
"owner-1")
with self.assertRaises(ValueError):
update_review(self.conn, record["id"], status="bogus")
with self.assertRaises(ValueError):
update_review(self.conn, record["id"], findings=123)
self.assertIsNone(
update_review(self.conn, "no-such-id", findings="x"))
if __name__ == "__main__":
unittest.main()
+240
View File
@@ -0,0 +1,240 @@
"""自动代码评审服务端单测:POST /tasks/<id>/reviews、
GET /tasks/<id>/reviews、POST /tasks/<id>/reviews/<rid>/findings。
不跑真实模型、不联网:本地临时端口 + 临时 SQLite,与
test_peer_messages_server.py 同款骨架。覆盖鉴权、权限、
目标校验、队列查看、findings 写回与跨任务隔离。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
class ReviewServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.outsider_id, self.outsider_key = self.auth.create_user(
"outsider")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
self.trace_dir = os.path.join(self.tmp.name, "host-traces")
os.makedirs(self.trace_dir, exist_ok=True)
self.server.trace_dir = self.trace_dir
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
status, self.project2 = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create_task(self, project_id):
status, task = self._req(
"POST", "/tasks",
{"project_id": project_id, "prompt": "做事"},
self.owner_key)
self.assertEqual(status, 201, task)
return task
def test_create_default_and_list(self):
task = self._create_task(self.project["id"])
status, created = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.owner_key)
self.assertEqual(status, 201, created)
self.assertEqual(created["status"], "requested")
self.assertIsNone(created["findings"])
self.assertEqual(created["target_type"], "uncommittedChanges")
status, body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None,
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(len(body["reviews"]), 1)
self.assertEqual(body["reviews"][0]["id"], created["id"])
def test_create_all_targets(self):
task = self._create_task(self.project["id"])
cases = [
({"target_type": "baseBranch", "branch": "main"}, "main"),
({"target_type": "commit", "sha": "abc123"}, "abc123"),
({"target_type": "custom", "instructions": "look"}, None),
]
for payload, _expected in cases:
status, body = self._req(
"POST", "/tasks/%s/reviews" % task["id"], payload,
self.owner_key)
self.assertEqual(status, 201, body)
status, body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None,
self.owner_key)
self.assertEqual(len(body["reviews"]), 3)
def test_create_target_validation(self):
task = self._create_task(self.project["id"])
bad = [
{"target_type": "baseBranch"},
{"target_type": "commit"},
{"target_type": "custom"},
{"target_type": "nope"},
{"target_type": "baseBranch", "branch": 123},
]
for payload in bad:
status, body = self._req(
"POST", "/tasks/%s/reviews" % task["id"], payload,
self.owner_key)
self.assertEqual(status, 400, (payload, body))
status, body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None,
self.owner_key)
self.assertEqual(body["reviews"], [])
def test_findings_writeback(self):
task = self._create_task(self.project["id"])
_status, review = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.owner_key)
status, body = self._req(
"POST",
"/tasks/%s/reviews/%s/findings" % (task["id"], review["id"]),
{"findings": "two issues"}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "completed")
self.assertEqual(body["findings"], "two issues")
status, body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None,
self.owner_key)
self.assertEqual(body["reviews"][0]["findings"], "two issues")
def test_findings_explicit_failed(self):
task = self._create_task(self.project["id"])
_status, review = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.owner_key)
status, body = self._req(
"POST",
"/tasks/%s/reviews/%s/findings" % (task["id"], review["id"]),
{"status": "failed", "findings": "reviewer error"},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "failed")
def test_findings_validation_and_isolation(self):
task = self._create_task(self.project["id"])
other = self._create_task(self.project["id"])
_status, review = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.owner_key)
url = "/tasks/%s/reviews/%s/findings" % (
task["id"], review["id"])
status, _body = self._req("POST", url, {}, self.owner_key)
self.assertEqual(status, 400)
status, _body = self._req(
"POST", url, {"status": "bogus"}, self.owner_key)
self.assertEqual(status, 400)
status, _body = self._req(
"POST", url, {"findings": 123}, self.owner_key)
self.assertEqual(status, 400)
# 记录属于 task,挂在 other 任务下写回时 404,不泄露。
status, _body = self._req(
"POST",
"/tasks/%s/reviews/%s/findings" % (
other["id"], review["id"]),
{"findings": "x"}, self.owner_key)
self.assertEqual(status, 404)
status, _body = self._req(
"POST",
"/tasks/%s/reviews/%s/findings" % (
task["id"], "no-such-review"),
{"findings": "x"}, self.owner_key)
self.assertEqual(status, 404)
def test_auth_and_permissions(self):
task = self._create_task(self.project["id"])
status, _body = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {})
self.assertEqual(status, 401)
status, _body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None)
self.assertEqual(status, 401)
status, _body = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.outsider_key)
self.assertEqual(status, 403)
status, _body = self._req(
"GET", "/tasks/%s/reviews" % task["id"], None,
self.outsider_key)
self.assertEqual(status, 403)
_status, review = self._req(
"POST", "/tasks/%s/reviews" % task["id"], {},
self.owner_key)
status, _body = self._req(
"POST",
"/tasks/%s/reviews/%s/findings" % (
task["id"], review["id"]),
{"findings": "x"}, self.outsider_key)
self.assertEqual(status, 403)
def test_task_not_found(self):
status, _body = self._req(
"POST", "/tasks/no-such-task/reviews", {},
self.owner_key)
self.assertEqual(status, 404)
status, _body = self._req(
"GET", "/tasks/no-such-task/reviews", None,
self.owner_key)
self.assertEqual(status, 404)
if __name__ == "__main__":
unittest.main()