test(api): свод 2 — засев, эталон прежнего ответа и тесты-оракулы нового режима (красные)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:19:22 +04:00
parent 174ec73732
commit b134cfd118
5 changed files with 13694 additions and 0 deletions

13254
api/tests/fixtures/svod2_legacy_golden.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import os
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from src.db.base import SessionLocal
@ -30,6 +31,7 @@ def client():
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:
@ -79,3 +81,54 @@ def isp_tokens(client, isp_password: str):
)
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, org_ids=None) -> list[dict]:
"""Прежний ответ свода 2 без параметров нового режима (оракульная база)."""
url = f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}"
if org_ids is not None:
url += f"?org_ids={org_ids}"
response = client.get(url, headers=headers)
assert response.status_code == 200, response.text
return response.json()["result"]
@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]
return {
"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",
}

View File

@ -0,0 +1,70 @@
"""Эталон прежнего ответа свода 2 (байт-в-байт).
Эталон записан один раз на коде «до» (SVOD2_WRITE_GOLDEN=1) и больше не перезаписывается.
"""
import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
import pytest
GOLDEN_PATH = Path(__file__).resolve().parent.parent / "fixtures" / "svod2_legacy_golden.json"
@dataclass(frozen=True)
class GoldenCase:
name: str
query: str = ""
who: str = "admin"
def url(self, sheet: str, data: dict) -> str:
query = self.query.format(org_a=data["org_a"])
base = f"/api/v1/svod/2/{sheet}/{data['year']}"
return f"{base}?{query}" if query else base
def headers(self, auth_headers, admin_tokens, isp_tokens) -> dict:
return auth_headers(isp_tokens if self.who == "isp" else admin_tokens)
GOLDEN_CASES = [
GoldenCase("admin_all"),
GoldenCase("admin_one", "org_ids={org_a}"),
GoldenCase("isp_all", who="isp"),
GoldenCase("admin_page50", "offset=0&limit=50"),
GoldenCase("admin_two", "offset=0&limit=2"),
]
def _golden() -> dict:
with open(GOLDEN_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def _record(key: str, content: bytes) -> None:
data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) if GOLDEN_PATH.exists() else {}
data[key] = {"sha256": hashlib.sha256(content).hexdigest(), "body": json.loads(content)}
GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
GOLDEN_PATH.write_text(
json.dumps(dict(sorted(data.items())), ensure_ascii=False, indent=1) + "\n",
encoding="utf-8",
)
@pytest.mark.parametrize("sheet", ["GO", "RF"])
@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.name)
def test_legacy_response_is_byte_identical(
client, svod2_data, admin_tokens, isp_tokens, auth_headers, sheet, case
):
r = client.get(
case.url(sheet, svod2_data),
headers=case.headers(auth_headers, admin_tokens, isp_tokens),
)
assert r.status_code == 200
if case.name == "admin_all":
assert r.json()["result"], "засев не дал строк свода 2"
key = f"{sheet}-{case.name}"
if os.getenv("SVOD2_WRITE_GOLDEN") == "1":
_record(key, r.content)
assert hashlib.sha256(r.content).hexdigest() == _golden()[key]["sha256"]

View File

@ -0,0 +1,260 @@
"""Тесты-оракулы нового режима API свода 2 (постраничная выдача, поиск, фильтры, варианты, compact).
Оракул — функции экрана «до» из tests.svod2_oracle, применённые к прежнему полному ответу.
"""
import pytest
from tests.integration.conftest import SVOD2_YEAR, legacy_full
from tests.svod2_oracle import (
compact_projection,
screen_facets,
screen_found,
screen_rows,
)
SMETA_TYPE = "КВ"
SMETA_TYPE_VARIANT = SMETA_TYPE.lower() + " "
SEARCHES = [
"аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда ",
" ", "1", ",", "777.25", "<cd_id>", "я" * 5000,
]
FILTERS = {
"type-case-space": {"smeta_type": SMETA_TYPE_VARIANT},
"org-twin": {"org_name": "Филиал Ёлки"},
"org-case-variant": {"org_name": "филиал ёлки "},
"unknown": {"smeta_type": "нет такого"},
"dir+org": {"smeta_direction": "Развитие", "org_name": "Филиал Ёлки"},
"search+type": {"search": "аренда", "smeta_type": SMETA_TYPE_VARIANT},
"search+type-hit": {"search": "о", "smeta_type": SMETA_TYPE_VARIANT},
"long": {"org_name": "я" * 5000},
}
SHEETS = ["GO", "RF"]
def _url(sheet: str, suffix: str = "") -> str:
return f"/api/v1/svod/2/{sheet}/{SVOD2_YEAR}{suffix}"
def _line_ids(rows: list[dict]) -> list[int]:
return [r["data"]["line_id"] for r in rows]
def _resolve_search(search: str, data: dict) -> str:
return str(data["cd_id"]) if search == "<cd_id>" else search
def _split(params: dict) -> tuple[str, dict]:
filters = dict(params)
search = filters.pop("search", "")
return search, filters
def _all_pages(client, sheet, headers, limit, **params) -> tuple[list[dict], list[int]]:
"""Все страницы нового режима: (строки, total каждой страницы)."""
rows: list[dict] = []
totals: list[int] = []
offset = 0
while True:
r = client.get(
_url(sheet),
params={**params, "offset": offset, "limit": limit},
headers=headers,
)
assert r.status_code == 200, r.text
body = r.json()
totals.append(body["total"])
rows.extend(body["result"])
if len(body["result"]) < limit:
return rows, totals
offset += limit
@pytest.fixture
def headers(admin_tokens, auth_headers):
return auth_headers(admin_tokens)
def test_legacy_mode_has_no_total(client, svod2_data, headers):
r = client.get(_url("GO", "?offset=0&limit=2"), headers=headers)
assert r.status_code == 200
body = r.json()
assert body["count"] <= 2
assert len(body["result"]) <= 2
assert "total" not in body
@pytest.mark.parametrize(
"q", ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad", "view=bad"]
)
def test_invalid_params_422(client, svod2_data, headers, q):
r = client.get(_url("GO", f"?{q}"), headers=headers)
assert r.status_code == 422
@pytest.mark.parametrize("sheet", SHEETS)
def test_pages_stitch_to_full_list(client, svod2_data, headers, sheet):
full = legacy_full(client, sheet, headers)
assert full
rows, totals = _all_pages(client, sheet, headers, 2, view="full")
assert _line_ids(rows) == _line_ids(full)
assert totals and all(t == len(full) for t in totals)
@pytest.mark.parametrize("sheet", SHEETS)
def test_page_past_end_has_total(client, svod2_data, headers, sheet):
full = legacy_full(client, sheet, headers)
r = client.get(
_url(sheet),
params={"view": "full", "offset": len(full), "limit": 2},
headers=headers,
)
assert r.status_code == 200
body = r.json()
assert body["result"] == []
assert body["count"] == 0
assert body["total"] == len(full)
def test_view_full_rows_equal_legacy_rows(client, svod2_data, headers):
full = legacy_full(client, "RF", headers)
r = client.get(
_url("RF"), params={"view": "full", "offset": 0, "limit": 50}, headers=headers
)
assert r.status_code == 200
body = r.json()
assert "total" in body
assert body["result"] == full[:50]
@pytest.mark.parametrize("sheet", SHEETS)
@pytest.mark.parametrize("search", SEARCHES, ids=[str(i) for i in range(len(SEARCHES))])
def test_search_matches_screen_oracle(client, svod2_data, headers, sheet, search):
search = _resolve_search(search, svod2_data)
full = legacy_full(client, sheet, headers)
expected = screen_rows(full, search)
rows, totals = _all_pages(client, sheet, headers, 3, view="compact", search=search)
assert _line_ids(rows) == _line_ids(expected)
assert all(t == len(expected) for t in totals)
@pytest.mark.parametrize("sheet", SHEETS)
@pytest.mark.parametrize("name", list(FILTERS))
def test_filters_match_screen_oracle(client, svod2_data, headers, sheet, name):
params = FILTERS[name]
search, filters = _split(params)
full = legacy_full(client, sheet, headers)
expected = screen_rows(full, search, filters)
rows, totals = _all_pages(client, sheet, headers, 3, view="compact", **params)
assert _line_ids(rows) == _line_ids(expected)
assert all(t == len(expected) for t in totals)
def _facets_to_dict(result: dict) -> dict:
out = {"total": result["total"], "search_total": result["search_total"]}
for col in ("smeta_type", "smeta_direction", "org_name"):
out[col] = {item["value"]: item["count"] for item in result[col]}
return out
@pytest.mark.parametrize("sheet", SHEETS)
@pytest.mark.parametrize("name", list(FILTERS))
@pytest.mark.parametrize("search", SEARCHES[:6], ids=[str(i) for i in range(6)])
def test_facets_match_screen_oracle(client, svod2_data, headers, sheet, name, search):
_, filters = _split(FILTERS[name])
full = legacy_full(client, sheet, headers)
expected = screen_facets(full, search, filters)
r = client.get(
_url(sheet, "/facets"), params={"search": search, **filters}, headers=headers
)
assert r.status_code == 200, r.text
assert _facets_to_dict(r.json()["result"]) == expected
@pytest.mark.parametrize("sheet", SHEETS)
@pytest.mark.parametrize("search", SEARCHES, ids=[str(i) for i in range(len(SEARCHES))])
def test_found_matches_screen_oracle(client, svod2_data, headers, sheet, search):
search = _resolve_search(search, svod2_data)
full = legacy_full(client, sheet, headers)
r = client.get(_url(sheet, "/facets"), params={"search": search}, headers=headers)
assert r.status_code == 200, r.text
result = r.json()["result"]
assert (result["search_total"], result["total"]) == screen_found(full, search)
@pytest.mark.parametrize("sheet", SHEETS)
def test_compact_is_projection_of_full(client, svod2_data, headers, sheet):
params = {"offset": 0, "limit": 5}
full = client.get(_url(sheet), params={**params, "view": "full"}, headers=headers)
compact = client.get(_url(sheet), params={**params, "view": "compact"}, headers=headers)
assert full.status_code == 200 and compact.status_code == 200
full_rows = full.json()["result"]
compact_rows = compact.json()["result"]
assert full_rows and len(full_rows) == len(compact_rows)
for f, c in zip(full_rows, compact_rows):
assert (c["row_type"], c["depth"], c["sort_order"]) == (
f["row_type"], f["depth"], f["sort_order"],
)
assert c["data"] == compact_projection(f["data"])
def test_rf_ckk_only_value_is_searchable(client, svod2_data, headers):
r = client.get(
_url("RF"),
params={"view": "compact", "search": svod2_data["ckk_only_value"]},
headers=headers,
)
assert r.status_code == 200
body = r.json()
assert body["total"] >= 1
assert body["result"], "строка с значением только из ККК не найдена поиском"
@pytest.mark.parametrize("sheet", SHEETS)
def test_isp_foreign_org_is_empty(client, svod2_data, isp_tokens, auth_headers, sheet):
isp = auth_headers(isp_tokens)
r = client.get(
_url(sheet),
params={"view": "full", "org_ids": svod2_data["org_b"], "offset": 0, "limit": 50},
headers=isp,
)
assert r.status_code == 200
body = r.json()
assert body["result"] == []
assert body["total"] == 0
f = client.get(
_url(sheet, "/facets"), params={"org_ids": svod2_data["org_b"]}, headers=isp
)
assert f.status_code == 200
result = f.json()["result"]
assert result["total"] == 0 and result["search_total"] == 0
assert result["smeta_type"] == []
assert result["smeta_direction"] == []
assert result["org_name"] == []
@pytest.mark.parametrize("sheet", SHEETS)
def test_isp_sees_only_own_org(
client, svod2_data, headers, isp_tokens, auth_headers, sheet
):
admin_rows = legacy_full(client, sheet, headers)
own = [
r for r in admin_rows
if r["data"]["header"]["source_org_unit_id"] == svod2_data["org_a"]
]
assert own
r = client.get(
_url(sheet),
params={"view": "full", "offset": 0, "limit": 50},
headers=auth_headers(isp_tokens),
)
assert r.status_code == 200
assert r.json()["total"] == len(own)
def test_facets_unknown_sheet_404(client, svod2_data, headers):
r = client.get(_url("XX", "/facets"), headers=headers)
assert r.status_code == 404

View File

@ -0,0 +1,57 @@
-- Засев свода 2 (год 2031). Скрипт режется по точке с запятой, поэтому без двоеточий и фигурных скобок в тексте.
INSERT INTO v3.org_unit (id, title, is_active, is_ssp) VALUES
(910001, 'Тестовый филиал Альфа', true, false),
(910002, 'Филиал Ёлки', true, false),
(910003, 'Филиал Ёлки', true, false),
(910004, 'филиал ёлки ', true, false);
INSERT INTO v3.user_org (id, user_id, org_unit_id) VALUES (910001, 2, 910001);
INSERT INTO v3.budget_form (id, form_type_code, "year", org_unit_id) VALUES
(910001, 'FORM_1', 2031, 910001),
(910002, 'FORM_4', 2031, 910001),
(910003, 'FORM_2', 2031, 910001),
(910004, 'FORM_1', 2031, 910002),
(910005, 'FORM_2', 2031, 910002),
(910006, 'FORM_1', 2031, 910003),
(910007, 'FORM_1', 2031, 910004);
INSERT INTO v3.budget_line (id, budget_form_id, expense_item_id, "name", direction) VALUES
(910001, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда помещения', 'Support'),
(910002, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), NULL, 'Development'),
(910003, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'ЁЛКА новогодняя', NULL),
(910004, 910001, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 5 LIMIT 1), 'Договор с Лютиком', 'Support'),
(910005, 910002, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 5 LIMIT 1), 'Капвложения форма 4', 'Development'),
(910006, 910002, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 7 LIMIT 1), NULL, NULL),
(910007, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда помещения РФ', 'Support'),
(910008, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), 'Только ККК', 'Development'),
(910009, 910003, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'Операционные РФ', NULL),
(910010, 910004, (SELECT id FROM v3.expense_item WHERE sheet = 'AHR' ORDER BY id OFFSET 3 LIMIT 1), 'Аренда филиал Б', 'Development'),
(910011, 910005, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 5 LIMIT 1), 'РФ филиал Б', 'Support'),
(910012, 910006, (SELECT id FROM v3.expense_item WHERE sheet = 'CAP' ORDER BY id OFFSET 3 LIMIT 1), 'Двойник Ёлки', 'Support'),
(910013, 910007, (SELECT id FROM v3.expense_item WHERE sheet = 'OPER' ORDER BY id OFFSET 3 LIMIT 1), 'Вариант регистра', 'Development');
INSERT INTO v3.plan (line_id, plan_q1, plan_q2, plan_q3, plan_q4, "comment") VALUES
(910001, 100.00, 0, 0, 0, NULL),
(910002, 250.50, 10, 0, 0, 'комментарий к плану'),
(910003, 0, 0, 0, 0, 'только комментарий'),
(910005, 5, 6, 7, 8, NULL),
(910006, 1, 0, 0, 0, NULL),
(910007, 300, 0, 0, 0, NULL),
(910009, 42, 0, 0, 0, NULL),
(910010, 11, 12, 0, 0, NULL),
(910011, 9, 0, 0, 0, NULL),
(910012, 15, 0, 0, 0, NULL),
(910013, 17, 0, 0, 0, NULL);
INSERT INTO v3.contract_summary (line_id, total_amount, counterparty, reference) VALUES
(910001, 500.00, 'ООО Ромашка', 'ДС-1'),
(910004, 70.00, 'ООО Лютик', 'ДС-2');
INSERT INTO v3.contract_detail (line_id, counterparty, reference, ceiling_amount, expenses_q1, expenses_q2) VALUES
(910001, NULL, 'Д-1', 1234.50, 20, 30),
(910002, 'ООО Ромашка Плюс', 'Д-2', 99, 1, 2),
(910008, NULL, 'Д-3', NULL, NULL, NULL);
INSERT INTO v3.ckk (line_id, ceiling_amount, expenses_q1, expenses_q2) VALUES
(910008, 1000, 777.25, 5);