"""ЭТАП 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 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 `` ("Политикой обработки персональных данных" 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) ... 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 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"(.*?)", tsx_source, re.DOTALL) assert match is not None, "no 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. , ) return re.sub(r"\s+", " ", inner).strip() def _extract_privacy_approval_iso_date(content_ts_source: str) -> str: """Pull the "DD 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 = """ Line one Line two """ assert _extract_span_text(sample) == "Line one Line two" def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None: """Sanity check: a 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 = """ Согласен(-на) на обработку персональных данных в соответствии с{" "} Политикой обработки персональных данных """ 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"