Files
robotex/tests/harness.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)
- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests.
- Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без
  выпущенных ключей доступа нет. В базе — только sha256 ключа.
- Задача и окно сессии привязаны к клиенту: чужой ключ их не видит.
- Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу
  (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу.
- python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats.
- smoke и loadtest с ключом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:48:48 +04:00

87 lines
3.0 KiB
Python

"""api и worker в одном процессе без RabbitMQ и Redis: Redis — fakeredis, отправка в очередь подменена
доставкой сообщения прямо в WindowPool воркера (tests/unit, tests/browser)."""
import asyncio
import json
from contextlib import contextmanager
import fakeredis
from fastapi.testclient import TestClient
import main
from db import clients
from db.engine import connect as connect_db
from db.journal import Journal
from tasks import store as task_store
from tasks.broker import RUN_TASK
from worker import windows
WORKER_ID = "w1"
class Harness:
def __init__(self, store, pool, redis_sync):
self.store = store
self.pool = pool
self.redis = redis_sync
self.messages = []
self.outcomes = []
# True — сообщение выполняется сразу при отправке; False — копится в messages до deliver()
self.auto = True
self.client: TestClient | None = None
async def dispatch(self, message):
self.messages.append(message)
if self.auto:
await self.deliver(message)
async def deliver(self, message):
if message.actor_name == RUN_TASK:
self.outcomes.append(await self.pool.handle(*message.args))
else:
await self.pool.close(*message.args)
await self.store.heartbeat(WORKER_ID, self.pool.info())
def deliver_pending(self):
pending, self.messages = self.messages, []
for message in pending:
self.client.portal.call(self.deliver, message)
def heartbeat(self, **info):
self.redis.set(f"{task_store.PREFIX}:worker:{WORKER_ID}", json.dumps(self.pool.info() | info))
def call(self, fn, *args):
return self.client.portal.call(fn, *args)
def make_client_key(db, name="test") -> str:
async def create():
await db.init()
return (await clients.create_client(db, name))[1]
key = asyncio.run(create())
asyncio.run(db.close()) # соединения sqlite привязаны к циклу asyncio.run — закрываем, приложение откроет свои
return key
@contextmanager
def run_env(monkeypatch, tmp_path, slots=2):
server = fakeredis.FakeServer()
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
db = connect_db(f"sqlite+aiosqlite:///{tmp_path / 'robotex.db'}")
key = make_client_key(db)
pool = windows.WindowPool(store, WORKER_ID, slots=slots, journal=Journal(db))
harness = Harness(store, pool, fakeredis.FakeRedis(server=server, decode_responses=True))
harness.db, harness.api_key = db, key
# профили — в отдельном каталоге: в tmp_path ещё и файл базы
profiles = tmp_path / "profiles"
profiles.mkdir()
harness.profiles = profiles
monkeypatch.setattr(windows, "USER_DATA_ROOT", profiles)
monkeypatch.setattr(main, "store", store)
monkeypatch.setattr(main, "database", db)
monkeypatch.setattr(main, "dispatch", harness.dispatch)
harness.heartbeat()
with TestClient(main.app, headers={"X-API-Key": key}) as client:
harness.client = client
yield harness
client.portal.call(pool.close_all)