All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после #3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки на нетронутых строках (длинные assert-сообщения и т.п.). Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего, кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих проектах, повторный `format --check` — 1001 файл без изменений (идемпотентно), compileall чистый. Refs #2864
189 lines
8.3 KiB
Python
189 lines
8.3 KiB
Python
"""Суффикс поля в /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
|