fix(landing): mirror Pydantic schema в PilotRequestModal validation (#1240)
Backend PilotRequestInput требует name min_length=2 и др., но modal
проверял только !form.name.trim(). 1-char name ('Я') проходил, backend
возвращал 422, apiFetch клал raw FastAPI JSON в Error.message, modal
рендерил его буквально для public lead form.
Patch: PILOT_LIMITS + PILOT_EMAIL_RE константы (синхр с backend),
per-field onBlur+live валидация с RU-сообщениями, minLength/maxLength
+ aria-invalid/aria-describedby, submit disabled при ошибках,
formatSubmitError → 4xx/5xx → human text. 26 vitest кейсов.
Closes #1240
This commit is contained in:
parent
703d3905b8
commit
fbafb1bf68
2 changed files with 478 additions and 45 deletions
|
|
@ -25,6 +25,25 @@ interface FormState {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FieldName = keyof FormState;
|
||||||
|
type FieldErrors = Partial<Record<FieldName, string>>;
|
||||||
|
|
||||||
|
// ── Schema mirror ──────────────────────────────────────────────────────────
|
||||||
|
// Зеркалирует backend Pydantic-схему `PilotRequestInput`
|
||||||
|
// (`backend/app/api/v1/pilot.py:25-34`). При изменении на бэкенде —
|
||||||
|
// синхронизировать здесь, иначе лид получит 422 и сырой JSON в модалке.
|
||||||
|
export const PILOT_LIMITS = {
|
||||||
|
name: { min: 2, max: 200 },
|
||||||
|
phone: { max: 50 },
|
||||||
|
email: { max: 200 },
|
||||||
|
company: { max: 200 },
|
||||||
|
message: { max: 2000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Тот же паттерн, что и в Pydantic Field(pattern=...) на бэкенде
|
||||||
|
// (`backend/app/api/v1/pilot.py:31`).
|
||||||
|
export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
||||||
|
|
@ -34,6 +53,85 @@ function deriveTrackingId(id: string): string {
|
||||||
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует одно поле против ограничений схемы. Возвращает текст ошибки
|
||||||
|
* (понятный пользователю) или пустую строку, если поле валидно.
|
||||||
|
*
|
||||||
|
* Экспортится для unit-тестов и потенциального переиспользования.
|
||||||
|
*/
|
||||||
|
export function validatePilotField(name: FieldName, value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
switch (name) {
|
||||||
|
case "name":
|
||||||
|
if (!trimmed) return "Укажите имя";
|
||||||
|
if (trimmed.length < PILOT_LIMITS.name.min)
|
||||||
|
return `Минимум ${PILOT_LIMITS.name.min} символа`;
|
||||||
|
if (trimmed.length > PILOT_LIMITS.name.max)
|
||||||
|
return `Максимум ${PILOT_LIMITS.name.max} символов`;
|
||||||
|
return "";
|
||||||
|
case "phone":
|
||||||
|
if (trimmed.length > PILOT_LIMITS.phone.max)
|
||||||
|
return `Максимум ${PILOT_LIMITS.phone.max} символов`;
|
||||||
|
return "";
|
||||||
|
case "email":
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.length > PILOT_LIMITS.email.max)
|
||||||
|
return `Максимум ${PILOT_LIMITS.email.max} символов`;
|
||||||
|
if (!PILOT_EMAIL_RE.test(trimmed)) return "Некорректный email";
|
||||||
|
return "";
|
||||||
|
case "company":
|
||||||
|
if (trimmed.length > PILOT_LIMITS.company.max)
|
||||||
|
return `Максимум ${PILOT_LIMITS.company.max} символов`;
|
||||||
|
return "";
|
||||||
|
case "message":
|
||||||
|
if (trimmed.length > PILOT_LIMITS.message.max)
|
||||||
|
return `Максимум ${PILOT_LIMITS.message.max} символов`;
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Валидирует все поля разом. Пустой объект — форма валидна. */
|
||||||
|
export function validatePilotForm(form: FormState): FieldErrors {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
(Object.keys(form) as FieldName[]).forEach((key) => {
|
||||||
|
const err = validatePilotField(key, form[key]);
|
||||||
|
if (err) errors[key] = err;
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дружелюбное сообщение об ошибке отправки.
|
||||||
|
* apiFetch (`lib/api.ts:39`) кладёт сырой response.text() в Error.message
|
||||||
|
* как «API error 422: {"detail":[...]}». На публичной лид-форме сырой JSON
|
||||||
|
* показывать нельзя — поэтому мапим коды в человеческий текст. На 422 это
|
||||||
|
* fallback при рассинхроне фронт/бэк валидации; клиент-сайд должен ловить
|
||||||
|
* это до отправки.
|
||||||
|
*/
|
||||||
|
export function formatSubmitError(err: unknown): string {
|
||||||
|
const fallback = "Не удалось отправить заявку. Попробуйте позже.";
|
||||||
|
if (!(err instanceof Error)) return fallback;
|
||||||
|
const match = err.message.match(/^API error (\d{3}):\s*(.*)$/s);
|
||||||
|
if (!match) return fallback;
|
||||||
|
const status = Number(match[1]);
|
||||||
|
const body = match[2];
|
||||||
|
if (status === 422) {
|
||||||
|
return "Проверьте корректность заполнения полей.";
|
||||||
|
}
|
||||||
|
if (status >= 500) {
|
||||||
|
return "Сервер недоступен. Попробуйте позже.";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as { detail?: unknown };
|
||||||
|
if (typeof parsed.detail === "string") return parsed.detail;
|
||||||
|
} catch {
|
||||||
|
// not JSON — fall through to fallback
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function PilotRequestModal({ open, onClose }: Props) {
|
export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
|
|
@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
company: "",
|
company: "",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
const [emailError, setEmailError] = useState<string>("");
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
const [trackingId, setTrackingId] = useState<string | null>(null);
|
const [trackingId, setTrackingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const mutation = useSubmitPilotRequest();
|
const mutation = useSubmitPilotRequest();
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
|
|
||||||
function handleChange(
|
function handleChange(
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) {
|
) {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setForm((prev) => ({ ...prev, [name]: value }));
|
const fieldName = name as FieldName;
|
||||||
if (name === "email") {
|
setForm((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : "");
|
// Live re-validate только для полей где ошибка уже показана (клиент сразу
|
||||||
}
|
// видит, что фикс «помог»), плюс email — исторически на онлайн-проверке.
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (fieldName !== "email" && !prev[fieldName]) return prev;
|
||||||
|
const err = validatePilotField(fieldName, value);
|
||||||
|
const next: FieldErrors = { ...prev };
|
||||||
|
if (err) next[fieldName] = err;
|
||||||
|
else delete next[fieldName];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur(
|
||||||
|
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const fieldName = name as FieldName;
|
||||||
|
const err = validatePilotField(fieldName, value);
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next: FieldErrors = { ...prev };
|
||||||
|
if (err) next[fieldName] = err;
|
||||||
|
else delete next[fieldName];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.name.trim()) return;
|
const validation = validatePilotForm(form);
|
||||||
if (form.email && !EMAIL_RE.test(form.email)) {
|
if (Object.keys(validation).length > 0) {
|
||||||
setEmailError("Некорректный email");
|
setErrors(validation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||||
...(form.company.trim() ? { company: form.company.trim() } : {}),
|
...(form.company.trim() ? { company: form.company.trim() } : {}),
|
||||||
...(form.message.trim()
|
...(form.message.trim()
|
||||||
? { message: form.message.trim().slice(0, 2000) }
|
? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -89,12 +207,15 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setForm({ name: "", phone: "", email: "", company: "", message: "" });
|
setForm({ name: "", phone: "", email: "", company: "", message: "" });
|
||||||
setEmailError("");
|
setErrors({});
|
||||||
setTrackingId(null);
|
setTrackingId(null);
|
||||||
mutation.reset();
|
mutation.reset();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0;
|
||||||
|
const submitDisabled = mutation.isPending || hasBlockingErrors;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onClose={handleClose} side="bottom">
|
<Drawer open={open} onClose={handleClose} side="bottom">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
name="name"
|
name="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
|
minLength={PILOT_LIMITS.name.min}
|
||||||
|
maxLength={PILOT_LIMITS.name.max}
|
||||||
placeholder="Алексей Кириллов"
|
placeholder="Алексей Кириллов"
|
||||||
style={inputStyle}
|
aria-invalid={errors.name ? "true" : "false"}
|
||||||
|
aria-describedby={errors.name ? "pilot-name-error" : undefined}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
borderColor: errors.name
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--border-strong)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{errors.name ? (
|
||||||
|
<span id="pilot-name-error" style={fieldErrorStyle}>
|
||||||
|
{errors.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company */}
|
{/* Company */}
|
||||||
|
|
@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
name="company"
|
name="company"
|
||||||
value={form.company}
|
value={form.company}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
maxLength={PILOT_LIMITS.company.max}
|
||||||
placeholder="PRINZIP"
|
placeholder="PRINZIP"
|
||||||
style={inputStyle}
|
aria-invalid={errors.company ? "true" : "false"}
|
||||||
|
aria-describedby={
|
||||||
|
errors.company ? "pilot-company-error" : undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
borderColor: errors.company
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--border-strong)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{errors.company ? (
|
||||||
|
<span id="pilot-company-error" style={fieldErrorStyle}>
|
||||||
|
{errors.company}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
|
|
@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
type="tel"
|
type="tel"
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
maxLength={PILOT_LIMITS.phone.max}
|
||||||
placeholder="+7 900 000-00-00"
|
placeholder="+7 900 000-00-00"
|
||||||
style={inputStyle}
|
aria-invalid={errors.phone ? "true" : "false"}
|
||||||
|
aria-describedby={
|
||||||
|
errors.phone ? "pilot-phone-error" : undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
borderColor: errors.phone
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--border-strong)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{errors.phone ? (
|
||||||
|
<span id="pilot-phone-error" style={fieldErrorStyle}>
|
||||||
|
{errors.phone}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
|
|
@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
maxLength={PILOT_LIMITS.email.max}
|
||||||
placeholder="alex@prinzip.ru"
|
placeholder="alex@prinzip.ru"
|
||||||
|
aria-invalid={errors.email ? "true" : "false"}
|
||||||
|
aria-describedby={
|
||||||
|
errors.email ? "pilot-email-error" : undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
...inputStyle,
|
...inputStyle,
|
||||||
borderColor: emailError
|
borderColor: errors.email
|
||||||
? "var(--danger)"
|
? "var(--danger)"
|
||||||
: "var(--border-strong)",
|
: "var(--border-strong)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{emailError ? (
|
{errors.email ? (
|
||||||
<span
|
<span id="pilot-email-error" style={fieldErrorStyle}>
|
||||||
style={{
|
{errors.email}
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--danger)",
|
|
||||||
marginTop: 4,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emailError}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
name="message"
|
name="message"
|
||||||
value={form.message}
|
value={form.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
placeholder="Расскажите о вашем проекте или вопросе..."
|
placeholder="Расскажите о вашем проекте или вопросе..."
|
||||||
rows={4}
|
rows={4}
|
||||||
maxLength={2000}
|
maxLength={PILOT_LIMITS.message.max}
|
||||||
|
aria-invalid={errors.message ? "true" : "false"}
|
||||||
|
aria-describedby={
|
||||||
|
errors.message ? "pilot-message-error" : undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
...inputStyle,
|
...inputStyle,
|
||||||
resize: "vertical",
|
resize: "vertical",
|
||||||
minHeight: 96,
|
minHeight: 96,
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
|
borderColor: errors.message
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--border-strong)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
|
@ -289,13 +464,19 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
display: "block",
|
display: "block",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{form.message.length} / 2000
|
{form.message.length} / {PILOT_LIMITS.message.max}
|
||||||
</span>
|
</span>
|
||||||
|
{errors.message ? (
|
||||||
|
<span id="pilot-message-error" style={fieldErrorStyle}>
|
||||||
|
{errors.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{mutation.isError ? (
|
{mutation.isError ? (
|
||||||
<div
|
<div
|
||||||
|
role="alert"
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 14px",
|
padding: "10px 14px",
|
||||||
background: "var(--danger-soft)",
|
background: "var(--danger-soft)",
|
||||||
|
|
@ -304,35 +485,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
color: "var(--danger)",
|
color: "var(--danger)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mutation.error instanceof Error
|
{formatSubmitError(mutation.error)}
|
||||||
? mutation.error.message
|
|
||||||
: "Ошибка отправки. Попробуйте позже."}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={mutation.isPending || !form.name.trim()}
|
disabled={submitDisabled}
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
background:
|
background: submitDisabled
|
||||||
mutation.isPending || !form.name.trim()
|
? "var(--accent-soft)"
|
||||||
? "var(--accent-soft)"
|
: "var(--accent)",
|
||||||
: "var(--accent)",
|
color: submitDisabled ? "var(--accent)" : "#fff",
|
||||||
color:
|
|
||||||
mutation.isPending || !form.name.trim()
|
|
||||||
? "var(--accent)"
|
|
||||||
: "#fff",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor:
|
cursor: submitDisabled ? "not-allowed" : "pointer",
|
||||||
mutation.isPending || !form.name.trim()
|
opacity: submitDisabled ? 0.7 : 1,
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
opacity: mutation.isPending || !form.name.trim() ? 0.7 : 1,
|
|
||||||
transition: "opacity 0.15s",
|
transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -369,3 +541,10 @@ const inputStyle: React.CSSProperties = {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
boxSizing: "border-box" as const,
|
boxSizing: "border-box" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fieldErrorStyle: React.CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--danger)",
|
||||||
|
marginTop: 4,
|
||||||
|
display: "block",
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PILOT_EMAIL_RE,
|
||||||
|
PILOT_LIMITS,
|
||||||
|
PilotRequestModal,
|
||||||
|
formatSubmitError,
|
||||||
|
validatePilotField,
|
||||||
|
validatePilotForm,
|
||||||
|
} from "../PilotRequestModal";
|
||||||
|
|
||||||
|
// ── Schema parity with backend Pydantic PilotRequestInput ─────────────────
|
||||||
|
// `backend/app/api/v1/pilot.py` defines:
|
||||||
|
// name: min_length=2, max_length=200
|
||||||
|
// phone: max_length=50
|
||||||
|
// email: max_length=200, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
||||||
|
// company: max_length=200
|
||||||
|
// message: max_length=2000
|
||||||
|
describe("PilotRequestModal — schema mirror", () => {
|
||||||
|
it("PILOT_LIMITS matches backend constraints", () => {
|
||||||
|
expect(PILOT_LIMITS).toEqual({
|
||||||
|
name: { min: 2, max: 200 },
|
||||||
|
phone: { max: 50 },
|
||||||
|
email: { max: 200 },
|
||||||
|
company: { max: 200 },
|
||||||
|
message: { max: 2000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PILOT_EMAIL_RE matches backend Pydantic pattern", () => {
|
||||||
|
// Source-of-truth string from pilot.py:31
|
||||||
|
expect(PILOT_EMAIL_RE.source).toBe("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePilotField — name", () => {
|
||||||
|
it("rejects empty name", () => {
|
||||||
|
expect(validatePilotField("name", "")).toBe("Укажите имя");
|
||||||
|
expect(validatePilotField("name", " ")).toBe("Укажите имя");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects single-char name (matches backend min_length=2)", () => {
|
||||||
|
// Это центральный кейс issue #1240: «Я» проходило старую проверку и упиралось в 422.
|
||||||
|
expect(validatePilotField("name", "Я")).toBe("Минимум 2 символа");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 2+ char name", () => {
|
||||||
|
expect(validatePilotField("name", "Ян")).toBe("");
|
||||||
|
expect(validatePilotField("name", "Алексей Кириллов")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects >200 char name", () => {
|
||||||
|
const long = "А".repeat(201);
|
||||||
|
expect(validatePilotField("name", long)).toBe("Максимум 200 символов");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts exactly 200 char name", () => {
|
||||||
|
expect(validatePilotField("name", "А".repeat(200))).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePilotField — phone", () => {
|
||||||
|
it("accepts empty / short phone", () => {
|
||||||
|
expect(validatePilotField("phone", "")).toBe("");
|
||||||
|
expect(validatePilotField("phone", "+7 900 000-00-00")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects >50 char phone", () => {
|
||||||
|
expect(validatePilotField("phone", "+".repeat(51))).toBe(
|
||||||
|
"Максимум 50 символов",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePilotField — email", () => {
|
||||||
|
it("accepts empty email (optional field)", () => {
|
||||||
|
expect(validatePilotField("email", "")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed email", () => {
|
||||||
|
expect(validatePilotField("email", "no-at-sign")).toBe("Некорректный email");
|
||||||
|
expect(validatePilotField("email", "user@")).toBe("Некорректный email");
|
||||||
|
expect(validatePilotField("email", "user@host")).toBe("Некорректный email");
|
||||||
|
expect(validatePilotField("email", "@host.ru")).toBe("Некорректный email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts well-formed email", () => {
|
||||||
|
expect(validatePilotField("email", "alex@prinzip.ru")).toBe("");
|
||||||
|
expect(validatePilotField("email", "a.b+tag@example.co.uk")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects >200 char email", () => {
|
||||||
|
// 196 a-chars + "@b.ru" (5) = 201 chars total → over the limit.
|
||||||
|
const tooLong = "a".repeat(196) + "@b.ru";
|
||||||
|
expect(tooLong.length).toBe(201);
|
||||||
|
expect(validatePilotField("email", tooLong)).toBe("Максимум 200 символов");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePilotField — company / message", () => {
|
||||||
|
it("rejects >200 char company", () => {
|
||||||
|
expect(validatePilotField("company", "x".repeat(201))).toBe(
|
||||||
|
"Максимум 200 символов",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects >2000 char message", () => {
|
||||||
|
expect(validatePilotField("message", "x".repeat(2001))).toBe(
|
||||||
|
"Максимум 2000 символов",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePilotForm — composite", () => {
|
||||||
|
it("returns empty object for valid form", () => {
|
||||||
|
expect(
|
||||||
|
validatePilotForm({
|
||||||
|
name: "Алексей",
|
||||||
|
phone: "+7 900 000-00-00",
|
||||||
|
email: "alex@prinzip.ru",
|
||||||
|
company: "PRINZIP",
|
||||||
|
message: "Здравствуйте",
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error map for invalid form", () => {
|
||||||
|
const errors = validatePilotForm({
|
||||||
|
name: "Я",
|
||||||
|
phone: "",
|
||||||
|
email: "bad-email",
|
||||||
|
company: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
expect(errors.name).toBe("Минимум 2 символа");
|
||||||
|
expect(errors.email).toBe("Некорректный email");
|
||||||
|
expect(errors.phone).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSubmitError — friendly 422 mapping", () => {
|
||||||
|
it("returns generic fallback for non-Error", () => {
|
||||||
|
expect(formatSubmitError(null)).toMatch(/Не удалось/);
|
||||||
|
expect(formatSubmitError("string")).toMatch(/Не удалось/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 422 to friendly text instead of raw FastAPI JSON", () => {
|
||||||
|
// Это центральный кейс issue #1240: apiFetch кидает «API error 422: {...}»
|
||||||
|
// и модалка раньше рендерила JSON verbatim. Теперь — человеческий текст.
|
||||||
|
const raw = `API error 422: {"detail":[{"type":"string_too_short","loc":["body","name"],"msg":"String should have at least 2 characters","input":"Я","ctx":{"min_length":2}}]}`;
|
||||||
|
expect(formatSubmitError(new Error(raw))).toBe(
|
||||||
|
"Проверьте корректность заполнения полей.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 500 to friendly fallback", () => {
|
||||||
|
expect(formatSubmitError(new Error("API error 500: boom"))).toMatch(
|
||||||
|
/Сервер недоступен/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses detail field if 4xx body is JSON with string detail", () => {
|
||||||
|
const raw = `API error 400: {"detail":"Custom backend message"}`;
|
||||||
|
expect(formatSubmitError(new Error(raw))).toBe("Custom backend message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns generic fallback on non-API-error message", () => {
|
||||||
|
expect(formatSubmitError(new Error("network error"))).toMatch(/Не удалось/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Component-level integration ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderModal() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<PilotRequestModal open={true} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PilotRequestModal — component behaviour", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||||
|
() => new Promise(() => {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables submit until name passes min_length", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderModal();
|
||||||
|
const submit = screen.getByRole("button", { name: /Отправить заявку/i });
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
const nameInput = screen.getByLabelText(/Имя/i);
|
||||||
|
await user.type(nameInput, "Я");
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await user.type(nameInput, "н");
|
||||||
|
expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows inline name error on blur with 1-char name (issue #1240 trace)", async () => {
|
||||||
|
renderModal();
|
||||||
|
const nameInput = screen.getByLabelText(/Имя/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(nameInput, { target: { value: "Я" } });
|
||||||
|
fireEvent.blur(nameInput);
|
||||||
|
expect(await screen.findByText(/Минимум 2 символа/i)).toBeInTheDocument();
|
||||||
|
// Submit should be blocked — no raw JSON 422 from backend reaches the user.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Отправить заявку/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows email error live when user types invalid value", async () => {
|
||||||
|
renderModal();
|
||||||
|
const nameInput = screen.getByLabelText(/Имя/i);
|
||||||
|
fireEvent.change(nameInput, { target: { value: "Алексей" } });
|
||||||
|
const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(emailInput, { target: { value: "no-at" } });
|
||||||
|
expect(await screen.findByText(/Некорректный email/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Отправить заявку/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets HTML attrs mirroring schema (minLength/maxLength)", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("minLength", "2");
|
||||||
|
expect(screen.getByLabelText(/Имя/i)).toHaveAttribute("maxLength", "200");
|
||||||
|
expect(screen.getByLabelText(/Компания/i)).toHaveAttribute(
|
||||||
|
"maxLength",
|
||||||
|
"200",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(/Телефон/i)).toHaveAttribute(
|
||||||
|
"maxLength",
|
||||||
|
"50",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(/Email/i)).toHaveAttribute("maxLength", "200");
|
||||||
|
expect(screen.getByLabelText(/Сообщение/i)).toHaveAttribute(
|
||||||
|
"maxLength",
|
||||||
|
"2000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue