mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 07:00:48 +08:00
feat(scheduler): add scheduled jobs with channel-aware delivery
Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns, and admin CRUD/edit UI. Route proactive reminders via the originating chat channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and PG-compatible polling, and fix chat UI to show each scheduled reminder separately. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5336f8835f
commit
47813f6a94
45 changed files with 4956 additions and 43 deletions
114
tests/test_admin_scheduled_jobs_api.py
Normal file
114
tests/test_admin_scheduled_jobs_api.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from interfaces.http.fastapi_app import create_app
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminScheduledJobsApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched_admin.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t1 = self.store.create_tenant("TeamA")
|
||||
self.tenant_id = str(t1["id"])
|
||||
admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(admin["id"])
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_scheduled_jobs_crud(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
once_at = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
create = self.client.post(
|
||||
"/admin/api/scheduled-jobs",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Test job",
|
||||
"prompt_text": "Say hello",
|
||||
"schedule_kind": "once",
|
||||
"schedule_expr": once_at,
|
||||
"specialist": "generalist",
|
||||
},
|
||||
)
|
||||
self.assertEqual(create.status_code, 200, create.text)
|
||||
body = create.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
job_id = str((body.get("job") or {}).get("id") or "")
|
||||
self.assertTrue(job_id)
|
||||
|
||||
listed = self.client.get("/admin/api/scheduled-jobs", headers=headers)
|
||||
self.assertTrue(listed.json().get("ok"))
|
||||
self.assertGreaterEqual(len(listed.json().get("items") or []), 1)
|
||||
|
||||
pause = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/pause", headers=headers, json={})
|
||||
self.assertTrue(pause.json().get("ok"))
|
||||
|
||||
resume = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/resume", headers=headers, json={})
|
||||
self.assertTrue(resume.json().get("ok"))
|
||||
|
||||
runs = self.client.get(f"/admin/api/scheduled-jobs/{job_id}/runs", headers=headers)
|
||||
self.assertTrue(runs.json().get("ok"))
|
||||
|
||||
patch = self.client.patch(
|
||||
f"/admin/api/scheduled-jobs/{job_id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Updated job",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "7200",
|
||||
"prompt_text": "Stand up hourly",
|
||||
"specialist": "generalist",
|
||||
},
|
||||
)
|
||||
self.assertEqual(patch.status_code, 200, patch.text)
|
||||
patched = patch.json()
|
||||
self.assertTrue(patched.get("ok"), patched)
|
||||
job = patched.get("job") or {}
|
||||
self.assertEqual(str(job.get("name") or ""), "Updated job")
|
||||
self.assertEqual(str(job.get("schedule_kind") or ""), "interval")
|
||||
self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
|
||||
self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/test_gateway_cron_service.py
Normal file
49
tests/test_gateway_cron_service.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from interfaces.gateway.http_adapter import dispatch_gateway_http_method
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class GatewayCronServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "cron.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("default")
|
||||
self.tenant_id = str(t["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_cron_add_and_list(self) -> None:
|
||||
once_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
|
||||
add = dispatch_gateway_http_method(
|
||||
"cron.add",
|
||||
{
|
||||
"name": "gw job",
|
||||
"schedule": once_at,
|
||||
"schedule_kind": "once",
|
||||
"tenant_id": self.tenant_id,
|
||||
"prompt": "hello",
|
||||
},
|
||||
)
|
||||
self.assertTrue(add.get("ok"), add)
|
||||
lst = dispatch_gateway_http_method("cron.list", {"tenant_id": self.tenant_id})
|
||||
self.assertTrue(lst.get("ok"), lst)
|
||||
payload = lst.get("payload") or {}
|
||||
self.assertGreaterEqual(len(payload.get("items") or []), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
20
tests/test_schedule_duration_parse.py
Normal file
20
tests/test_schedule_duration_parse.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from runtime.application.gateway.inbound_service import _parse_schedule_duration_seconds
|
||||
|
||||
|
||||
class ScheduleDurationParseTests(unittest.TestCase):
|
||||
def test_minutes(self) -> None:
|
||||
self.assertEqual(_parse_schedule_duration_seconds("5分钟"), 300)
|
||||
|
||||
def test_hours(self) -> None:
|
||||
self.assertEqual(_parse_schedule_duration_seconds("1小时"), 3600)
|
||||
|
||||
def test_seconds(self) -> None:
|
||||
self.assertEqual(_parse_schedule_duration_seconds("120秒"), 120)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
tests/test_schedule_tools.py
Normal file
59
tests/test_schedule_tools.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool, schedule_list_tool
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ScheduleToolsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "tools.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_schedule_create_and_list(self) -> None:
|
||||
create = schedule_create_tool()
|
||||
out = create.handler(
|
||||
{
|
||||
"tenant_id": self.tenant_id,
|
||||
"owner_user_id": self.user_id,
|
||||
"name": "Reminder",
|
||||
"prompt_text": "Check inbox",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "900",
|
||||
"specialist": "ops",
|
||||
"interaction_mode": "expert",
|
||||
}
|
||||
)
|
||||
self.assertTrue(out.get("ok"), out)
|
||||
lst = schedule_list_tool()
|
||||
listed = lst.handler({"tenant_id": self.tenant_id})
|
||||
self.assertTrue(listed.get("ok"))
|
||||
self.assertEqual(len(listed.get("items") or []), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
133
tests/test_scheduler_ephemeral_context.py
Normal file
133
tests/test_scheduler_ephemeral_context.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from runtime.scheduler.worker_turn import resolve_scheduled_outbound_text
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class EphemeralUserContextTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ctx.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
sess = self.store.create_session("test")
|
||||
self.session_id = str(sess.id)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_build_model_context_injects_ephemeral_user_text(self) -> None:
|
||||
from runtime.direct_loop import _build_model_context
|
||||
from svc.llm.chat_models import RuleBasedChatModel
|
||||
|
||||
msgs = _build_model_context(
|
||||
store=self.store,
|
||||
session_id=self.session_id,
|
||||
max_messages=20,
|
||||
system_prompt="sys",
|
||||
model=RuleBasedChatModel(),
|
||||
lang="zh",
|
||||
memory_context=None,
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
user_text="【定时主动提醒】站起来",
|
||||
active_turn_uuid="turn-sched-1",
|
||||
)
|
||||
roles = [str(m.get("role") or "") for m in msgs]
|
||||
self.assertIn("user", roles)
|
||||
self.assertEqual(str(msgs[-1].get("content") or ""), "【定时主动提醒】站起来")
|
||||
rows = self.store.get_messages(session_id=self.session_id, limit=10)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
|
||||
class ScheduledOutboundFallbackTests(unittest.TestCase):
|
||||
def test_uses_prompt_when_model_reply_empty(self) -> None:
|
||||
text = resolve_scheduled_outbound_text(
|
||||
payload={"prompt_text": "站起来活动一下"},
|
||||
reply_text="",
|
||||
)
|
||||
self.assertIn("站起来活动一下", text)
|
||||
self.assertTrue(text.startswith("⏰"))
|
||||
|
||||
def test_keeps_model_reply_when_present(self) -> None:
|
||||
text = resolve_scheduled_outbound_text(
|
||||
payload={"prompt_text": "站起来活动一下"},
|
||||
reply_text="该起来啦!",
|
||||
)
|
||||
self.assertEqual(text, "该起来啦!")
|
||||
|
||||
|
||||
class ScheduledAssistantPersistTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "persist.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
sess = self.store.create_session("sched")
|
||||
self.session_id = str(sess.id)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_finalize_persists_assistant_when_missing(self) -> None:
|
||||
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
|
||||
|
||||
task = MagicMock()
|
||||
task.task_type = "scheduled_turn"
|
||||
payload = {
|
||||
"tenant_id": "t1",
|
||||
"job_id": "",
|
||||
"run_id_scheduled": "",
|
||||
"session_id": self.session_id,
|
||||
"prompt_text": "休息",
|
||||
"delivery": {"weixin": {"enabled": False}},
|
||||
"resolved_channel": "admin_chat",
|
||||
}
|
||||
with unittest.mock.patch(
|
||||
"runtime.scheduler.channel_delivery.deliver_scheduled_reply",
|
||||
return_value={"ok": True, "skipped": True},
|
||||
):
|
||||
finalize_scheduled_turn_success(
|
||||
store=self.store,
|
||||
task=task,
|
||||
payload={
|
||||
**payload,
|
||||
"session_id": self.session_id,
|
||||
},
|
||||
base_result={"reply_text": "", "turn_uuid": "tu-1"},
|
||||
)
|
||||
rows = self.store.get_messages(session_id=self.session_id, limit=10)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(str(rows[0].role), "assistant")
|
||||
self.assertEqual(str(getattr(rows[0], "event_type", "") or ""), "assistant_text")
|
||||
self.assertIn("休息", str(rows[0].content or ""))
|
||||
|
||||
def test_updates_empty_assistant_for_same_turn(self) -> None:
|
||||
from runtime.scheduler.worker_turn import _persist_scheduled_assistant_reply
|
||||
|
||||
self.store.add_message(
|
||||
session_id=self.session_id,
|
||||
role="assistant",
|
||||
content="",
|
||||
turn_uuid="tu-empty",
|
||||
event_type="assistant_text",
|
||||
)
|
||||
_persist_scheduled_assistant_reply(
|
||||
self.store,
|
||||
session_id=self.session_id,
|
||||
turn_uuid="tu-empty",
|
||||
reply_text="⏰ 提醒:站起来",
|
||||
)
|
||||
rows = self.store.get_messages(session_id=self.session_id, limit=10)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertIn("站起来", str(rows[0].content or ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
130
tests/test_scheduler_expressions.py
Normal file
130
tests/test_scheduler_expressions.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class SchedulerExpressionsTests(unittest.TestCase):
|
||||
def test_normalize_schedule_kind(self) -> None:
|
||||
self.assertEqual(normalize_schedule_kind("CRON"), "cron")
|
||||
self.assertEqual(normalize_schedule_kind("bad"), "cron")
|
||||
|
||||
def test_interval_next_run(self) -> None:
|
||||
base = datetime(2026, 6, 26, 10, 0, 0, tzinfo=timezone.utc)
|
||||
nxt = compute_next_run_at(
|
||||
schedule_kind="interval",
|
||||
schedule_expr="120",
|
||||
timezone_name="Asia/Shanghai",
|
||||
from_dt=base,
|
||||
)
|
||||
self.assertEqual(nxt, (base + timedelta(seconds=120)).isoformat())
|
||||
|
||||
def test_once_future(self) -> None:
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
nxt = compute_next_run_at(
|
||||
schedule_kind="once",
|
||||
schedule_expr=future.isoformat(),
|
||||
timezone_name="Asia/Shanghai",
|
||||
)
|
||||
self.assertIsNotNone(nxt)
|
||||
|
||||
|
||||
class ScheduledJobStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_row_to_job_dict_row(self) -> None:
|
||||
from svc.persistence.scheduled_job_store import _row_to_job
|
||||
|
||||
row = {
|
||||
"id": "j1",
|
||||
"tenant_id": self.tenant_id,
|
||||
"name": "Test",
|
||||
"description": "",
|
||||
"status": "active",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "300",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"prompt_text": "ping",
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "generalist",
|
||||
"lang": "zh",
|
||||
"delivery_json": "{}",
|
||||
"source_session_id": None,
|
||||
"created_by_user_id": self.user_id,
|
||||
"source": "chat",
|
||||
"next_run_at": "2026-06-26T10:00:00+00:00",
|
||||
"last_run_at": None,
|
||||
"last_run_status": "",
|
||||
"created_at": "2026-06-26T09:00:00+00:00",
|
||||
"updated_at": "2026-06-26T09:00:00+00:00",
|
||||
}
|
||||
job = _row_to_job(row)
|
||||
self.assertEqual(job.id, "j1")
|
||||
self.assertEqual(job.name, "Test")
|
||||
job = self.store.scheduled_job_create(
|
||||
tenant_id=self.tenant_id,
|
||||
name="Daily report",
|
||||
prompt_text="Summarize alarms",
|
||||
schedule_kind="interval",
|
||||
schedule_expr="3600",
|
||||
created_by_user_id=self.user_id,
|
||||
)
|
||||
self.assertEqual(job.status, "active")
|
||||
self.assertTrue(job.next_run_at)
|
||||
rows = self.store.scheduled_job_list(tenant_id=self.tenant_id)
|
||||
self.assertEqual(len(rows), 1)
|
||||
ok = self.store.scheduled_job_set_status(
|
||||
tenant_id=self.tenant_id,
|
||||
job_id=job.id,
|
||||
status="paused",
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
due = self.store.scheduled_job_list_due(limit=10)
|
||||
self.assertEqual(due, [])
|
||||
|
||||
def test_run_record(self) -> None:
|
||||
job = self.store.scheduled_job_create(
|
||||
tenant_id=self.tenant_id,
|
||||
name="Once",
|
||||
prompt_text="Ping",
|
||||
schedule_kind="once",
|
||||
schedule_expr=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
|
||||
created_by_user_id=self.user_id,
|
||||
)
|
||||
run = self.store.scheduled_job_run_create(job_id=job.id, tenant_id=self.tenant_id)
|
||||
self.assertEqual(run.status, "queued")
|
||||
updated = self.store.scheduled_job_run_update(
|
||||
run_id=run.id,
|
||||
tenant_id=self.tenant_id,
|
||||
patch={"status": "success", "reply_text": "ok"},
|
||||
)
|
||||
self.assertIsNotNone(updated)
|
||||
self.assertEqual(str(updated.reply_text), "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
180
tests/test_scheduler_proactive_delivery.py
Normal file
180
tests/test_scheduler_proactive_delivery.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from runtime.scheduler.channel_delivery import deliver_scheduled_reply
|
||||
from runtime.scheduler.cron_service import build_delivery_for_session
|
||||
from runtime.scheduler.session_resolver import resolve_scheduled_session
|
||||
from runtime.scheduler.turn_text import build_scheduled_turn_instruction
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ScheduledTurnTextTests(unittest.TestCase):
|
||||
def test_instruction_is_internal_not_user_facing(self) -> None:
|
||||
text = build_scheduled_turn_instruction(
|
||||
prompt_text="站起来活动一下",
|
||||
mode="scheduled",
|
||||
lang="zh",
|
||||
)
|
||||
self.assertIn("提醒意图", text)
|
||||
self.assertNotIn("[scheduled:", text)
|
||||
|
||||
|
||||
class ScheduledSessionResolverTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.admin_id = str(admin["id"])
|
||||
sess = self.store.create_session("WeChat session")
|
||||
self.session_id = str(sess.id)
|
||||
self.store.ensure_ui_session_owner(
|
||||
session_id=self.session_id,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.admin_id,
|
||||
)
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(self.tenant_id, "weixin", "weixin-default", "wx-user-123", "wx-user-123", self.session_id),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_source_session_preserves_weixin_delivery_target(self) -> None:
|
||||
job = MagicMock()
|
||||
job.tenant_id = self.tenant_id
|
||||
job.delivery_json = '{"weixin":{"enabled":true}}'
|
||||
job.source_session_id = self.session_id
|
||||
job.name = "rest"
|
||||
resolved = resolve_scheduled_session(store=self.store, job=job, created_by_user_id=self.admin_id)
|
||||
self.assertEqual(resolved.channel, "weixin")
|
||||
self.assertEqual(resolved.external_chat_id, "wx-user-123")
|
||||
|
||||
|
||||
class ScheduledDeliveryChannelTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_whatsapp_session_disables_weixin_delivery(self) -> None:
|
||||
sess = self.store.create_session("WA session")
|
||||
session_id = str(sess.id)
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(self.tenant_id, "whatsapp", "wa-default", "15551234567@s.whatsapp.net", "15551234567", session_id),
|
||||
)
|
||||
delivery = build_delivery_for_session(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self.assertTrue(delivery["whatsapp"]["enabled"])
|
||||
self.assertEqual(delivery["whatsapp"]["chat_id"], "15551234567@s.whatsapp.net")
|
||||
self.assertFalse(delivery["weixin"]["enabled"])
|
||||
|
||||
def test_weixin_session_disables_whatsapp_delivery(self) -> None:
|
||||
sess = self.store.create_session("WX session")
|
||||
session_id = str(sess.id)
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(self.tenant_id, "weixin", "weixin-default", "wx-user-9", "wx-user-9", session_id),
|
||||
)
|
||||
delivery = build_delivery_for_session(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self.assertTrue(delivery["weixin"]["enabled"])
|
||||
self.assertFalse(delivery["whatsapp"]["enabled"])
|
||||
|
||||
|
||||
class ScheduledDeliveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
@patch("runtime.scheduler.channel_delivery.enqueue_weixin_reply")
|
||||
def test_deliver_uses_stored_context_token(self, mock_enqueue: MagicMock) -> None:
|
||||
mock_enqueue.return_value = {"ok": True, "channel": "wechat", "queued": True}
|
||||
self.store.set_channel_context_token(
|
||||
tenant_id=self.tenant_id,
|
||||
channel="wechat",
|
||||
account_id="real-acct",
|
||||
external_chat_id="wx-user-123",
|
||||
context_token="ctx-abc",
|
||||
)
|
||||
sess = self.store.create_session("wx-deliver")
|
||||
session_id = str(sess.id)
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(self.tenant_id, "wechat", "real-acct", "wx-user-123", "wx-user-123", session_id),
|
||||
)
|
||||
out = deliver_scheduled_reply(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
reply_text="该起来活动啦",
|
||||
delivery_json='{"weixin":{"enabled":true}}',
|
||||
resolved_channel="wechat",
|
||||
resolved_chat_id="wx-user-123",
|
||||
resolved_account_id="weixin-default",
|
||||
session_id=session_id,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
mock_enqueue.assert_called_once()
|
||||
kwargs = mock_enqueue.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("context_token"), "ctx-abc")
|
||||
self.assertEqual(kwargs.get("chat_id"), "wx-user-123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
116
tests/test_scheduler_viewer_username.py
Normal file
116
tests/test_scheduler_viewer_username.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from runtime.scheduler.session_resolver import resolve_scheduled_viewer_username
|
||||
from runtime.scheduler.service import enqueue_scheduled_job_run
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ResolveScheduledViewerUsernameTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "sched.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.member = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="alice",
|
||||
display_name="Alice",
|
||||
role="member",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_weixin_uses_administrator_pool(self) -> None:
|
||||
uname = resolve_scheduled_viewer_username(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=str(self.member["id"]),
|
||||
channel="weixin",
|
||||
)
|
||||
self.assertEqual(uname, "administrator")
|
||||
|
||||
def test_admin_chat_uses_job_owner_username(self) -> None:
|
||||
uname = resolve_scheduled_viewer_username(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=str(self.member["id"]),
|
||||
channel="admin_chat",
|
||||
)
|
||||
self.assertEqual(uname, "alice")
|
||||
|
||||
def test_enqueue_payload_includes_viewer_username_for_weixin(self) -> None:
|
||||
job = MagicMock()
|
||||
job.tenant_id = self.tenant_id
|
||||
job.id = "job-weixin"
|
||||
job.next_run_at = "2026-06-26T10:00:00+00:00"
|
||||
job.delivery_json = '{"weixin":{"enabled":true}}'
|
||||
job.prompt_text = "stretch"
|
||||
job.lang = "zh"
|
||||
job.interaction_mode = "expert"
|
||||
job.specialist = "generalist"
|
||||
job.created_by_user_id = str(self.member["id"])
|
||||
job.schedule_kind = "interval"
|
||||
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
self.store.scheduled_job_run_create = MagicMock(return_value=run) # type: ignore[method-assign]
|
||||
self.store.scheduled_job_run_update = MagicMock() # type: ignore[method-assign]
|
||||
self.store.scheduled_job_reserve_next_run = MagicMock() # type: ignore[method-assign]
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _capture_create(**kwargs: object) -> MagicMock:
|
||||
captured.update(kwargs)
|
||||
return MagicMock(id="task-1")
|
||||
|
||||
self.store.oclaw_task_create = _capture_create # type: ignore[method-assign]
|
||||
|
||||
from runtime.scheduler import service as sched_service
|
||||
from runtime.scheduler.session_resolver import ResolvedSession
|
||||
|
||||
resolved = ResolvedSession(
|
||||
session_id="sess-1",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=str(self.admin["id"]),
|
||||
channel="weixin",
|
||||
account_id="weixin-default",
|
||||
external_chat_id="wx-user-1",
|
||||
external_user_id="wx-user-1",
|
||||
is_group=False,
|
||||
)
|
||||
original_resolve = sched_service.resolve_scheduled_session
|
||||
original_worker = sched_service.ensure_worker_started
|
||||
try:
|
||||
sched_service.resolve_scheduled_session = MagicMock(return_value=resolved)
|
||||
sched_service.ensure_worker_started = MagicMock(return_value="worker-1")
|
||||
enqueue_scheduled_job_run(self.store, job=job, mode="scheduled")
|
||||
finally:
|
||||
sched_service.resolve_scheduled_session = original_resolve
|
||||
sched_service.ensure_worker_started = original_worker
|
||||
|
||||
payload = captured.get("payload")
|
||||
self.assertIsInstance(payload, dict)
|
||||
assert isinstance(payload, dict)
|
||||
self.assertEqual(payload.get("viewer_username"), "administrator")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
93
tests/test_tool_context_inject.py
Normal file
93
tests/test_tool_context_inject.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.tools.context_inject import enrich_tool_arguments
|
||||
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ToolContextInjectTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ctx.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
sess = self.store.create_session_for_user(
|
||||
title="wx",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
self.session_id = str(sess.id)
|
||||
self.store.ensure_ui_session_owner(
|
||||
session_id=self.session_id,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_enrich_from_session_owner(self) -> None:
|
||||
out = enrich_tool_arguments(
|
||||
store=self.store,
|
||||
session_id=self.session_id,
|
||||
tool_name="schedule_create",
|
||||
arguments={"name": "x"},
|
||||
)
|
||||
self.assertEqual(out.get("tenant_id"), self.tenant_id)
|
||||
self.assertEqual(out.get("owner_user_id"), self.user_id)
|
||||
self.assertEqual(out.get("session_id"), self.session_id)
|
||||
|
||||
def test_schedule_create_strips_llm_user_id(self) -> None:
|
||||
tool = schedule_create_tool()
|
||||
res = tool.handler(
|
||||
{
|
||||
"session_id": self.session_id,
|
||||
"user_id": "wx_fake_external_id",
|
||||
"name": "Rest",
|
||||
"prompt_text": "提醒休息",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "300",
|
||||
}
|
||||
)
|
||||
self.assertTrue(res.get("ok"), res)
|
||||
|
||||
def test_tool_runtime_filters_user_id_for_schedule_schema(self) -> None:
|
||||
from runtime.tools.tool_validation import filter_arguments_to_schema
|
||||
|
||||
tool = schedule_create_tool()
|
||||
filtered = filter_arguments_to_schema(
|
||||
tool.parameters,
|
||||
{
|
||||
"user_id": "bad",
|
||||
"name": "x",
|
||||
"prompt_text": "y",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "60",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("user_id", filtered)
|
||||
self.assertIn("name", filtered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
77
tests/test_tool_runtime_schedule_validation.py
Normal file
77
tests/test_tool_runtime_schedule_validation.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
|
||||
from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
|
||||
from runtime.tools.base import ToolRegistry
|
||||
from svc.llm.transports.base import LLMToolCall
|
||||
from svc.persistence.assistant_store import reset_assistant_store_singleton
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ToolRuntimeScheduleValidationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "rt.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
|
||||
reset_assistant_store_singleton()
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash="x",
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
sess = self.store.create_session_for_user(
|
||||
title="wx",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
self.session_id = str(sess.id)
|
||||
self.store.ensure_ui_session_owner(
|
||||
session_id=self.session_id,
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_assistant_store_singleton()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_execute_schedule_create_with_hallucinated_user_id(self) -> None:
|
||||
reg = ToolRegistry([schedule_create_tool()])
|
||||
ex = ToolExecutor()
|
||||
ctx = ToolExecutionContext(
|
||||
store=self.store,
|
||||
tools=reg,
|
||||
session_id=self.session_id,
|
||||
path_policy_tenant_id=self.tenant_id,
|
||||
path_policy_user_id=self.user_id,
|
||||
)
|
||||
tc = LLMToolCall(
|
||||
id="call1",
|
||||
name="schedule_create",
|
||||
arguments={
|
||||
"user_id": "wechat_external_123",
|
||||
"name": "Break",
|
||||
"prompt_text": "Stand up",
|
||||
"schedule_kind": "interval",
|
||||
"schedule_expr": "300",
|
||||
},
|
||||
)
|
||||
result, _dur = ex._execute_tool(ctx, tc)
|
||||
self.assertTrue(result.get("ok"), result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
148
tests/test_weixin_scheduled_delivery.py
Normal file
148
tests/test_weixin_scheduled_delivery.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from runtime.scheduler.channel_delivery import enqueue_weixin_reply
|
||||
from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
|
||||
from svc.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class WeixinBridgePollTests(unittest.TestCase):
|
||||
def test_poll_accepts_wechat_channel_alias(self) -> None:
|
||||
from interfaces.http.weixin_ilink_api import _BRIDGE
|
||||
|
||||
_BRIDGE._events.clear()
|
||||
_BRIDGE._seq = 0
|
||||
_BRIDGE.enqueue_reply(
|
||||
token="",
|
||||
channel="weixin",
|
||||
account_id="acct-1",
|
||||
chat_id="user-1",
|
||||
text="hello",
|
||||
context_token="ctx-1",
|
||||
)
|
||||
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="acct-1")
|
||||
self.assertEqual(len(msgs), 1)
|
||||
self.assertEqual(str(msgs[0].get("text") or ""), "hello")
|
||||
|
||||
def test_poll_accepts_legacy_default_account_id(self) -> None:
|
||||
from interfaces.http.weixin_ilink_api import _BRIDGE
|
||||
|
||||
_BRIDGE._events.clear()
|
||||
_BRIDGE._seq = 0
|
||||
_BRIDGE.enqueue_reply(
|
||||
token="",
|
||||
channel="wechat",
|
||||
account_id="weixin-default",
|
||||
chat_id="user-1",
|
||||
text="hello",
|
||||
context_token="ctx-1",
|
||||
)
|
||||
msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="real-acct")
|
||||
self.assertEqual(len(msgs), 1)
|
||||
|
||||
|
||||
class WeixinDeliveryTargetTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "wx.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
sess = self.store.create_session("wx")
|
||||
self.session_id = str(sess.id)
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO channel_session_v2
|
||||
(tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
""",
|
||||
(self.tenant_id, "wechat", "real-acct-9", "wx-user-9", "wx-user-9", self.session_id),
|
||||
)
|
||||
self.store.set_channel_context_token(
|
||||
tenant_id=self.tenant_id,
|
||||
channel="wechat",
|
||||
account_id="real-acct-9",
|
||||
external_chat_id="wx-user-9",
|
||||
context_token="ctx-xyz",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_resolve_account_and_token_from_session(self) -> None:
|
||||
target = resolve_weixin_delivery_target(
|
||||
self.store,
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=self.session_id,
|
||||
delivery={"weixin": {"enabled": True}},
|
||||
resolved_channel="wechat",
|
||||
resolved_chat_id="",
|
||||
resolved_account_id="weixin-default",
|
||||
)
|
||||
self.assertEqual(target["account_id"], "real-acct-9")
|
||||
self.assertEqual(target["chat_id"], "wx-user-9")
|
||||
self.assertEqual(target["context_token"], "ctx-xyz")
|
||||
|
||||
|
||||
class EnqueueWeixinReplyTests(unittest.TestCase):
|
||||
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="7")
|
||||
def test_requires_context_token(self, _mock_enqueue: MagicMock) -> None:
|
||||
out = enqueue_weixin_reply(
|
||||
channel="wechat",
|
||||
account_id="acct",
|
||||
chat_id="user",
|
||||
text="hi",
|
||||
context_token="",
|
||||
)
|
||||
self.assertFalse(out.get("ok"))
|
||||
self.assertEqual(out.get("error"), "context_token_missing")
|
||||
_mock_enqueue.assert_not_called()
|
||||
|
||||
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="8")
|
||||
def test_queues_with_context_token(self, mock_enqueue: MagicMock) -> None:
|
||||
out = enqueue_weixin_reply(
|
||||
channel="wechat",
|
||||
account_id="acct",
|
||||
chat_id="user",
|
||||
text="hi",
|
||||
context_token="ctx-1",
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertTrue(out.get("queued"))
|
||||
mock_enqueue.assert_called_once()
|
||||
|
||||
@patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="9")
|
||||
def test_persists_durable_outbound_when_store_available(self, mock_enqueue: MagicMock) -> None:
|
||||
tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
db = Path(tmp.name) / "wx.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
|
||||
store = SqliteStore(str(db))
|
||||
try:
|
||||
out = enqueue_weixin_reply(
|
||||
channel="wechat",
|
||||
account_id="acct",
|
||||
chat_id="user",
|
||||
text="hi",
|
||||
context_token="ctx-1",
|
||||
store=store,
|
||||
tenant_id="t1",
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertTrue(out.get("durable"))
|
||||
pending = store.list_pending_weixin_outbound_messages(account_id="acct", limit=10)
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(str(pending[0].get("context_token") or ""), "ctx-1")
|
||||
mock_enqueue.assert_called_once()
|
||||
finally:
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue