"""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"