gendesign/backend/tests/smoke/test_prod_smoke.py
lekss361 608490fbba
fix(parcels): _parse_floors handle int (post-migration #169) (#176)
* fix(parcels): _parse_floors handle int (post-migration #169 schema change)

After PR #169 cad_buildings schema migration, `floors` column is INT
(was TEXT in legacy schema). Existing `_parse_floors(r.get("floors"))`
call in analyze_parcel → _neighbors_summary crashes with:
  AttributeError: 'int' object has no attribute 'strip'

Fix: type union str | int | None. If int → return directly (no strip).
Preserve TEXT range parsing ("5-7" → 7) for backwards-compat with
any legacy data still in cad_buildings_old_apr26.

* test(smoke): production smoke tests for post-deploy regressions (#168)

Add tests/smoke/test_prod_smoke.py covering known regression surfaces.
Marks: prod_smoke + slow. Env: PROD_SMOKE_BASE_URL, PROD_SMOKE_ADMIN_TOKEN.
Run manually: cd backend && uv run pytest tests/smoke/ -m prod_smoke -v

* fix(cadastre): exact-match headers to nspd_lite to bypass NSPD WAF (#168)

Pilot v2 (job_id=2) failed 50/50 with HTTP 403 WAF block. Comparing
nspd_bulk_client.DEFAULT_HEADERS vs legacy nspd_lite.HEADERS (which works
on VPS IP since April 2026):

  PascalCase keys → lowercase keys
  Chrome/148 UA → Chrome/144 UA
  No cache-control / pragma → "no-cache" both
  accept-language ru first → en first
  No origin → "https://nspd.gov.ru"
  referer "/map" → "/map?thematic=PKK"

NSPD WAF (BotShield-class) likely fingerprints на header order + values
combined with TLS fingerprint. Matching legacy exactly minimizes deltas.

Test plan: retry pilot job after deploy, expect 0 WAF blocks for first
5 quarters.

* fix(test): exclude prod_smoke tests by default (#168)

CI ran tests/smoke/test_prod_smoke.py and they hit production
https://gendsgn.ru/api/v1/parcels/.../analyze which currently returns 500
(parse_floors regression — exactly what this PR fixes). Catch-22: PR can't
merge because smoke tests fail against pre-merge prod.

Fix: add `addopts = ["-m", "not prod_smoke"]` so default pytest excludes
them. Run manually post-deploy with: pytest -m prod_smoke -v

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 15:08:16 +03:00

237 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Production smoke tests — must pass after every merge to main.
Run after deploy completes (~2-3min after merge). CI workflow:
cd backend && uv run pytest tests/smoke/test_prod_smoke.py -m prod_smoke -v
Each test makes a real HTTP call to https://gendsgn.ru, validates response
structure (not exact data) и crash regressions, and reports timing.
Marks:
- @pytest.mark.prod_smoke — skip by default in CI, run only on-demand
- @pytest.mark.slow — long-running (≥5s)
Tests cover the regression surface that bot review window cannot catch:
- Schema migrations break field types → AttributeError на user actions
- Frontend bundle ↔ backend contract drift
- Auth/admin endpoints за токеном
- Background workers + Celery queue
"""
from __future__ import annotations
import os
import httpx
import pytest
BASE_URL = os.getenv("PROD_SMOKE_BASE_URL", "https://gendsgn.ru")
ADMIN_TOKEN = os.getenv("PROD_SMOKE_ADMIN_TOKEN")
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
# Test cads — known-good в prod, в quarter 66:41:0303161 (Крауля)
TEST_CAD = "66:41:0204016:10" # Билимбаевская, 32 ЖК velocity baseline
TEST_QUARTER = "66:41:0303161" # 502 объекта (170 ЗУ + 256 зд + 75 соор + 1 ЕНК)
pytestmark = pytest.mark.prod_smoke
# ─────────────────────────────────────────────────────────────────────────────
# Public endpoints (no auth)
# ─────────────────────────────────────────────────────────────────────────────
def test_health_returns_ok() -> None:
"""Smoke: /health должен возвращать {"status":"ok"}."""
r = httpx.get(f"{BASE_URL}/health", timeout=TIMEOUT)
assert r.status_code == 200
assert r.json().get("status") == "ok"
def test_site_finder_page_renders() -> None:
"""SSR returns HTML с заголовком GenDesign."""
r = httpx.get(f"{BASE_URL}/site-finder", timeout=TIMEOUT)
assert r.status_code == 200
assert "GenDesign" in r.text or "Site Finder" in r.text
def test_parcels_analyze_returns_200() -> None:
"""REGRESSION SHIELD: ловит crashes в analyze pipeline.
Раньше ломали:
- PR #149: body stream double-read (api.ts)
- PR #152: SQLAlchemy :weights::jsonb cast
- PR #160: :window_interval::interval cast
- PR #169 → 'int has no attribute strip' (cad_buildings.floors INT)
Test: POST /api/v1/parcels/{cad}/analyze → 200 + required fields.
"""
r = httpx.post(
f"{BASE_URL}/api/v1/parcels/{TEST_CAD}/analyze",
json={},
timeout=httpx.Timeout(60.0, connect=10.0), # analyze может быть медленным
)
assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text[:500]}"
body = r.json()
# Required top-level keys (validate схема не дрейфит):
required = ["cad_num", "score", "pipeline_24mo", "velocity", "gate_verdict"]
for key in required:
assert key in body, f"Missing field '{key}' in analyze response. Keys: {list(body.keys())}"
# neighbors_summary must NOT crash even when floors=int (regression #169):
assert "neighbors_summary" in body
assert body["neighbors_summary"].get("data_available") is not False or (
body["neighbors_summary"].get("note", "").startswith("neighbors query failed") is False
)
def test_parcels_analyze_velocity_block_present() -> None:
"""REGRESSION SHIELD: velocity migrated to objective_corpus_room_month (#158, #163).
После migration ожидаем что для testovogo cad есть velocity-блок:
- score ≥ 0
- period.start/end не None (значит objective fresh data есть)
- by_room_bucket список (per-room breakdown — PR #163)
"""
r = httpx.post(
f"{BASE_URL}/api/v1/parcels/{TEST_CAD}/analyze",
json={},
timeout=httpx.Timeout(60.0, connect=10.0),
)
assert r.status_code == 200
velocity = r.json().get("velocity")
if velocity is None:
pytest.skip("velocity null — possibly no data in cadastre, not regression")
assert "score" in velocity
assert "period" in velocity
# PR #163 enrichment:
assert "by_room_bucket" in velocity, "Missing by_room_bucket per PR #163"
def test_parcels_analyze_pipeline_by_class_enriched() -> None:
"""REGRESSION SHIELD: pipeline_by_class obj_class enrichment (#165).
Раньше показывало "Не указан: 4075". После #165 — стандарт/комфорт/etc
через JOIN на objective_complex_mapping.
"""
r = httpx.post(
f"{BASE_URL}/api/v1/parcels/{TEST_CAD}/analyze",
json={},
timeout=httpx.Timeout(60.0, connect=10.0),
)
assert r.status_code == 200
pipeline = r.json().get("pipeline_24mo", {})
by_class = pipeline.get("by_class", {})
# Не должно быть так что unknown == 100% (это означало бы regression #165)
if by_class:
total = sum(by_class.values())
unknown_share = by_class.get("unknown", 0) / total if total > 0 else 0
assert unknown_share < 0.9, (
f"pipeline_by_class.unknown == {unknown_share:.1%}"
"regression of #165? Should JOIN on objective_lots."
)
# ─────────────────────────────────────────────────────────────────────────────
# Admin endpoints (auth required — skip if no token)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def admin_headers() -> dict[str, str]:
if not ADMIN_TOKEN:
pytest.skip("PROD_SMOKE_ADMIN_TOKEN env var not set")
return {"X-Admin-Token": ADMIN_TOKEN}
def test_admin_jobs_settings_returns_200(admin_headers: dict[str, str]) -> None:
r = httpx.get(
f"{BASE_URL}/api/v1/admin/jobs/settings",
headers=admin_headers,
timeout=TIMEOUT,
)
assert r.status_code == 200
assert isinstance(r.json(), list | dict)
def test_admin_cadastre_jobs_endpoint(admin_headers: dict[str, str]) -> None:
"""PR #171 admin cadastre endpoints."""
r = httpx.get(
f"{BASE_URL}/api/v1/admin/cadastre/jobs?limit=5",
headers=admin_headers,
timeout=TIMEOUT,
)
assert r.status_code == 200
body = r.json()
assert isinstance(body, list)
if body:
# Validate схема jobs row (PR #171)
required = ["job_id", "status", "targets_total", "targets_done"]
for key in required:
assert key in body[0], f"Missing field '{key}' in job row"
def test_admin_weight_profiles_endpoint(admin_headers: dict[str, str]) -> None:
"""PR #138 weight profiles list."""
r = httpx.get(
f"{BASE_URL}/api/v1/admin/site-finder/weight-profiles?user_id=admin",
headers=admin_headers,
timeout=TIMEOUT,
)
assert r.status_code == 200
# ─────────────────────────────────────────────────────────────────────────────
# Frontend bundle (catches build regressions)
# ─────────────────────────────────────────────────────────────────────────────
def test_admin_scrape_cadastre_page_renders() -> None:
"""PR #172 — new admin UI page must SSR ok."""
r = httpx.get(f"{BASE_URL}/admin/scrape/cadastre", timeout=TIMEOUT)
assert r.status_code == 200
assert "Cadastre" in r.text or "кадастр" in r.text.lower() or "Bulk" in r.text
def test_admin_scrape_geo_page_renders() -> None:
"""Existing pattern — должен работать (PR #172 не должен сломать)."""
r = httpx.get(f"{BASE_URL}/admin/scrape/geo", timeout=TIMEOUT)
assert r.status_code == 200
# ─────────────────────────────────────────────────────────────────────────────
# Schema invariants (через DB read через admin endpoints)
# ─────────────────────────────────────────────────────────────────────────────
def test_health_db_connection_works() -> None:
"""`/health` returns 200 если DB reachable + migrations applied."""
r = httpx.get(f"{BASE_URL}/health?check_db=1", timeout=TIMEOUT)
if r.status_code == 200 and "db" in r.json():
assert r.json()["db"] == "ok", f"DB check failed: {r.json()}"
# ─────────────────────────────────────────────────────────────────────────────
# How to run after merge (CI integration)
# ─────────────────────────────────────────────────────────────────────────────
# Add to .github/workflows/post-deploy-smoke.yml:
#
# on:
# workflow_run:
# workflows: [deploy.yml]
# types: [completed]
# jobs:
# smoke:
# if: ${{ github.event.workflow_run.conclusion == 'success' }}
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - run: uv sync
# - run: |
# cd backend && uv run pytest tests/smoke/ -m prod_smoke -v
# env:
# PROD_SMOKE_BASE_URL: https://gendsgn.ru
# PROD_SMOKE_ADMIN_TOKEN: ${{ secrets.SCRAPE_ADMIN_TOKEN }}
# - name: Post slack/sentry alert if failed
# if: failure()
# run: echo "smoke tests failed — investigate"