This commit is contained in:
Your Name
2026-07-27 15:20:15 +08:00
parent 4970d8f8d3
commit 8ba13a8ff9
10 changed files with 424 additions and 39 deletions
@@ -0,0 +1,93 @@
from __future__ import annotations
import os
import sys
import unittest
from collections import namedtuple
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
BACKEND_DIR = Path(__file__).resolve().parents[1]
os.environ["KEFU_DB_TYPE"] = "sqlite"
os.environ["KEFU_DATABASE_URL"] = ""
os.environ["KEFU_DB_PATH"] = str(BACKEND_DIR / "kefu.db")
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import main
_AggregateRow = namedtuple(
"_AggregateRow",
(
"total_accounts",
"online_accounts",
"my_accounts",
"my_online_accounts",
),
)
class _AggregateResult:
def __init__(self, row):
self._row = row
def one(self):
return self._row
class DashboardAccountStatsTests(unittest.IsolatedAsyncioTestCase):
async def test_conditional_aggregate_maps_global_and_personal_counts(self):
row = _AggregateRow(
total_accounts=12,
online_accounts=5,
my_accounts=3,
my_online_accounts=2,
)
db = SimpleNamespace(
execute=AsyncMock(return_value=_AggregateResult(row))
)
user = SimpleNamespace(id=42, role="operator")
response = await main.get_dashboard_account_stats(db=db, user=user)
self.assertIsInstance(response, main.DashboardAccountStatsResponse)
self.assertEqual(response.total_accounts, 12)
self.assertEqual(response.online_accounts, 5)
self.assertEqual(response.my_accounts, 3)
self.assertEqual(response.my_online_accounts, 2)
db.execute.assert_awaited_once()
async def test_owner_scope_is_only_inside_personal_aggregates(self):
row = _AggregateRow(
total_accounts=8,
online_accounts=4,
my_accounts=2,
my_online_accounts=1,
)
db = SimpleNamespace(
execute=AsyncMock(return_value=_AggregateResult(row))
)
user = SimpleNamespace(id=73, role="viewer")
await main.get_dashboard_account_stats(db=db, user=user)
statement = db.execute.await_args.args[0]
sql = " ".join(str(statement).lower().split())
compiled_params = list(statement.compile().params.values())
# All roles receive the same global totals. The current user id may
# appear in CASE expressions for the two personal counters, but must
# never filter the entire aggregate query through a global WHERE.
self.assertIn("owner_id", sql)
self.assertGreaterEqual(sql.count("case when"), 3)
self.assertEqual(sql.count("accounts.owner_id"), 2)
self.assertIn(73, compiled_params)
self.assertNotIn(" where ", f" {sql} ")
self.assertEqual(db.execute.await_count, 1)
if __name__ == "__main__":
unittest.main()
+136 -4
View File
@@ -30,36 +30,127 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
await asyncio.sleep(0.002)
self.assertEqual(queue.pending_count, 0)
async def test_one_account_runs_three_jobs_at_successive_fifo_slots(self):
async def test_immediate_if_idle_runs_first_now_then_successive_fifo_slots(self):
queue = await self._start_queue(account_id=101)
interval = 0.05
loop = asyncio.get_running_loop()
started_at = loop.time()
calls: list[tuple[int, float]] = []
finished = asyncio.Event()
first_started = asyncio.Event()
release_first = asyncio.Event()
def callback_for(index: int):
async def callback() -> None:
calls.append((index, loop.time() - started_at))
if index == 0:
first_started.set()
await release_first.wait()
if len(calls) == 3:
finished.set()
return callback
for index in range(3):
await queue.enqueue(interval, callback_for(index), description=str(index))
await queue.enqueue(
interval,
callback_for(0),
description="0",
immediate_if_idle=True,
)
await asyncio.wait_for(first_started.wait(), timeout=0.1)
for index in (1, 2):
await queue.enqueue(
interval,
callback_for(index),
description=str(index),
immediate_if_idle=True,
)
release_first.set()
await asyncio.wait_for(finished.wait(), timeout=0.75)
await self._wait_until_idle(queue)
self.assertEqual([index for index, _ in calls], [0, 1, 2])
elapsed = [timestamp for _, timestamp in calls]
for timestamp, expected in zip(elapsed, (interval, interval * 2, interval * 3)):
for timestamp, expected in zip(elapsed, (0, interval, interval * 2)):
self.assertGreaterEqual(timestamp, expected - 0.015)
self.assertLess(timestamp, expected + 0.15)
self.assertGreaterEqual(elapsed[1] - elapsed[0], interval - 0.02)
self.assertGreaterEqual(elapsed[2] - elapsed[1], interval - 0.02)
async def test_immediate_if_idle_does_not_bypass_active_send(self):
queue = await self._start_queue(account_id=102)
interval = 1.0
first_started = asyncio.Event()
release_first = asyncio.Event()
second_started = asyncio.Event()
async def first_callback() -> None:
first_started.set()
await release_first.wait()
async def second_callback() -> None:
second_started.set()
await queue.enqueue(
interval,
first_callback,
description="first",
immediate_if_idle=True,
)
await asyncio.wait_for(first_started.wait(), timeout=0.1)
await queue.enqueue(
interval,
second_callback,
description="second",
immediate_if_idle=True,
)
snapshot = await queue.snapshot()
self.assertEqual([item["status"] for item in snapshot], ["sending", "waiting"])
self.assertEqual(snapshot[0]["interval_seconds"], 0)
self.assertEqual(snapshot[1]["interval_seconds"], int(interval))
release_first.set()
await asyncio.sleep(0.02)
self.assertFalse(second_started.is_set())
async def test_immediate_if_idle_resets_after_queue_drains(self):
queue = await self._start_queue(account_id=103)
interval = 1.0
first_finished = asyncio.Event()
second_started = asyncio.Event()
release_second = asyncio.Event()
async def first_callback() -> None:
first_finished.set()
async def second_callback() -> None:
second_started.set()
await release_second.wait()
await queue.enqueue(
interval,
first_callback,
description="first wave",
immediate_if_idle=True,
)
await asyncio.wait_for(first_finished.wait(), timeout=0.1)
await self._wait_until_idle(queue)
await queue.enqueue(
interval,
second_callback,
description="second wave",
immediate_if_idle=True,
)
await asyncio.wait_for(second_started.wait(), timeout=0.1)
snapshot = await queue.snapshot()
self.assertEqual(len(snapshot), 1)
self.assertEqual(snapshot[0]["status"], "sending")
self.assertEqual(snapshot[0]["interval_seconds"], 0)
release_second.set()
async def test_separate_account_queues_reach_first_slot_without_blocking(self):
first_queue = await self._start_queue(account_id=201)
second_queue = await self._start_queue(account_id=202)
@@ -194,6 +285,47 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
new_due = datetime.fromisoformat(new["scheduled_at"]).timestamp()
self.assertAlmostEqual(old_due - new_due, interval, delta=0.05)
async def test_send_now_on_zero_slot_does_not_claim_later_jobs_shifted(self):
queue = await self._start_queue(account_id=512)
interval = 1.0
async def noop() -> None:
pass
# Both enqueues complete without yielding to the consumer, preserving
# the narrow management-API window where the zero-slot first job is
# still waiting and can be selected by send-now.
await queue.enqueue(
interval,
noop,
description="immediate first",
immediate_if_idle=True,
)
await queue.enqueue(
interval,
noop,
description="scheduled second",
immediate_if_idle=True,
)
before = await queue.snapshot()
second_before = next(
item for item in before if item["description"] == "scheduled second"
)
result = await queue.send_now(before[0]["job_id"])
after = await queue.snapshot()
second_after = next(
item for item in after if item["description"] == "scheduled second"
)
self.assertEqual(result["status"], "accepted")
self.assertEqual(result["shifted_count"], 0)
second_due_before = datetime.fromisoformat(
second_before["scheduled_at"]
).timestamp()
second_due_after = datetime.fromisoformat(second_after["scheduled_at"]).timestamp()
self.assertAlmostEqual(second_due_after, second_due_before, delta=0.01)
async def test_send_now_middle_runs_first_and_only_shifts_jobs_behind_it(self):
queue = await self._start_queue(account_id=503)
interval = 0.12
+15 -2
View File
@@ -23,7 +23,9 @@ from rpa_engine.playwright_worker import DouyinWorker
class _RecordingQueue:
def __init__(self) -> None:
self.jobs: list[tuple[float, object, str, dict, frozenset[str]]] = []
self.jobs: list[
tuple[float, object, str, dict, frozenset[str], bool]
] = []
async def enqueue(
self,
@@ -33,11 +35,19 @@ class _RecordingQueue:
details=None,
merge_key="",
merge_keys=None,
immediate_if_idle=False,
) -> int:
keys = merge_keys if merge_keys is not None else [merge_key]
normalized_keys = frozenset(str(key) for key in keys if str(key or "").strip())
self.jobs.append(
(delay_seconds, callback, description, dict(details or {}), normalized_keys)
(
delay_seconds,
callback,
description,
dict(details or {}),
normalized_keys,
bool(immediate_if_idle),
)
)
return len(self.jobs)
@@ -75,6 +85,7 @@ class _RecordingQueue:
job[2],
merged_details,
frozenset(job[4] | incoming_keys),
job[5],
)
return {
"status": "merged",
@@ -123,6 +134,8 @@ class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(service._reply_queue.jobs), 1)
self.assertEqual(match_reply.await_count, 1)
self.assertEqual(service._reply_queue.jobs[0][0], 60)
self.assertTrue(service._reply_queue.jobs[0][5])
details = service._reply_queue.jobs[0][3]
self.assertEqual(details["sender_name"], "张三")
self.assertEqual(details["conversation_id"], "conv-1")