chore: add tests for redis infra, webhook queue and split-arch entrypoints

This commit is contained in:
3252a8
2026-05-17 00:31:12 +03:00
parent fc7f97c136
commit 1e8ffa15d0
5 changed files with 675 additions and 19 deletions
+121
View File
@@ -0,0 +1,121 @@
"""Verifies that the admin sync endpoint hands work to the worker via Redis.
After the container split, /api/admin/sync no longer runs ``perform_sync``
in-process; it enqueues a ``panel_sync`` event onto the webhook queue and
returns a fast ack. These tests pin that contract.
"""
import json
import unittest
from types import SimpleNamespace
from typing import Any, List
from unittest.mock import patch
from aiohttp import web
# Importing the facade populates each admin_api_impl submodule's globals with
# helpers like ``_require_admin_user_id`` and ``_ok``/``_error``. Without this
# side effect, ``sync_module._require_admin_user_id`` does not exist yet.
import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.admin_api_impl import sync as sync_module
class _FakeRequest:
"""The shape ``admin_sync_route`` reads from the aiohttp request."""
def __init__(self, settings: SimpleNamespace, admin_telegram_id: int = 42) -> None:
self.app = {"settings": settings}
self._store = {"admin_telegram_id": admin_telegram_id}
def get(self, key: str, default: Any = None) -> Any:
return self._store.get(key, default)
def _make_settings() -> SimpleNamespace:
return SimpleNamespace(
ADMIN_IDS=[42],
REDIS_KEY_PREFIX="shop",
WEBHOOK_QUEUE_NAME="webhook-events",
REDIS_URL="redis://r:6379/0",
)
def _parse(response: web.Response) -> dict:
return json.loads(response.body.decode())
def _patch_admin_auth(monkeypatch_target: Any) -> None:
"""``_require_admin_user_id`` looks up session state we don't have here."""
monkeypatch_target.side_effect = lambda request: int(request.get("admin_telegram_id"))
class AdminSyncQueueTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_queued_when_redis_accepts_event(self):
recorded: List[dict] = []
async def fake_enqueue(settings, provider, payload, *, event_id=None):
recorded.append(
{
"settings": settings,
"provider": provider,
"payload": payload,
"event_id": event_id,
}
)
return True
with (
patch.object(sync_module, "enqueue_webhook_event", fake_enqueue),
patch.object(sync_module, "_require_admin_user_id") as auth,
):
_patch_admin_auth(auth)
response = await sync_module.admin_sync_route(_FakeRequest(_make_settings()))
self.assertEqual(response.status, 200)
body = _parse(response)
self.assertTrue(body["ok"])
self.assertEqual(body["result"], {"status": "queued"})
self.assertEqual(len(recorded), 1)
entry = recorded[0]
self.assertEqual(entry["provider"], "panel_sync")
self.assertEqual(entry["payload"], {"requested_by": 42})
# event_id=None lets Redis enqueue every admin request (no dedupe key).
self.assertIsNone(entry["event_id"])
async def test_returns_503_when_queue_is_unavailable(self):
async def fake_enqueue(settings, provider, payload, *, event_id=None):
return False
with (
patch.object(sync_module, "enqueue_webhook_event", fake_enqueue),
patch.object(sync_module, "_require_admin_user_id") as auth,
):
_patch_admin_auth(auth)
response = await sync_module.admin_sync_route(_FakeRequest(_make_settings()))
self.assertEqual(response.status, 503)
body = _parse(response)
self.assertFalse(body["ok"])
self.assertEqual(body["error"], "queue_unavailable")
async def test_non_admin_is_rejected_before_enqueueing(self):
async def fake_enqueue(*args, **kwargs):
raise AssertionError("enqueue must not be called for non-admin requests")
def deny(_request):
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "forbidden"}),
content_type="application/json",
)
with (
patch.object(sync_module, "enqueue_webhook_event", fake_enqueue),
patch.object(sync_module, "_require_admin_user_id", side_effect=deny),
):
with self.assertRaises(web.HTTPForbidden):
await sync_module.admin_sync_route(_FakeRequest(_make_settings()))
if __name__ == "__main__": # pragma: no cover
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
import io
import logging
import os
import sys
import unittest
from unittest.mock import patch
import app_logging
def _reset_root_logger() -> None:
root = logging.getLogger()
for handler in list(root.handlers):
root.removeHandler(handler)
handler.close()
root.setLevel(logging.WARNING)
class ConfigureLoggingTests(unittest.TestCase):
def setUp(self) -> None:
_reset_root_logger()
self.addCleanup(_reset_root_logger)
def test_default_level_is_info(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("LOG_LEVEL", None)
app_logging.configure_logging()
self.assertEqual(logging.getLogger().level, logging.INFO)
def test_log_level_env_is_honored(self):
with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}, clear=False):
app_logging.configure_logging()
self.assertEqual(logging.getLogger().level, logging.DEBUG)
def test_invalid_log_level_falls_back_to_info(self):
with patch.dict(os.environ, {"LOG_LEVEL": "TOTALLY_BOGUS"}, clear=False):
app_logging.configure_logging()
self.assertEqual(logging.getLogger().level, logging.INFO)
def test_handler_writes_to_stdout_with_expected_format(self):
# basicConfig is a no-op if root already has handlers — start clean.
with patch.dict(os.environ, {"LOG_LEVEL": "INFO"}, clear=False):
app_logging.configure_logging()
# First handler is the one we installed (basicConfig adds at most one).
handlers = logging.getLogger().handlers
self.assertTrue(handlers, "configure_logging() must install a handler")
handler = handlers[0]
self.assertIs(handler.stream, sys.stdout)
# Confirm the format string structure by emitting a record into a buffer.
formatter = handler.formatter
record = logging.LogRecord(
name="test", level=logging.INFO, pathname=__file__, lineno=1,
msg="hello", args=(), exc_info=None,
)
rendered = formatter.format(record) if formatter else ""
# Format is "%(asctime)s - %(name)s - %(levelname)s - %(message)s".
self.assertIn(" - test - INFO - hello", rendered)
def test_configure_logging_does_not_swallow_subsequent_logs(self):
buffer = io.StringIO()
# Reset state, then replace stdout briefly so we can capture handler output.
with patch.dict(os.environ, {"LOG_LEVEL": "INFO"}, clear=False):
with patch.object(sys, "stdout", buffer):
app_logging.configure_logging()
logging.getLogger("test.module").info("payload-marker")
for handler in logging.getLogger().handlers:
handler.flush()
self.assertIn("payload-marker", buffer.getvalue())
self.assertIn("INFO", buffer.getvalue())
if __name__ == "__main__": # pragma: no cover
unittest.main()
+141
View File
@@ -0,0 +1,141 @@
"""Behaviour of ``PanelWebhookService.handle_webhook``.
The service must:
* reject requests without the configured shared secret or a missing/bad
signature header — otherwise an attacker could forge panel events;
* acknowledge an event with HTTP 200 once it is on the queue (the worker
container does the heavy lifting);
* fall back to in-process background dispatch when Redis is unreachable so
a single-node deploy still processes events.
"""
import asyncio
import hashlib
import hmac
import json
import unittest
from types import SimpleNamespace
from typing import Any, List
from unittest.mock import patch
from bot.services import panel_webhook_service as pws
SECRET = "panel-shared-secret"
def _sign(body: bytes) -> str:
return hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
def _make_service():
settings = SimpleNamespace(
PANEL_WEBHOOK_SECRET=SECRET,
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True,
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3,
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True,
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True,
DEFAULT_LANGUAGE="ru",
email_auth_configured=False,
)
# Build minimally with object() placeholders — handle_webhook does not
# touch the bot/i18n/db/panel collaborators on its enqueue path.
return pws.PanelWebhookService(
bot=object(),
settings=settings,
i18n=object(),
async_session_factory=object(),
panel_service=object(),
)
class HandleWebhookSecurityTests(unittest.IsolatedAsyncioTestCase):
async def test_unauthorized_when_secret_not_configured(self):
service = _make_service()
service.settings.PANEL_WEBHOOK_SECRET = ""
response = await service.handle_webhook(b"{}", _sign(b"{}"))
self.assertEqual(response.status, 401)
async def test_unauthorized_when_signature_header_missing(self):
service = _make_service()
response = await service.handle_webhook(b'{"name":"user.expired"}', None)
self.assertEqual(response.status, 401)
async def test_unauthorized_on_signature_mismatch(self):
service = _make_service()
body = b'{"name":"user.expired"}'
response = await service.handle_webhook(body, "deadbeef")
self.assertEqual(response.status, 401)
async def test_bad_request_for_invalid_json(self):
service = _make_service()
body = b"not-json"
response = await service.handle_webhook(body, _sign(body))
self.assertEqual(response.status, 400)
async def test_ok_no_event_when_name_missing(self):
service = _make_service()
body = json.dumps({"payload": {"telegramId": 1}}).encode()
response = await service.handle_webhook(body, _sign(body))
self.assertEqual(response.status, 200)
self.assertEqual(response.text, "ok_no_event")
class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase):
async def test_enqueues_to_redis_and_returns_ok(self):
service = _make_service()
captured: List[dict] = []
async def fake_enqueue(settings, provider, payload, *, event_id=None):
captured.append(
{"provider": provider, "payload": payload, "event_id": event_id}
)
return True
body = json.dumps(
{
"name": "user.expires_in_24_hours",
"payload": {"telegramId": 99, "uuid": "abc"},
}
).encode()
with patch.object(pws, "enqueue_webhook_event", fake_enqueue):
response = await service.handle_webhook(body, _sign(body))
self.assertEqual(response.status, 200)
self.assertEqual(response.text, "ok")
self.assertEqual(len(captured), 1)
entry = captured[0]
self.assertEqual(entry["provider"], "panel")
self.assertEqual(entry["payload"]["event"], "user.expires_in_24_hours")
self.assertEqual(entry["payload"]["user"], {"telegramId": 99, "uuid": "abc"})
# event_id combines event name with the strongest available identifier.
self.assertEqual(entry["event_id"], "user.expires_in_24_hours:99")
async def test_falls_back_to_background_task_when_redis_unavailable(self):
service = _make_service()
background_seen: List[Any] = []
async def fake_enqueue(*args, **kwargs):
return False
async def fake_handle_event(event_name, user_payload):
background_seen.append((event_name, user_payload))
body = json.dumps(
{"name": "user.expired", "payload": {"telegramId": 7}}
).encode()
with (
patch.object(pws, "enqueue_webhook_event", fake_enqueue),
patch.object(service, "handle_event", fake_handle_event),
):
response = await service.handle_webhook(body, _sign(body))
# Yield to the event loop so the scheduled background task runs.
await asyncio.sleep(0)
self.assertEqual(response.status, 200)
self.assertEqual(background_seen, [("user.expired", {"telegramId": 7})])
if __name__ == "__main__": # pragma: no cover
unittest.main()
+83 -19
View File
@@ -4,33 +4,97 @@ import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
from startup_banner import print_startup_banner
from startup_banner import _redis_target, print_startup_banner
def _render(service: str, env: dict) -> str:
buffer = io.StringIO()
with patch.dict(os.environ, env, clear=False), redirect_stdout(buffer):
print_startup_banner(service)
return buffer.getvalue()
class StartupBannerTests(unittest.TestCase):
def test_startup_banner_marks_services_without_mojibake(self):
for service in ("frontend", "backend", "worker", "migrate"):
with self.subTest(service=service):
buffer = io.StringIO()
with (
patch.dict(
os.environ,
{
"IMAGE_TAG": "test-tag",
"POSTGRES_HOST": "postgres",
"POSTGRES_DB": "postgres",
"REDIS_URL": "redis://redis:6379/0",
},
clear=False,
),
redirect_stdout(buffer),
):
print_startup_banner(service)
output = buffer.getvalue()
output = _render(
service,
{
"IMAGE_TAG": "test-tag",
"POSTGRES_HOST": "postgres",
"POSTGRES_DB": "postgres",
"REDIS_URL": "redis://redis:6379/0",
},
)
self.assertIn(f"container :: {service.upper()}", output)
self.assertIn("image tag :: test-tag", output)
self.assertIn("remnawave-minishop", output)
self.assertIn("███", output)
self.assertNotIn("в", output)
class StartupBannerServiceDetailsTests(unittest.TestCase):
def test_backend_lists_ports_postgres_and_redis(self):
output = _render(
"backend",
{
"IMAGE_TAG": "v1",
"WEB_SERVER_PORT": "8090",
"WEBAPP_SERVER_PORT": "8091",
"WEBAPP_ENABLED": "True",
"POSTGRES_HOST": "pg",
"POSTGRES_PORT": "5440",
"POSTGRES_DB": "shop",
"REDIS_URL": "redis://r:6380/2",
},
)
self.assertIn("webhooks :: :8090", output)
self.assertIn("webapp api :: on / :8091", output)
self.assertIn("postgres :: pg:5440/shop", output)
self.assertIn("redis :: r:6380/2", output)
def test_worker_lists_queue_and_panel_sync_interval(self):
output = _render(
"worker",
{
"IMAGE_TAG": "v1",
"WEBHOOK_QUEUE_CONCURRENCY": "8",
"WORKER_PANEL_SYNC_INTERVAL_SECONDS": "600",
"TARIFFS_CONFIG_PATH": "data/tariffs.json",
"POSTGRES_HOST": "pg",
"POSTGRES_DB": "shop",
"REDIS_URL": "redis://r:6379/0",
},
)
self.assertIn("queue concurrency :: 8", output)
self.assertIn("panel sync interval :: 600s", output)
self.assertIn("tariffs config :: data/tariffs.json", output)
def test_migrate_shows_one_shot_mode_and_data_dir(self):
output = _render(
"migrate",
{
"IMAGE_TAG": "v1",
"POSTGRES_HOST": "pg",
"POSTGRES_DB": "shop",
},
)
self.assertIn("mode :: one-shot migrations", output)
self.assertIn("data dir :: /app/data", output)
class StartupBannerRedisTargetTests(unittest.TestCase):
def test_redis_target_renders_host_port_db(self):
with patch.dict(os.environ, {"REDIS_URL": "redis://r:6379/3"}, clear=False):
self.assertEqual(_redis_target(), "r:6379/3")
def test_redis_target_defaults_db_to_zero(self):
with patch.dict(os.environ, {"REDIS_URL": "redis://r"}, clear=False):
self.assertEqual(_redis_target(), "r/0")
def test_redis_target_when_url_missing(self):
env = dict(os.environ)
env.pop("REDIS_URL", None)
with patch.dict(os.environ, env, clear=True):
self.assertEqual(_redis_target(), "-")
+255
View File
@@ -0,0 +1,255 @@
import asyncio
import json
import time
import unittest
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from unittest.mock import patch
from bot.infra import redis as redis_infra
from bot.infra import webhook_queue
class FakeRedis:
"""Minimal async stand-in for redis.asyncio.Redis used by tests."""
def __init__(self) -> None:
self._kv: Dict[str, str] = {}
self._ttl: Dict[str, float] = {}
self._lists: Dict[str, List[str]] = {}
async def set(
self,
key: str,
value: str,
*,
nx: bool = False,
ex: Optional[int] = None,
) -> bool:
self._expire_if_due(key)
if nx and key in self._kv:
return False
self._kv[key] = value
if ex is not None:
self._ttl[key] = time.monotonic() + ex
else:
self._ttl.pop(key, None)
return True
async def get(self, key: str) -> Optional[str]:
self._expire_if_due(key)
return self._kv.get(key)
async def delete(self, *keys: str) -> int:
removed = 0
for key in keys:
if key in self._kv:
self._kv.pop(key, None)
self._ttl.pop(key, None)
removed += 1
if key in self._lists:
self._lists.pop(key, None)
removed += 1
return removed
async def lpush(self, key: str, *values: str) -> int:
bucket = self._lists.setdefault(key, [])
for value in values:
bucket.insert(0, value)
return len(bucket)
async def brpop(
self, key: str, timeout: int = 0
) -> Optional[Tuple[str, str]]:
bucket = self._lists.get(key)
if bucket:
return key, bucket.pop()
# In real Redis, brpop blocks. Tests never use the timeout path.
return None
async def llen(self, key: str) -> int:
return len(self._lists.get(key, []))
async def eval(self, script: str, numkeys: int, *args: Any) -> int:
# The only Lua used by the code base releases redis_lock atomically.
keys = args[:numkeys]
argv = args[numkeys:]
if keys and argv and self._kv.get(keys[0]) == argv[0]:
self._kv.pop(keys[0], None)
self._ttl.pop(keys[0], None)
return 1
return 0
async def aclose(self) -> None: # pragma: no cover - close path
self._kv.clear()
self._ttl.clear()
self._lists.clear()
def _expire_if_due(self, key: str) -> None:
expires_at = self._ttl.get(key)
if expires_at is not None and time.monotonic() >= expires_at:
self._kv.pop(key, None)
self._ttl.pop(key, None)
def _make_settings(**overrides: Any) -> SimpleNamespace:
base: Dict[str, Any] = {
"REDIS_URL": "redis://redis:6379/0",
"REDIS_KEY_PREFIX": "remnawave-tg-shop",
"WEBHOOK_QUEUE_NAME": "webhook-events",
}
base.update(overrides)
return SimpleNamespace(**base)
class RedisKeyTests(unittest.TestCase):
def test_redis_key_joins_prefix_and_parts(self):
settings = _make_settings(REDIS_KEY_PREFIX="shop")
self.assertEqual(redis_infra.redis_key(settings, "queue", "events"), "shop:queue:events")
def test_redis_key_filters_empty_or_colon_only_parts(self):
settings = _make_settings(REDIS_KEY_PREFIX="shop")
# Empty / colon-only parts are dropped; numeric and string parts coexist.
self.assertEqual(
redis_infra.redis_key(settings, "lock", "", ":", "panel-sync", 42),
"shop:lock:panel-sync:42",
)
def test_redis_key_strips_leading_trailing_colons(self):
settings = _make_settings(REDIS_KEY_PREFIX=":shop:")
self.assertEqual(
redis_infra.redis_key(settings, ":webhook:", "seen"),
"shop:webhook:seen",
)
class WebhookQueueTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.fake = FakeRedis()
async def fake_get_redis(_settings):
return self.fake
self._patcher = patch.object(webhook_queue, "get_redis", fake_get_redis)
self._patcher.start()
self.addCleanup(self._patcher.stop)
async def test_enqueue_returns_false_when_redis_unavailable(self):
async def no_redis(_settings):
return None
with patch.object(webhook_queue, "get_redis", no_redis):
ok = await webhook_queue.enqueue_webhook_event(
_make_settings(), "yookassa", {"id": "p_1"}
)
self.assertFalse(ok)
async def test_enqueue_writes_payload_and_increments_depth(self):
settings = _make_settings()
ok = await webhook_queue.enqueue_webhook_event(
settings,
"yookassa",
{"id": "p_42", "amount": "100"},
event_id="payment.succeeded:p_42",
)
self.assertTrue(ok)
depth = await webhook_queue.webhook_queue_depth(settings)
self.assertEqual(depth, 1)
popped = await webhook_queue.pop_webhook_event(settings)
assert popped is not None
self.assertEqual(popped["provider"], "yookassa")
self.assertEqual(popped["event_id"], "payment.succeeded:p_42")
self.assertEqual(popped["payload"], {"id": "p_42", "amount": "100"})
self.assertIsInstance(popped["enqueued_at"], (int, float))
async def test_enqueue_dedupes_repeated_event_ids(self):
settings = _make_settings()
first = await webhook_queue.enqueue_webhook_event(
settings,
"panel",
{"event": "user.expired", "user": {"telegramId": 1}},
event_id="user.expired:1",
)
second = await webhook_queue.enqueue_webhook_event(
settings,
"panel",
{"event": "user.expired", "user": {"telegramId": 1}},
event_id="user.expired:1",
)
self.assertTrue(first)
# Dedupe path is treated as a success — the event was already accepted.
self.assertTrue(second)
self.assertEqual(await webhook_queue.webhook_queue_depth(settings), 1)
async def test_pop_returns_none_when_queue_empty(self):
settings = _make_settings()
self.assertIsNone(await webhook_queue.pop_webhook_event(settings))
async def test_pop_skips_invalid_payload(self):
settings = _make_settings()
await self.fake.lpush(webhook_queue.webhook_queue_key(settings), "not-json")
result = await webhook_queue.pop_webhook_event(settings)
self.assertIsNone(result)
async def test_queue_key_uses_prefix_and_queue_name(self):
settings = _make_settings(REDIS_KEY_PREFIX="shop", WEBHOOK_QUEUE_NAME="custom-q")
self.assertEqual(webhook_queue.webhook_queue_key(settings), "shop:queue:custom-q")
async def test_enqueue_serializes_unicode_without_escaping(self):
settings = _make_settings()
await webhook_queue.enqueue_webhook_event(
settings, "panel", {"name": "Юзер", "id": 7}, event_id="u:7"
)
# Raw payload in Redis preserves Cyrillic without \uXXXX escapes.
raw = self.fake._lists[webhook_queue.webhook_queue_key(settings)][0]
self.assertIn("Юзер", raw)
parsed = json.loads(raw)
self.assertEqual(parsed["payload"]["name"], "Юзер")
class RedisLockTests(unittest.IsolatedAsyncioTestCase):
async def test_lock_is_noop_when_redis_unavailable(self):
async def no_redis(_settings):
return None
with patch.object(redis_infra, "get_redis", no_redis):
async with redis_infra.redis_lock(
_make_settings(), "panel-sync", ttl_seconds=30
) as acquired:
self.assertTrue(acquired)
async def test_lock_excludes_concurrent_holders_and_releases_on_exit(self):
fake = FakeRedis()
async def fake_get_redis(_settings):
return fake
with patch.object(redis_infra, "get_redis", fake_get_redis):
settings = _make_settings()
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as first:
self.assertTrue(first)
async with redis_infra.redis_lock(
settings, "panel-sync", ttl_seconds=30
) as second:
self.assertFalse(second)
# After exit, the lock is released and can be re-acquired.
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as again:
self.assertTrue(again)
class SleepOrStopTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_promptly_when_event_set(self):
event = asyncio.Event()
event.set()
await redis_infra.sleep_or_stop(event, seconds=5) # would hang on a bug
async def test_times_out_when_event_not_set(self):
event = asyncio.Event()
# Tiny timeout keeps the test fast; the function should not raise on timeout.
await redis_infra.sleep_or_stop(event, seconds=0.01)
if __name__ == "__main__": # pragma: no cover
unittest.main()