feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)
This commit is contained in:
1 parent
29efc6c61f
commit
e0cecd70aa
18 files changed
+3474
-27
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user