Open the psycopg transaction without unsupported SQLAlchemy-style options, then set the PostgreSQL isolation and read-only characteristics before the first query. Cover the exact snapshot endpoint path with a regression test.
167 lines
4.7 KiB
Python
167 lines
4.7 KiB
Python
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.v1.endpoints import workbench_events as endpoint
|
|
from app.auth.project_dependencies import ProjectContext
|
|
from app.infra.db.project_routing import ActiveProjectRouting
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
class FakeHub:
|
|
generation = 0
|
|
|
|
async def oldest_cursor(self):
|
|
return 5
|
|
|
|
async def current_cursor(self):
|
|
return 8
|
|
|
|
|
|
class FakeHubManager:
|
|
@asynccontextmanager
|
|
async def subscribe(self, _project_id, _dsn):
|
|
yield FakeHub()
|
|
|
|
|
|
class FakeMetadataRepository:
|
|
def __init__(self, routings):
|
|
self.routings = routings
|
|
self.session = SimpleNamespace(closed=False, close=self.close)
|
|
|
|
async def close(self):
|
|
self.session.closed = True
|
|
|
|
async def list_active_project_db_routings(self, _role):
|
|
return self.routings
|
|
|
|
|
|
class FakeSnapshotConnection:
|
|
def __init__(self):
|
|
self.statements = []
|
|
|
|
@asynccontextmanager
|
|
async def transaction(self):
|
|
yield
|
|
|
|
async def execute(self, statement):
|
|
self.statements.append(statement)
|
|
|
|
|
|
class FakeEventRepository:
|
|
def __init__(self, _conn, _project_id):
|
|
pass
|
|
|
|
async def current_cursor(self):
|
|
return 12
|
|
|
|
|
|
class FakeOperationalTaskService:
|
|
async def list(self, **_kwargs):
|
|
return [], None
|
|
|
|
async def list_notifications(self, **_kwargs):
|
|
return [], None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sync_snapshot_starts_psycopg_read_only_repeatable_read_transaction(monkeypatch):
|
|
project_id = uuid4()
|
|
conn = FakeSnapshotConnection()
|
|
now = datetime.now(timezone.utc)
|
|
|
|
async def fake_runtime(**_kwargs):
|
|
return {
|
|
"availability": "not_configured",
|
|
"source": "none",
|
|
"scenario_availability": "not_configured",
|
|
"artifact_availability": "not_configured",
|
|
"schedule_availability": "not_configured",
|
|
"work_order_availability": "not_configured",
|
|
"alert_availability": "not_configured",
|
|
"generated_at": now,
|
|
"conditions": [],
|
|
"alerts": [],
|
|
"scenarios": [],
|
|
"latest_analysis": None,
|
|
"work_orders": [],
|
|
}
|
|
|
|
monkeypatch.setattr(endpoint, "WorkbenchEventRepository", FakeEventRepository)
|
|
monkeypatch.setattr(endpoint, "workbench_runtime", fake_runtime)
|
|
monkeypatch.setattr(endpoint, "_service", lambda _ctx, _conn: FakeOperationalTaskService())
|
|
|
|
snapshot = await endpoint.workbench_sync_snapshot(
|
|
ctx=ProjectContext(
|
|
project_id=project_id,
|
|
project_code="project-a",
|
|
user_id=uuid4(),
|
|
project_role="member",
|
|
),
|
|
conn=conn,
|
|
)
|
|
|
|
assert snapshot.event_cursor == 12
|
|
assert conn.statements == [
|
|
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_expired_event_cursor_requires_snapshot_reset(monkeypatch):
|
|
project_id = uuid4()
|
|
dsn = "postgresql://test@db.example:5432/project_a"
|
|
routing = ActiveProjectRouting("project-a", dsn, project_id=project_id)
|
|
metadata = FakeMetadataRepository([
|
|
SimpleNamespace(project_id=project_id, dsn=dsn)
|
|
])
|
|
monkeypatch.setattr(endpoint, "workbench_event_hubs", FakeHubManager())
|
|
|
|
response = await endpoint.workbench_events(
|
|
after=1,
|
|
last_event_id=None,
|
|
ctx=ProjectContext(
|
|
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
|
project_role="member",
|
|
),
|
|
routing=routing,
|
|
metadata_repo=metadata,
|
|
)
|
|
first = await anext(response.body_iterator)
|
|
|
|
assert metadata.session.closed is True
|
|
assert "event: reset_required" in first
|
|
assert '"cursor":1' in first
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_shared_business_database_disables_event_stream():
|
|
project_id = uuid4()
|
|
dsn = "postgresql://user:secret@db.example:5432/shared"
|
|
metadata = FakeMetadataRepository([
|
|
SimpleNamespace(project_id=project_id, dsn=dsn),
|
|
SimpleNamespace(project_id=uuid4(), dsn="postgresql://other:other@db.example/shared"),
|
|
])
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await endpoint.workbench_events(
|
|
after=0,
|
|
last_event_id=None,
|
|
ctx=ProjectContext(
|
|
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
|
project_role="member",
|
|
),
|
|
routing=ActiveProjectRouting("project-a", dsn, project_id=project_id),
|
|
metadata_repo=metadata,
|
|
)
|
|
|
|
assert exc.value.status_code == 503
|