diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py index a634646..f4a432a 100644 --- a/api/tests/integration/test_heavy_get_json.py +++ b/api/tests/integration/test_heavy_get_json.py @@ -126,6 +126,15 @@ def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, assert any((r["data"].get("header") or {}).get("name") == TRICKY for r in result) +def test_heavy_get_gzip_compressed(client, admin_tokens, auth_headers): + """Спека, сценарий «Сжатие»: ответ >= 1000 байт (GZipMiddleware minimum_size=1000) уходит с Content-Encoding: gzip.""" + headers = {**auth_headers(admin_tokens), "Accept-Encoding": "gzip"} + r = client.get("/api/v1/form/4/sheet/AHR", headers=headers) + assert r.status_code == 200, r.text + assert r.headers.get("content-encoding") == "gzip" + assert r.json()["success"] is True # клиент распаковал тело + + def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch): async def boom(*args, **kwargs): raise AssertionError("GET листа не должен вызывать SheetRepository.get") @@ -133,21 +142,53 @@ def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, mon get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) -SVOD2_PLAN_LINE_SQL = text( - "WITH bf AS (INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) " - "VALUES ('FORM_1', :year, 2) RETURNING id), " - "bl AS (INSERT INTO v3.budget_line (budget_form_id, expense_item_id) " - "SELECT id, :expense_item_id FROM bf RETURNING id) " - "INSERT INTO v3.plan (line_id, plan_q1) SELECT id, 1000 FROM bl" -) +# Свод 2 берёт строки по версиям v3.v_svod2_go / v3.v_svod2_rf (миграция 0049, SQL-файл 0049_summary2_related_records.sql): +# GO - budget_form FORM_1/FORM_4 за год, RF (с 0041) - только FORM_2; статья с sheet IN (AHR, CAP, OPER); +# строка попадает в свод, если у неё есть хотя бы одна "присутствующая" секция (например, plan с ненулевым планом). +# Хардкод (предположение о fixture.sql, T4.2): орг. единицы 1 (Test_SSP) и 2 (Test РФ) - единственные в фикстуре; +# в фикстуре за YEAR есть только FORM_2 (budget_form id=4, org 2) без budget_line; статьи 88 (AHR, R061161003), +# 523 (CAP, 3.00.0.), 729 (OPER) существуют в справочнике и имеют разные section_code - это нужно, чтобы +# sort_by=section_code и offset/limit давали различимый порядок. Если fixture.sql или справочник изменятся, +# test_svod2_fixture_not_empty упадёт первым. +SVOD2_ORG_IDS = (1, 2) +SVOD2_EXPENSE_ITEM_IDS = (88, 523, 729) # AHR, CAP, OPER +SVOD2_FORMS = [("FORM_1", 1), ("FORM_1", 2), ("FORM_2", 1), ("FORM_2", 2)] # GO: FORM_1, RF: FORM_2 +SVOD2_MIN_ROWS = 6 # 2 орг. единицы x 3 статьи на каждый из листов GO и RF @pytest.fixture def svod2_plan_line(client, db_session): - """Фикстура не даёт свода 2 строк за YEAR: вставляем FORM_1 с ненулевым планом. - Откатывается вместе с сессией теста (POST строки без плана свод 2 не учитывает).""" + """Фикстура не даёт свода 2 строк за YEAR: достраиваем формы FORM_1 и FORM_2 по двум орг. единицам + и строки трёх статей (AHR, CAP, OPER) с ненулевым планом. Откатывается вместе с сессией теста.""" async def _set(): - await db_session.execute(SVOD2_PLAN_LINE_SQL, {"year": YEAR, "expense_item_id": 88}) + for form_type, org_id in SVOD2_FORMS: + await db_session.execute( + text( + "INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) " + "VALUES (:ft, :year, :org) ON CONFLICT (form_type_code, org_unit_id, year) DO NOTHING" + ), + {"ft": form_type, "year": YEAR, "org": org_id}, + ) + await db_session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id) " + "SELECT bf.id, e FROM v3.budget_form bf CROSS JOIN unnest(CAST(:items AS int[])) AS e " + "WHERE bf.year = :year AND bf.form_type_code IN ('FORM_1', 'FORM_2') " + "AND NOT EXISTS (SELECT 1 FROM v3.budget_line x " + "WHERE x.budget_form_id = bf.id AND x.expense_item_id = e)" + ), + {"year": YEAR, "items": list(SVOD2_EXPENSE_ITEM_IDS)}, + ) + await db_session.execute( + text( + "INSERT INTO v3.plan (line_id, plan_q1) " + "SELECT bl.id, 1000 FROM v3.budget_line bl JOIN v3.budget_form bf ON bf.id = bl.budget_form_id " + "WHERE bf.year = :year AND bf.form_type_code IN ('FORM_1', 'FORM_2') " + "AND bl.expense_item_id = ANY(CAST(:items AS int[])) " + "ON CONFLICT (line_id) DO UPDATE SET plan_q1 = 1000" + ), + {"year": YEAR, "items": list(SVOD2_EXPENSE_ITEM_IDS)}, + ) client.portal.call(_set) @@ -174,11 +215,20 @@ SVOD2_CASES = [ ] +def _svod2_min_rows(query: dict) -> int: + if "limit" in query: + return 1 + if "sort_by" in query: + return 2 + return 1 + + @pytest.mark.usefixtures("svod2_plan_line") @pytest.mark.parametrize("sheet,query,repo_kwargs", SVOD2_CASES) def test_svod2_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet, query, repo_kwargs): result = get_ok(client, f"/api/v1/svod/2/{sheet}/{YEAR}", auth_headers(admin_tokens), query) old = old_rows(client, lambda: SummaryRepository(db_session).get_svod2_rows(year=YEAR, sheet=sheet, **repo_kwargs)) + assert len(result) >= _svod2_min_rows(query), "сравнение на пустом/слишком коротком списке ничего не доказывает" assert result == expected_sheet(old) @@ -187,7 +237,11 @@ def test_svod2_fixture_not_empty(client, admin_tokens, auth_headers): """Иначе сравнения свода 2 выше проходят на пустых списках.""" go = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(admin_tokens)) rf = get_ok(client, f"/api/v1/svod/2/RF/{YEAR}", auth_headers(admin_tokens)) - assert go or rf + assert len(go) >= SVOD2_MIN_ROWS, len(go) + assert len(rf) >= SVOD2_MIN_ROWS, len(rf) + for rows in (go, rf): + assert {r["data"]["header"]["source_org_unit_id"] for r in rows} == set(SVOD2_ORG_IDS) + assert len({r["data"]["header"]["section_code"] for r in rows}) >= 2 @pytest.mark.usefixtures("svod2_plan_line") @@ -252,10 +306,11 @@ def test_project_report_matches_old_path(client, db_session, admin_tokens, auth_ assert any(row["row_type"] == "INPUT" for row in result) -def test_rf_rollup_error_same_as_old_path(client, db_session, admin_tokens, auth_headers, project_id): - """v3.v_form3_rf_rollup_jsonb в миграции 0002_functions.sql ссылается на несуществующий r.year - (дефект вне AURORA-1848): на любой БД ответ - 400. Фиксируем, что новый путь отдаёт то же самое, - что и старый, а успешное сравнение с эталоном для rf-rollup невозможно.""" +def test_rf_rollup_pinned_preexisting_sql_error(client, admin_tokens, auth_headers): + """Фиксирует СУЩЕСТВУЮЩИЙ дефект вне AURORA-1848: v3.v_form3_rf_rollup_jsonb в миграции 0002_functions.sql + ссылается на несуществующий r.year, поэтому на любой БД ответ - 400 с текстом SQL-ошибки (так же отвечал + и старый путь). Когда дефект будет исправлен, этот тест упадёт и его нужно превратить в тест + эквивалентности с эталоном ProjectService.get_rf_rollup_rows (как остальные get_ok-сравнения).""" r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(admin_tokens)) assert (r.status_code, r.json()) == RF_ROLLUP_SQL_ERROR @@ -303,7 +358,7 @@ def test_projects_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch.setattr(ProjectRepository, method, boom) r = client.get(url.format(pid=project_id), headers=auth_headers(admin_tokens)) assert not called - if method != "get_rf_rollup_rows": # rf-rollup: см. test_rf_rollup_error_same_as_old_path + if method != "get_rf_rollup_rows": # rf-rollup: см. test_rf_rollup_pinned_preexisting_sql_error assert r.status_code == 200, r.text diff --git a/perf/analysis/api_split.py b/perf/analysis/api_split.py index 0da561d..972ae6c 100644 --- a/perf/analysis/api_split.py +++ b/perf/analysis/api_split.py @@ -2,8 +2,8 @@ cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/20261026-s1/analysis -Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:198,241,304, -368,424,473; summary.py:75,126; projects.py:139,272,305,329): время от начала обработчика до получения строк из БД (поиск +Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (GET: api/src/api/v1/forms.py:198 get_sheet; +summary.py:75,126; projects.py:139,272,305,329): время от начала обработчика до получения строк из БД (поиск формы + SQL-функция + отдача готового JSON драйвером). После задач A ответ собирается из строк jsonb без pydantic, поэтому остаток `полное время - X-DB-Time-Ms` - это склейка строк в `list_json_response` (api/src/api/v1/json_response.py), сжатие GZipMiddleware (api/src/main.py:248, compresslevel=1), передача и распаковка на клиенте (httpx).