Files
qtalker---/backend/tests/conftest.py
Indiana c88fbc843a feat: device pairing, telemetry ingestion, live dashboard WS (Workstream G)
Implements the backend half of the ESP32-P4 sensor node spec's pairing,
ingestion, and live-broadcast contract:

- New Device model (backend/app/models/device.py): id, user_id FK, name,
  token_hash (unique+indexed), created_at, last_seen_at. Reuses
  generate_session_token()/hash_token() from auth_session.py verbatim for
  the one-time raw pairing token / stored hash.
- POST /api/device, GET /api/device (session-cookie authenticated REST
  pairing endpoints) and POST /api/device/telemetry (device bearer-token
  authenticated ingestion, per-device rate limited, 16KB body cap, 64
  reading cap, strict shape validation — never a 500 on garbage input) in
  backend/app/routes/device.py.
- /ws/device-feed live dashboard WS (qm_session cookie authenticated),
  fanning out ingested readings to the owning user's connected dashboard
  sockets via an in-process dict[user_id, connections] registry, each with
  its own send-queue + single sender task (mirrors app.ws's
  SeanceState/_sender convention).
- last_seen_at updates on every successful ingestion.
- _process_reading(device, reading) left as an explicit no-op handoff point
  for Workstream K's summon-pipeline integration.

Backend suite: 102 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:12:21 +00:00

66 lines
2.3 KiB
Python

import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.db import Base, get_db
from app.main import app
# pytest-asyncio gives each test function its own event loop by default;
# asyncpg connections are bound to the loop they were opened on, so a pooled
# connection from one test's loop breaks the next test. NullPool sidesteps
# this by opening a fresh connection per checkout — scoped to this dedicated
# test engine so app.db.engine (used by production) is unaffected.
test_engine = create_async_engine(settings.database_url, poolclass=NullPool)
TestSessionLocal = async_sessionmaker(test_engine, expire_on_commit=False)
@pytest_asyncio.fixture(autouse=True)
async def _reset_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async def _override_get_db():
async with TestSessionLocal() as session:
yield session
app.dependency_overrides[get_db] = _override_get_db
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
@pytest.fixture
def sync_client(monkeypatch):
"""Sync TestClient (supports WebSocket tests). The WS handler's session
maker is swapped to the NullPool test engine so its writes land in the
same test database the async fixtures see. The lifespan's engine is also
swapped: TestClient runs the lifespan on its own portal loop, and the
pooled production engine would carry connections across loops."""
import app.main as main_module
import app.routes.device as device_module
import app.ws as ws_module
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(device_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(main_module, "engine", test_engine)
with TestClient(app, base_url="https://testserver") as tc:
yield tc
@pytest_asyncio.fixture
async def db_session():
async with TestSessionLocal() as session:
yield session