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;
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует одно поле против ограничений схемы. Возвращает текст ошибки
|
||||
* (понятный пользователю) или пустую строку, если поле валидно.
|
||||
*
|
||||
* Экспортится для 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function PilotRequestModal({ open, onClose }: Props) {
|
||||
|
|
@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
company: "",
|
||||
message: "",
|
||||
});
|
||||
const [emailError, setEmailError] = useState<string>("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [trackingId, setTrackingId] = useState<string | null>(null);
|
||||
|
||||
const mutation = useSubmitPilotRequest();
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
if (name === "email") {
|
||||
setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : "");
|
||||
}
|
||||
const fieldName = name as FieldName;
|
||||
setForm((prev) => ({ ...prev, [fieldName]: value }));
|
||||
// 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>) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
if (form.email && !EMAIL_RE.test(form.email)) {
|
||||
setEmailError("Некорректный email");
|
||||
const validation = validatePilotForm(form);
|
||||
if (Object.keys(validation).length > 0) {
|
||||
setErrors(validation);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||
...(form.company.trim() ? { company: form.company.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() {
|
||||
setForm({ name: "", phone: "", email: "", company: "", message: "" });
|
||||
setEmailError("");
|
||||
setErrors({});
|
||||
setTrackingId(null);
|
||||
mutation.reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0;
|
||||
const submitDisabled = mutation.isPending || hasBlockingErrors;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onClose={handleClose} side="bottom">
|
||||
{/* Header */}
|
||||
|
|
@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
minLength={PILOT_LIMITS.name.min}
|
||||
maxLength={PILOT_LIMITS.name.max}
|
||||
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>
|
||||
|
||||
{/* Company */}
|
||||
|
|
@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
name="company"
|
||||
value={form.company}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
maxLength={PILOT_LIMITS.company.max}
|
||||
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>
|
||||
|
||||
{/* Phone */}
|
||||
|
|
@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
maxLength={PILOT_LIMITS.phone.max}
|
||||
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>
|
||||
|
||||
{/* Email */}
|
||||
|
|
@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
type="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
maxLength={PILOT_LIMITS.email.max}
|
||||
placeholder="alex@prinzip.ru"
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={
|
||||
errors.email ? "pilot-email-error" : undefined
|
||||
}
|
||||
style={{
|
||||
...inputStyle,
|
||||
borderColor: emailError
|
||||
borderColor: errors.email
|
||||
? "var(--danger)"
|
||||
: "var(--border-strong)",
|
||||
}}
|
||||
/>
|
||||
{emailError ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--danger)",
|
||||
marginTop: 4,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{emailError}
|
||||
{errors.email ? (
|
||||
<span id="pilot-email-error" style={fieldErrorStyle}>
|
||||
{errors.email}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
name="message"
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Расскажите о вашем проекте или вопросе..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
maxLength={PILOT_LIMITS.message.max}
|
||||
aria-invalid={errors.message ? "true" : "false"}
|
||||
aria-describedby={
|
||||
errors.message ? "pilot-message-error" : undefined
|
||||
}
|
||||
style={{
|
||||
...inputStyle,
|
||||
resize: "vertical",
|
||||
minHeight: 96,
|
||||
fontFamily: "inherit",
|
||||
borderColor: errors.message
|
||||
? "var(--danger)"
|
||||
: "var(--border-strong)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
|
|
@ -289,13 +464,19 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
display: "block",
|
||||
}}
|
||||
>
|
||||
{form.message.length} / 2000
|
||||
{form.message.length} / {PILOT_LIMITS.message.max}
|
||||
</span>
|
||||
{errors.message ? (
|
||||
<span id="pilot-message-error" style={fieldErrorStyle}>
|
||||
{errors.message}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{mutation.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
background: "var(--danger-soft)",
|
||||
|
|
@ -304,35 +485,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
|
|||
color: "var(--danger)",
|
||||
}}
|
||||
>
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: "Ошибка отправки. Попробуйте позже."}
|
||||
{formatSubmitError(mutation.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending || !form.name.trim()}
|
||||
disabled={submitDisabled}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
background:
|
||||
mutation.isPending || !form.name.trim()
|
||||
? "var(--accent-soft)"
|
||||
: "var(--accent)",
|
||||
color:
|
||||
mutation.isPending || !form.name.trim()
|
||||
? "var(--accent)"
|
||||
: "#fff",
|
||||
background: submitDisabled
|
||||
? "var(--accent-soft)"
|
||||
: "var(--accent)",
|
||||
color: submitDisabled ? "var(--accent)" : "#fff",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
mutation.isPending || !form.name.trim()
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
opacity: mutation.isPending || !form.name.trim() ? 0.7 : 1,
|
||||
cursor: submitDisabled ? "not-allowed" : "pointer",
|
||||
opacity: submitDisabled ? 0.7 : 1,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
|
|
@ -369,3 +541,10 @@ const inputStyle: React.CSSProperties = {
|
|||
outline: "none",
|
||||
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