gendesign/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py
bot-backend 48664dfe0e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m49s
fix(tradein/payments): pre-flight должен ловить аномалию, не штатное состояние (review PR #2754)
Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).

- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
  теперь считает только реальную аномалию (retain_until не проставлен, а
  платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
  под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
  позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
  оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
  джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
  (PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
  добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
  образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
  удаление — задача на проде выключена и ни разу не запускалась, текст
  теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
  на краткое изложение сути в комментарии + ссылку на PR #2754.
2026-08-06 22:49:09 +03:00

103 lines
4.6 KiB
Python

"""Payments retention (PR #2754) — "12 месяцев" text sync guard.
WHY:
mera-public/content.ts declares itself as the ONE place product promises
live (docstring at the top of that file: "ни одного утверждения, которого
не делает код"). The public retention promise ("оплаченный отчёт хранится
N месяцев") has THREE places it could quietly drift: the backend setting
(`settings.trade_in_paid_retention_days`), the frontend constant
(`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders
it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on
PR #2754 caught exactly this: a comment claimed the number "reads from the
same setting" while the page actually hardcoded a `12 месяцев` literal --
a comment cannot fail CI, same lesson as
test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which
this file mirrors).
WHAT:
1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts
and does NOT hardcode a "N месяцев" literal of its own.
2. The frontend months constant and the backend days setting stay within
a sane calendar tolerance of each other (28-31 days per month) -- this
does NOT enforce byte-identity (days and months are different units by
design, see content.ts docstring), only that nobody silently changes
one without the other drifting out of "still honestly ~12 months".
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_FRONTEND_CONTENT = (
Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts"
)
_FRONTEND_PRIVACY_PAGE = (
Path(__file__).resolve().parents[2]
/ "frontend"
/ "src"
/ "app"
/ "mera-public"
/ "privacy"
/ "page.tsx"
)
_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;")
_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?')
def _extract_months_constant(content_ts_source: str) -> int:
match = _MONTHS_CONST_RE.search(content_ts_source)
assert match is not None, (
"PAID_REPORT_RETENTION_MONTHS not found in content.ts -- "
"constant renamed/removed without updating this test"
)
return int(match.group(1))
def test_frontend_files_exist() -> None:
assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}"
assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}"
def test_privacy_page_imports_retention_constant_not_hardcoded() -> None:
"""The whole point: FAILS if privacy/page.tsx stops importing the shared
constant and goes back to a hardcoded '12 месяцев' literal (exactly the
drift the deep-review finding caught -- comment said 'reads from content.ts',
code said otherwise)."""
src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8")
assert "PAID_REPORT_RETENTION_MONTHS" in src, (
"privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- "
"the retention promise must be rendered from the shared content.ts "
"constant, not written out again by hand"
)
assert re.search(r'from\s+"\.\./content"', src), (
"privacy/page.tsx must import from '../content' (content.ts), where "
"PAID_REPORT_RETENTION_MONTHS is declared"
)
literal_hits = _LITERAL_MONTHS_RE.findall(src)
assert not literal_hits, (
"privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the "
"PAID_REPORT_RETENTION_MONTHS constant instead: "
f"{literal_hits!r}"
)
def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None:
"""Not byte-identity (days vs months are different units, deliberately --
see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity
bound that `trade_in_paid_retention_days` still honestly rounds to the
number of months the public page promises (28-31 days/month, generous)."""
from app.core.config import settings
days = settings.trade_in_paid_retention_days
months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8"))
assert 28 * months <= days <= 31 * months, (
f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to "
f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together "
"(and the offer text, when it exists) so the public promise stays true"
)