更新
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user