From 74e7969ef90115828531b1b9b82549ace853afc5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 14 Aug 2026 15:29:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/lead):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=D0=BA=D1=83=20=D0=9F=D0=94=D0=BD=20=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit РКН/владелец: рядом с чекбоксом согласия должна быть ссылка на сам документ политики обработки ПДн, а не упоминание закона. Чекбокс в 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 (недостижим с живого роута) — текст приведён в соответствие без ссылки: компонент не смонтирован нигде, и нет теста, который держал бы там ссылку в актуальном состоянии. --- tradein-mvp/backend/app/api/v1/lead.py | 22 ++- .../tests/test_consent_text_frontend_sync.py | 127 ++++++++++++++++-- .../frontend/src/app/mera-public/content.ts | 5 +- .../components/trade-in/HeroTransparency.tsx | 11 +- .../src/components/trade-in/v2/LeadForm.tsx | 12 +- tradein-mvp/frontend/src/lib/legal-copy.ts | 26 ++++ 6 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/legal-copy.ts diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 6ac5ba07..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -51,16 +51,24 @@ _PHONE_MAX_DIGITS = 15 # Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится # per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась # только в audit-лог (#2497 TODO, теперь закрыт). -_CONSENT_POLICY_VERSION = "2026-07" +# +# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/ +# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» → +# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию +# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует +# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет +# это соответствие автоматически, так что рассинхронизация здесь падает в CI. +_CONSENT_POLICY_VERSION = "2026-08-13" -# Снимок точного текста согласия, который видит пользователь при отправке лида. -# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/ -# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять -# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки -# будут нести устаревший snapshot под новой version-меткой. +# Снимок точного текста согласия, который видит пользователь при отправке лида +# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом +# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/ +# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно +# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые +# строки будут нести устаревший snapshot под новой version-меткой. _CONSENT_TEXT_SNAPSHOT = ( "Согласен(-на) на обработку персональных данных в соответствии с " - "Федеральным законом «О персональных данных» № 152-ФЗ" + "Политикой обработки персональных данных" ) diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py index b87af5ae..b82bda69 100644 --- a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -15,8 +15,19 @@ WHAT: 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). If someone - edits ONE side without the other, this test fails. + 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 @@ -34,25 +45,71 @@ 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 = ( - Path(__file__).resolve().parents[2] - / "frontend" - / "src" - / "components" - / "trade-in" - / "v2" - / "LeadForm.tsx" + _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"\s*(.*?)\s*", tsx_source, re.DOTALL) + match = re.search(r"(.*?)", tsx_source, re.DOTALL) assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" - return re.sub(r"\s+", " ", match.group(1)).strip() + 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: @@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: ) +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 = """ @@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """ 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" diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index a2269a15..c4ab622d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -157,7 +157,10 @@ export const LEGAL_ENTITY: { * ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой * странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже). */ -export const PRIVACY_PATH = "/mera-public/privacy"; +// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же +// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым +// нежелательно тянуть весь этот B2C-лэндинг-модуль (см. legal-copy.ts). +export { PRIVACY_PATH } from "@/lib/legal-copy"; export const OFFER_PATH = "/mera-public/oferta"; export const REFUND_PATH = "/mera-public/refund"; diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index fc2d8906..d4dfd991 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -250,8 +250,15 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { disabled={leadMutation.isPending} /> - Согласен(-на) на обработку персональных данных в соответствии с Федеральным - законом «О персональных данных» № 152-ФЗ + {/* Легаси-дерево, недостижимое с живого /trade-in/v2 (см. LeadForm.tsx + header-комментарий) — текст держим в синхроне с v2-чекбоксом, чтобы + формулировки не разъезжались, если этот компонент когда-нибудь + вернётся в строй. Кликабельную ссылку здесь намеренно не добавляем: + компонент нигде не смонтирован, никакой пользователь его не видит, + и нет теста, который держал бы такую ссылку в актуальном состоянии + (в отличие от LeadForm.tsx — см. test_consent_text_frontend_sync.py). */} + Согласен(-на) на обработку персональных данных в соответствии с Политикой + обработки персональных данных {phoneInvalid && ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx index 3aeb5484..49c7c480 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LeadForm.tsx @@ -23,8 +23,10 @@ // explanation for whichever field blocked it. import { useState } from "react"; import type { CSSProperties, FormEvent } from "react"; +import Link from "next/link"; import { tokens } from "./tokens"; import { useCreateLeadMutation } from "@/lib/trade-in-api"; +import { PRIVACY_PATH } from "@/lib/legal-copy"; const { accent, @@ -306,8 +308,14 @@ export function LeadForm({ estimateId }: LeadFormProps) { style={{ marginTop: 2, cursor: "inherit", flex: "0 0 auto" }} /> - Согласен(-на) на обработку персональных данных в соответствии с - Федеральным законом «О персональных данных» № 152-ФЗ + Согласен(-на) на обработку персональных данных в соответствии с{" "} + {/* target="_blank" — клик по ссылке не должен сбрасывать уже заполненную + форму заявки (телефон + чекбокс) в текущей вкладке. Ссылка вложена + в diff --git a/tradein-mvp/frontend/src/lib/legal-copy.ts b/tradein-mvp/frontend/src/lib/legal-copy.ts new file mode 100644 index 00000000..2f625cf4 --- /dev/null +++ b/tradein-mvp/frontend/src/lib/legal-copy.ts @@ -0,0 +1,26 @@ +/** + * Юридические константы, разделяемые между B2C-лэндингом + * (`src/app/mera-public/content.ts`) и B2B trade-in виджетами + * (`src/components/trade-in/**`, включая v2 `LeadForm.tsx`). + * + * ПОЧЕМУ отдельный модуль: v2-компонент результата оценки (B2B-экран, + * пилоты/менеджеры) не должен тянуть `mera-public/content.ts` целиком — + * тот модуль набит B2C-лэндинг-специфичными константами (`LEGAL_ENTITY`, + * `PUBLIC_ESTIMATE_ENABLED`, банковские реквизиты и т.д.), появление + * которых в графе импортов B2B-экрана было бы случайной и нежелательной + * связью двух разных поверхностей продукта. Вместо этого путь к странице + * политики ПДн живёт здесь — в модуле БЕЗ импортов, который могут + * безопасно использовать обе стороны. + * + * `content.ts` ре-экспортирует `PRIVACY_PATH` отсюда (не дублирует + * литерал) — единственный источник истины для пути. + */ + +/** + * Путь страницы политики обработки персональных данных внутри Next-приложения + * (то, что понимает `next/link` с учётом `basePath=/trade-in` в проде). + * Публично та же страница доступна коротким адресом meraocenka.ru/privacy + * (см. `PUBLIC_PRIVACY_URL` в `mera-public/content.ts`), но для ссылки из + * чекбоксов согласия внутри приложения нужен именно внутренний путь. + */ +export const PRIVACY_PATH = "/mera-public/privacy"; -- 2.45.3