gendesign/backend/tests/sql/test_2464_leads_stats_suffix_contract.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

189 lines
8.3 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.

"""Суффикс поля в /admin/leads/stats обязан соответствовать смыслу величины (#2464).
В ответе рядом стоят величины двух видов: за всё время (`leads_total`, `sources_total`) и
за окно `months` (`leads_window`, `converted_window`, `conv_pct_window`). Соглашение
читается однозначно по самим именам.
`revenue_total` и `deals_total` его нарушали: считались по CTE `window_leads`, то есть за
окно, а суффиксом обещали итог. Админка из-за этого печатала карточку «Revenue (всего)» с
12-месячной цифрой.
Проверяется ИНВАРИАНТ, а не набор имён: для данных, где итог заведомо не равен окну,
каждое поле `*_total` обязано совпасть с итогом, каждое `*_window` — с окном. Такая
формулировка краснеет на origin/main по НЕВЕРНОМУ ЗНАЧЕНИЮ, а не по отсутствию ключа, и
переживёт любое разумное переименование.
Тест герметичный: обе таблицы создаются ВРЕМЕННЫМИ в своей же сессии; по конвенции
`tests/sql/*` DSN по умолчанию смотрит в туннель к прод-базе, поэтому в фикстуре стоит
проверка, что затенение сработало.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return (
raw
if raw.startswith("postgresql+")
else raw.replace("postgresql://", "postgresql+psycopg://")
)
def _db_reachable() -> tuple[bool, str]:
try:
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
with eng.connect() as c:
c.execute(text("SELECT 1"))
return True, ""
except Exception as exc:
return False, str(exc)
_DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
_SCHEMA = """
CREATE TEMP TABLE prinzip_leads (
lead_id bigint, created_at timestamptz, source text, converted boolean,
deal_id bigint) ON COMMIT DROP;
CREATE TEMP TABLE prinzip_deals (
deal_id bigint, deal_price numeric) ON COMMIT DROP;
"""
_WINDOW_MONTHS = 12
# Внутри окна: 2 заявки, обе со сделками по 1 000 000.
# Снаружи (три года назад): 3 заявки, сделки по 5 000 000 — итог заведомо не равен окну.
_IN_WINDOW_LEADS = 2
_OUT_WINDOW_LEADS = 3
_ALL_TIME_LEADS = _IN_WINDOW_LEADS + _OUT_WINDOW_LEADS
_IN_WINDOW_REVENUE = 2_000_000.0
_IN_WINDOW_DEALS = 2
@pytest.fixture
def db():
engine = create_engine(_dsn())
session = sessionmaker(bind=engine)()
try:
session.execute(text(_SCHEMA))
for table in ("prinzip_leads", "prinzip_deals"):
n = session.execute(text(f"SELECT count(*) FROM {table}")).scalar()
assert n == 0, (
f"{table}: запрос попал НЕ во временную таблицу ({n} строк) — "
"тест читал бы боевые данные"
)
yield session
finally:
session.rollback()
session.close()
engine.dispose()
@pytest.fixture
def seeded(db):
rows = [
(1, "0 days", "site", True, 101, 1_000_000),
(2, "10 days", "site", True, 102, 1_000_000),
(3, "1100 days", "avito", True, 103, 5_000_000),
(4, "1101 days", "avito", True, 104, 5_000_000),
(5, "1102 days", "vk", True, 105, 5_000_000),
]
for lead_id, ago, source, converted, deal_id, price in rows:
db.execute(
text(
"INSERT INTO prinzip_leads (lead_id, created_at, source, converted, deal_id)"
" VALUES (:l, NOW() - CAST(:ago AS interval), :s, :c, :d)"
),
{"l": lead_id, "ago": ago, "s": source, "c": converted, "d": deal_id},
)
db.execute(
text("INSERT INTO prinzip_deals (deal_id, deal_price) VALUES (:d, :p)"),
{"d": deal_id, "p": price},
)
return db
def _stats(db) -> dict:
from app.api.v1.admin_leads import leads_stats
return leads_stats(db=db, months=_WINDOW_MONTHS)
def test_window_suffixed_fields_match_the_window(seeded) -> None:
"""Всё, что названо `_window`, обязано считаться по окну.
На origin/main эти величины лежат под именами `revenue_total`/`deals_total`,
поэтому проверка ниже (по `_total`) и краснеет — здесь же контроль, что
оконные значения не поехали.
"""
stats = _stats(seeded)
assert stats["leads_window"] == _IN_WINDOW_LEADS
assert stats["converted_window"] == _IN_WINDOW_LEADS
def test_total_suffixed_fields_are_all_time(seeded) -> None:
"""КАЖДОЕ поле `*_total` обязано быть за всё время, а не за окно.
На origin/main `revenue_total` = 2 000 000 (только окно) при итоге 17 000 000,
и `deals_total` = 2 при итоге 5 — красное по неверному ЗНАЧЕНИЮ.
"""
stats = _stats(seeded)
all_time_revenue = float(
seeded.execute(text("SELECT COALESCE(SUM(deal_price), 0) FROM prinzip_deals")).scalar()
)
all_time_deals = int(seeded.execute(text("SELECT COUNT(*) FROM prinzip_deals")).scalar())
expected = {
"leads_total": _ALL_TIME_LEADS,
"revenue_total": all_time_revenue,
"deals_total": all_time_deals,
}
for key, value in stats.items():
if not key.endswith("_total"):
continue
if key not in expected:
continue
assert value == expected[key], (
f"поле {key!r} обещает суффиксом величину за ВСЁ время, а равно {value} "
f"при итоге {expected[key]} — это цифра за окно {_WINDOW_MONTHS} мес"
)
def test_revenue_and_deals_are_named_by_their_scope(seeded) -> None:
"""Выручка и сделки должны нести суффикс, соответствующий их охвату.
Отдельно от предыдущего: там проверяется значение под именем, здесь — что имя
вообще выбрано по охвату. Ловит «починку», которая оставила бы `_total` и
просто перестала показывать поле в UI.
"""
stats = _stats(seeded)
assert stats.get("revenue_window") == _IN_WINDOW_REVENUE, (
f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
)
assert stats.get("deals_window") == _IN_WINDOW_DEALS
def test_window_months_present_on_empty_data(db) -> None:
"""Контроль: на пустых данных ответ сохраняет форму и ширину окна.
Оговорка, чтобы тест не читался как покрытие ветки `if not row`: он туда НЕ
попадает. Запрос агрегатный и на пустых таблицах возвращает обычную строку
(leads_total=0, leads_window=0), поэтому исполняется основная ветка. Ветка
пустого ответа сегодня недостижима — её согласованность правится вслепую,
и проверить её этим тестом нельзя.
"""
stats = _stats(db)
assert "window_months" in stats, f"нет window_months в пустом ответе: {sorted(stats)}"
assert stats["window_months"] == _WINDOW_MONTHS