Конфликты summary.py, summary_repository.py, summary_service.py, test_summary2_related_records_sql.py: сохранены новые методы страниц и фасетов, режим без параметров идёт по быстрому JSON-пути test (AURORA-1848). Миграция переименована в 0057 (down_revision 0056); в svod2_lines добавлена поправка 0050 (СПОД аренды, только ГО). Эталон svod2_legacy_golden перезаписан под ответ без null-полей; legacy_full оракулов строится прямым вызовом функции. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
import asyncio
|
||
from contextlib import contextmanager
|
||
import os
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
|
||
from src.db.base import SessionLocal
|
||
from src.db.session import get_db
|
||
from src.main import app
|
||
|
||
|
||
|
||
@pytest.fixture
|
||
def db_session():
|
||
"""Сессия БД теста: её же использует client, всё откатывается в конце теста."""
|
||
db_ref = SessionLocal()
|
||
yield db_ref
|
||
|
||
|
||
@pytest.fixture
|
||
def client(db_session):
|
||
db_ref = db_session
|
||
loop_ref = None
|
||
|
||
async def _get_db():
|
||
nonlocal loop_ref
|
||
loop_ref = asyncio.get_running_loop()
|
||
try:
|
||
yield db_ref
|
||
await db_ref.flush()
|
||
except:
|
||
await db_ref.rollback()
|
||
raise
|
||
|
||
app.dependency_overrides[get_db] = _get_db
|
||
with TestClient(app) as test_client:
|
||
try:
|
||
test_client.db_session = db_ref
|
||
yield test_client
|
||
finally:
|
||
if db_ref is not None and loop_ref:
|
||
async def cleanup():
|
||
await db_ref.rollback()
|
||
await db_ref.close()
|
||
|
||
future = asyncio.run_coroutine_threadsafe(cleanup(), loop_ref)
|
||
future.result()
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def admin_password() -> str:
|
||
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
|
||
return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123")
|
||
|
||
|
||
@pytest.fixture
|
||
def isp_password() -> str:
|
||
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
|
||
return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123")
|
||
|
||
|
||
@pytest.fixture
|
||
def admin_tokens(client, admin_password: str):
|
||
response = client.post(
|
||
"/api/v1/auth/login",
|
||
json={"username": "admin", "password": admin_password},
|
||
)
|
||
assert response.status_code == 200
|
||
return response.json()
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_headers():
|
||
def _build(tokens: dict) -> dict:
|
||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||
|
||
return _build
|
||
|
||
@pytest.fixture
|
||
def isp_tokens(client, isp_password: str):
|
||
response = client.post(
|
||
"/api/v1/auth/login",
|
||
json={"username": "isp1", "password": isp_password},
|
||
)
|
||
assert response.status_code == 200
|
||
return response.json()
|
||
|
||
|
||
SVOD2_YEAR = 2031
|
||
SVOD2_SEED_PATH = os.path.join(os.path.dirname(__file__), "..", "sqls", "svod2_seed.sql")
|
||
|
||
|
||
def legacy_full(client, sheet: str, headers: dict = None, org_ids=None) -> list[dict]:
|
||
"""Прежний полный ответ свода 2 без параметров нового режима (оракульная база).
|
||
|
||
Строится прямым вызовом функции представления, а не через GET без параметров: после AURORA-1848
|
||
этот GET отдаёт data без null-полей (jsonb_strip_nulls), а оракул экрана «до» работает с полным
|
||
data - в нём пустая секция contract_detail затирает поля contract_summary значением null.
|
||
Запрос идёт от имени администратора, headers оставлен для совместимости вызовов.
|
||
"""
|
||
function_name = {"GO": "v3.v_svod2_go", "RF": "v3.v_svod2_rf"}[sheet]
|
||
|
||
async def _fetch():
|
||
result = await client.db_session.execute(
|
||
text(
|
||
f"SELECT sort_order, data FROM {function_name}("
|
||
"CAST(:year AS INT), CAST(:org_ids AS INT[]), NULL, NULL, 'org_name', 'asc')"
|
||
),
|
||
{"year": SVOD2_YEAR, "org_ids": org_ids},
|
||
)
|
||
return [
|
||
{"row_type": "INPUT", "depth": 3, "sort_order": sort_order, "data": data}
|
||
for sort_order, data in result.fetchall()
|
||
]
|
||
|
||
return client.portal.call(_fetch)
|
||
|
||
|
||
@pytest.fixture
|
||
def svod2_sql(client):
|
||
"""Выполняет text(sql) на сессии клиента (тот же цикл событий, что у запросов)."""
|
||
|
||
def _run(sql: str, params: dict | None = None):
|
||
async def _exec():
|
||
result = await client.db_session.execute(text(sql), params or {})
|
||
return list(result.fetchall()) if result.returns_rows else []
|
||
|
||
return client.portal.call(_exec)
|
||
|
||
return _run
|
||
|
||
|
||
@pytest.fixture
|
||
def svod2_data(client, svod2_sql) -> dict:
|
||
"""Засевает данные свода 2 (откатываются вместе с сессией клиента)."""
|
||
with open(SVOD2_SEED_PATH, "r", encoding="utf-8") as f:
|
||
script = f.read()
|
||
for statement in script.split(";"):
|
||
statement = statement.strip()
|
||
if statement:
|
||
svod2_sql(statement)
|
||
# id строки contract_detail: contract_detail.line_id = 910002, технический id,
|
||
# который попадает в шапку строки, — expense_item_id этой строки
|
||
cd_id = svod2_sql("SELECT expense_item_id FROM v3.budget_line WHERE id = 910002")[0][0]
|
||
yield {
|
||
"year": SVOD2_YEAR,
|
||
"org_a": 910001,
|
||
"org_b": 910002,
|
||
"org_b_twin": 910003,
|
||
"org_b_case": 910004,
|
||
"cd_id": cd_id,
|
||
"ckk_only_value": "777.25",
|
||
}
|
||
# откат, пока portal открыт: иначе при отсутствии запросов к API транзакция засева
|
||
# остаётся висеть и блокирует следующий засев
|
||
client.portal.call(client.db_session.rollback)
|