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
РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ
политики обработки ПДн, а не упоминание закона. Чекбокс в 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 (недостижим с живого роута) — текст
приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет
теста, который держал бы там ссылку в актуальном состоянии.
194 lines
9.5 KiB
Python
194 lines
9.5 KiB
Python
"""ЭТАП 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"
|