gendesign/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py
bot-backend 74e7969ef9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
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 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
fix(tradein/lead): кликабельная ссылка на политику ПДн в чекбоксе согласия
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в LeadForm.tsx
(v2, живой /trade-in/v2) теперь линкует "Политикой обработки персональных
данных" на /mera-public/privacy (target=_blank, чтобы не терять заполненную
форму). Путь вынесен в новый src/lib/legal-copy.ts (модуль без импортов) —
content.ts ре-экспортирует оттуда, чтобы B2B-виджет не тянул B2C-лэндинг-модуль
целиком.

_CONSENT_TEXT_SNAPSHOT/_CONSENT_POLICY_VERSION в lead.py обновлены под новый
плоский текст и дату утверждения политики (PRIVACY_APPROVAL: 2026-08-13).
test_consent_text_frontend_sync.py: экстрактор теперь снимает JSX-теги/{" "}
спейсеры перед сравнением (иначе сломался бы на разметке ссылки) + новый тест
держит _CONSENT_POLICY_VERSION в синхроне с PRIVACY_APPROVAL из content.ts,
чтобы версия не расходилась молча с редакцией документа.

Легаси-дубль в HeroTransparency.tsx (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
2026-08-14 15:29:49 +03:00

194 lines
9.5 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.

"""ЭТАП 4 B2C launch — consent-text sync guard (part D).
WHY:
_CONSENT_TEXT_SNAPSHOT in app/api/v1/lead.py is a durable 152-ФЗ
proof-of-consent: it must be the EXACT text a user actually saw and agreed
to. Before this test, the only thing keeping it in sync with the real
frontend checkbox label (LeadForm.tsx) was a code COMMENT ("Должен
ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx"). A comment cannot fail CI
-- a frontend copy edit could silently drift from the backend snapshot,
and every future lead's "proof" would then misrepresent what the user
actually saw.
WHAT:
Extract the actual consent-checkbox label text straight out of
LeadForm.tsx (regex, no JSX parser needed -- there is exactly one <span>
in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for-
byte after whitespace normalisation (JSX text nodes wrap across source
lines; the DOM-rendered text collapses that to single spaces). The label
now wraps a `<Link>` ("Политикой обработки персональных данных" is a
clickable link to the actual policy document, RKN/owner requirement --
the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only
the human-readable text, so the comparison stays a FLAT string on both
sides). If someone edits ONE side without the other, this test fails.
A second test (`test_consent_policy_version_matches_privacy_approval_date`)
guards the OTHER half of the same drift class found during triage: nothing
was checking that _CONSENT_POLICY_VERSION actually points at the privacy
policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts).
Bumping the policy text without bumping the version tag (or vice versa)
would silently mislabel every lead's proof-of-consent snapshot.
NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT
in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet
-- the anonymous /estimate flow isn't live (rbac_guard still requires
X-Authenticated-User on every non-public path, see app/core/rbac.py). When
that flow ships its own consent checkbox, add a second sync test here
mirroring this one -- do NOT rely on a comment for that pairing either.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_REPO_ROOT = Path(__file__).resolve().parents[2]
_FRONTEND_LEAD_FORM = (
_REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx"
)
_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г.").
_RU_MONTHS_GENITIVE = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
def _extract_span_text(tsx_source: str) -> str:
"""Pull the text content of the (single) <span>...</span> in LeadForm.tsx,
whitespace-normalised the same way a browser collapses JSX text-node
whitespace when rendering (multiple lines/indentation -> single spaces).
The span may contain nested JSX markup (e.g. a <Link> wrapping part of the
label, and a `{" "}` expression-spacer forcing a real space between a text
node and the link on the next source line -- plain JSX whitespace between
a text node and a tag on separate lines collapses to NOTHING, not a
space, so LeadForm.tsx needs that explicit spacer for correct rendering).
Both are stripped here so the comparison is against the flat, human-
readable text a user actually sees -- not the markup.
"""
match = re.search(r"<span>(.*?)</span>", tsx_source, re.DOTALL)
assert match is not None, "no <span> found in LeadForm.tsx -- consent label markup changed"
inner = match.group(1)
inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space
inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments
inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. <Link ...>, </Link>)
return re.sub(r"\s+", " ", inner).strip()
def _extract_privacy_approval_iso_date(content_ts_source: str) -> str:
"""Pull the "DD <month genitive> YYYY" date out of PRIVACY_APPROVAL in
mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string.
PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the
order that approves the actual privacy-policy EDITION the consent
checkbox links to (/mera-public/privacy) -- it is the correct source of
truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION
(which dates the offer + refund-policy documents, a different pair).
"""
match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source)
assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts"
date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1))
assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}"
day, month_name, year = date_match.groups()
month = _RU_MONTHS_GENITIVE.get(month_name)
assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}"
return f"{year}-{month:02d}-{int(day):02d}"
def test_frontend_lead_form_exists() -> None:
assert _FRONTEND_LEAD_FORM.is_file(), f"missing frontend file: {_FRONTEND_LEAD_FORM}"
def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None:
"""The whole point: this FAILS if lead.py._CONSENT_TEXT_SNAPSHOT and
LeadForm.tsx's checkbox label ever diverge -- no longer just a comment."""
from app.api.v1.lead import _CONSENT_TEXT_SNAPSHOT
frontend_text = _extract_span_text(_FRONTEND_LEAD_FORM.read_text(encoding="utf-8"))
backend_text = re.sub(r"\s+", " ", _CONSENT_TEXT_SNAPSHOT).strip()
assert frontend_text == backend_text, (
"consent text drift detected between app/api/v1/lead.py._CONSENT_TEXT_SNAPSHOT "
"and frontend/src/components/trade-in/v2/LeadForm.tsx checkbox label -- the "
"152-ФЗ proof-of-consent snapshot no longer matches what users actually see. "
"Bump _CONSENT_POLICY_VERSION and update _CONSENT_TEXT_SNAPSHOT together with "
"any frontend copy change.\n"
f" frontend: {frontend_text!r}\n"
f" backend: {backend_text!r}"
)
def test_consent_policy_version_matches_privacy_approval_date() -> None:
"""Guards the other half of the same drift class as the test above:
_CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it
claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some
unrelated date bumped by hand. A silent mismatch here would mislabel
every lead's proof-of-consent snapshot with the wrong policy edition."""
from app.api.v1.lead import _CONSENT_POLICY_VERSION
assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}"
expected_version = _extract_privacy_approval_iso_date(
_FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8")
)
assert _CONSENT_POLICY_VERSION == expected_version, (
"app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy "
"edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/"
"content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever "
"PRIVACY_APPROVAL changes (or vice versa).\n"
f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n"
f" PRIVACY_APPROVAL date: {expected_version!r}"
)
def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
"""Sanity check on the extraction helper itself, independent of the real file."""
sample = """
<span>
Line one
Line two
</span>
"""
assert _extract_span_text(sample) == "Line one Line two"
def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None:
"""Sanity check: a <Link> wrapping part of the label (plus the {" "}
spacer JSX needs to force a real space before it) must collapse to plain
text, exactly like a browser renders it -- this is the shape LeadForm.tsx
actually uses today for the policy-document link."""
sample = """
<span>
Согласен(-на) на обработку персональных данных в соответствии с{" "}
<Link href={PRIVACY_PATH} target="_blank" rel="noreferrer">
Политикой обработки персональных данных
</Link>
</span>
"""
assert _extract_span_text(sample) == (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Политикой обработки персональных данных"
)
def test_extract_privacy_approval_iso_date_helper() -> None:
"""Sanity check on the RU-date extraction helper, independent of the real file."""
sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";'
assert _extract_privacy_approval_iso_date(sample) == "2026-08-13"