SFM/tests/unit/test_opencloud_token_store.py
Raykov-MS 4e11db322d Fix event-loop mismatch masking OpenCloud token as "not found" on first click
Each call into opencloud_token_store is wrapped in its own asyncio.run()
(app/app_config.py, main.py's token popover), so every call gets a fresh
event loop. The asyncpg connection pool is bound to the loop it was created
in, so the next asyncio.run() could try to reuse a connection from an
already-closed loop and fail — unrelated to actual DB availability, but the
broad except Exception in resolve_opencloud_access_token() silently
swallowed it and fell through to "token not found", masking the real cause.

- add_token/get_token/remove_token/list_usernames now call engine.dispose()
  in a finally block so each asyncio.run() starts with a clean pool.
- resolve_opencloud_access_token() now surfaces the real exception via
  st.error() before falling back to the .env token (kept only for local dev
  without a DB).
- Added tests/unit/test_opencloud_token_store.py covering that dispose() is
  called after every store operation, including when commit() raises.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:56:08 +03:00

162 lines
5.1 KiB
Python

from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock
import pytest
from cryptography.fernet import Fernet
from app import opencloud_token_store as store
from app.opencloud_token_store import OpenCloudTokenStoreError
class FakeResult:
def __init__(self, usernames: list[str]) -> None:
self._usernames = usernames
def scalars(self):
return self
def all(self):
return self._usernames
class FakeRow:
def __init__(self, username: str, encrypted_token: str, added_by=None) -> None:
self.username = username
self.encrypted_token = encrypted_token
self.added_by = added_by
class FakeSession:
"""Мини-модель БД: {username: FakeRow}, без реального движка."""
def __init__(self, table: dict[str, FakeRow]) -> None:
self._table = table
async def get(self, model, username): # noqa: ANN001
_ = model
return self._table.get(username)
def add(self, obj: FakeRow) -> None:
self._table[obj.username] = obj
async def delete(self, obj: FakeRow) -> None:
self._table.pop(obj.username, None)
async def commit(self) -> None:
pass
async def execute(self, stmt): # noqa: ANN001
_ = stmt
return FakeResult(sorted(self._table.keys()))
async def __aenter__(self) -> FakeSession:
return self
async def __aexit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
return False
class FakeSessionLocal:
def __init__(self, table: dict[str, FakeRow]) -> None:
self._table = table
def __call__(self) -> FakeSession:
return FakeSession(self._table)
@pytest.fixture(autouse=True)
def _fake_store(monkeypatch):
"""Подменяет SessionLocal/engine и FERNET_KEY на in-memory заглушки.
Каждый вызов add_token/get_token/remove_token/list_usernames в проде
оборачивается ОТДЕЛЬНЫМ asyncio.run() (см. app/app_config.py) — новый
event loop на каждый вызов. Тесты ниже воспроизводят это же (несколько
независимых asyncio.run() подряд в одном тесте) и проверяют, что
engine.dispose() вызывается после каждой операции.
"""
table: dict[str, FakeRow] = {}
fake_engine = AsyncMock()
monkeypatch.setattr(store, "SessionLocal", FakeSessionLocal(table))
monkeypatch.setattr(store, "engine", fake_engine)
monkeypatch.setattr(store.settings, "FERNET_KEY", Fernet.generate_key().decode())
return table, fake_engine
def test_add_token_then_get_token_across_separate_event_loops(_fake_store) -> None:
table, fake_engine = _fake_store
asyncio.run(store.add_token("raykovmse", "coherent effects plausibly apple"))
assert fake_engine.dispose.await_count == 1
token = asyncio.run(store.get_token("raykovmse"))
assert fake_engine.dispose.await_count == 2
assert token == "coherent effects plausibly apple"
def test_add_token_updates_existing_row(_fake_store) -> None:
table, fake_engine = _fake_store
asyncio.run(store.add_token("raykovmse", "first token", added_by="admin"))
asyncio.run(store.add_token("raykovmse", "second token", added_by="raykovmse"))
assert fake_engine.dispose.await_count == 2
assert len(table) == 1
assert asyncio.run(store.get_token("raykovmse")) == "second token"
def test_get_token_missing_username_disposes_engine(_fake_store) -> None:
_, fake_engine = _fake_store
result = asyncio.run(store.get_token("no-such-user"))
assert result is None
assert fake_engine.dispose.await_count == 1
def test_remove_token_disposes_engine(_fake_store) -> None:
table, fake_engine = _fake_store
asyncio.run(store.add_token("raykovmse", "token-123"))
fake_engine.dispose.reset_mock()
removed = asyncio.run(store.remove_token("raykovmse"))
assert removed is True
assert "raykovmse" not in table
assert fake_engine.dispose.await_count == 1
def test_list_usernames_disposes_engine(_fake_store) -> None:
_, fake_engine = _fake_store
asyncio.run(store.add_token("raykovmse", "token-123"))
fake_engine.dispose.reset_mock()
usernames = asyncio.run(store.list_usernames())
assert usernames == ["raykovmse"]
assert fake_engine.dispose.await_count == 1
def test_add_token_disposes_engine_even_when_commit_fails(
_fake_store, monkeypatch
) -> None:
_, fake_engine = _fake_store
async def _failing_commit(self) -> None: # noqa: ANN001
raise RuntimeError("boom")
monkeypatch.setattr(FakeSession, "commit", _failing_commit)
with pytest.raises(RuntimeError):
asyncio.run(store.add_token("raykovmse", "token-123"))
assert fake_engine.dispose.await_count == 1
def test_add_token_requires_username_and_token(_fake_store) -> None:
with pytest.raises(OpenCloudTokenStoreError):
asyncio.run(store.add_token("", "token-123"))
with pytest.raises(OpenCloudTokenStoreError):
asyncio.run(store.add_token("raykovmse", ""))