test(tradein/frontend): первые проверки поведения — пустое поле, погашенное число, отказ 429 (#2766) (#2769)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / deploy (push) Successful in 57s

This commit is contained in:
bot-backend 2026-08-07 07:55:37 +00:00
parent 0de22f4bc9
commit a034d7a1c1
8 changed files with 416 additions and 1 deletions

View file

@ -304,6 +304,20 @@ jobs:
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
- name: Run tests (vitest)
# Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
# проверки поведения: лэйн гейтил только типы и статический анализ, а оба
# молчат про то, что видит пользователь — пустое поле, погашенное число,
# отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
# frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
#
# Пропусков в сьюте нет и быть не должно: сторож пропусков
# (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
# пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
# запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
# с причиной В КОДЕ.
run: npm test
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint

View file

@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
},
"dependencies": {
@ -18,12 +20,18 @@
"recharts": "^2.15.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@eslint/eslintrc": "^3.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"typescript": "^5.9.3"
"jsdom": "^25.0.1",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}

View file

@ -0,0 +1,111 @@
/**
* #2766 отказ по частоте на форме входа должен читаться как отказ по частоте.
*
* PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401
* падал в общую фразу «Не удалось войти», а человек, которого притормозил
* лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено,
* что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль»,
* ни с общей ошибкой.
*
* Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая
* `apiFetch` и настоящий `HTTPError` то есть проверяется весь путь от кода
* ответа до текста на экране, а не одна функция в вакууме.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "../page";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
}));
/**
* Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных
* у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не
* подмешивал свои ошибки в проверяемое состояние формы.
*/
function stubFetch(loginStatus: number, loginBody: unknown) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/auth/login")) {
return new Response(JSON.stringify(loginBody), {
status: loginStatus,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
}),
);
}
function renderLogin() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<LoginPage />
</QueryClientProvider>,
);
}
function submitCredentials() {
fireEvent.change(screen.getByLabelText("Логин"), {
target: { value: "praktika" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "hunter2" },
});
fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" }));
}
describe("LoginPage — сообщение об ошибке входа", () => {
beforeEach(() => vi.unstubAllGlobals());
afterEach(() => vi.unstubAllGlobals());
it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => {
stubFetch(429, { detail: "Too Many Requests" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Слишком много попыток. Попробуйте через несколько минут",
);
expect(alert).not.toHaveTextContent("Неверный логин или пароль");
expect(alert).not.toHaveTextContent("Не удалось войти");
});
it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => {
stubFetch(401, { detail: "Invalid credentials" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Неверный логин или пароль");
expect(alert).not.toHaveTextContent("Слишком много попыток");
});
it("500 — общая фраза (её текст не должен подменять собой 429)", async () => {
stubFetch(500, { detail: "boom" });
renderLogin();
submitCredentials();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Не удалось войти. Проверьте подключение и попробуйте ещё раз",
);
});
it("до отправки формы сообщения об ошибке нет", async () => {
stubFetch(429, { detail: "Too Many Requests" });
renderLogin();
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
});
});

View file

@ -0,0 +1,106 @@
/**
* #2766 что показывает карточка дома, когда поле пришло пустым.
*
* Повод: `series_name` пуст у всех 9 457 домов на проде (#2700). Строка «Дом»
* склеивается из четырёх полей через « · », и вопрос не в том, есть ли серия,
* а в том, ЧТО видит пользователь на её месте: висящий разделитель, слово
* «null» или ничего. Здесь закреплён последний вариант.
*
* Проверка целится в текст ячейки ЦЕЛИКОМ (`toBe`, не `toContain`) именно
* хвостовой разделитель отличает «пришло пусто, нарисовали пусто» от
* «пришло пусто, нарисовали дыру», и подстрочное совпадение его не заметит.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SaleShareList } from "../SaleShareList";
import type { BuildingSaleShare } from "@/types/sale-share";
function makeBuilding(
overrides: Partial<BuildingSaleShare> = {},
): BuildingSaleShare {
return {
house_id: 1,
address: "Ленина, 1",
lat: 56.83,
lon: 60.6,
sale_share_pct: 12.5,
sale_share_pct_45d: null,
listings_45d: null,
over_100: false,
active_secondary: 8,
flat_count_effective: 64,
gar_match_method: "exact",
median_price_rub: 6_500_000,
median_price_per_m2: 145_000,
avg_days_on_market: 42,
year_built: 1979,
house_type: "panel",
total_floors: 9,
series_name: "1-464Д",
is_emergency: false,
...overrides,
};
}
function renderList(buildings: BuildingSaleShare[]) {
return render(
<SaleShareList
buildings={buildings}
sort="share_desc"
onSort={vi.fn()}
selectedHouseId={null}
hoveredHouseId={null}
onSelect={vi.fn()}
onHover={vi.fn()}
isLoading={false}
isError={false}
minPct={5}
window="now"
/>,
);
}
/** Текст последней содержательной ячейки строки — колонка «Дом». */
function houseMetaText(address: string): string {
const row = screen.getByText(address).closest("tr");
if (row === null) throw new Error(`строка с адресом ${address} не отрисована`);
// Колонки: Адрес · Доля · В продаже · Медиана · Экспозиция · Дом · Действия.
return row.cells[5].textContent ?? "";
}
describe("SaleShareList — колонка «Дом» при пустом поле", () => {
it("серия есть — попадает в строку последним элементом", () => {
renderList([makeBuilding({ series_name: "1-464Д" })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт. · 1-464Д");
});
it("series_name = null — строка без хвостового разделителя (прод-состояние #2700)", () => {
renderList([makeBuilding({ series_name: null })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
});
it("series_name = пустая строка — тоже без хвостового разделителя", () => {
renderList([makeBuilding({ series_name: "" })]);
expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
});
it("пусты все поля дома — прочерк, а не пустая ячейка и не «null»", () => {
renderList([
makeBuilding({
year_built: null,
house_type: null,
total_floors: null,
series_name: null,
}),
]);
expect(houseMetaText("Ленина, 1")).toBe("—");
});
it("пустая серия не съедает остальные поля дома", () => {
renderList([
makeBuilding({ series_name: null, house_type: null, year_built: null }),
]);
expect(houseMetaText("Ленина, 1")).toBe("9 эт.");
});
});

View file

@ -0,0 +1,150 @@
/**
* #2766 погашенное число медианного торга обязано быть ОБЪЯСНЕНО.
*
* Гейт правдоподобия (#2666 + #2672, PR #2706) сократил показываемые числа
* вдвое: 64 группы из 128 32 после порога по различным объявлениям. Бэкенд в
* этом случае обнуляет `median_discount_pct` и кладёт в
* `median_discount_explanation` текст, называющий оба числа (сколько сделок и
* сколько разных объявлений). Пользователь должен увидеть этот текст не
* пустое место (читается как поломка виджета) и не «0%» (читается как «торга
* нет»).
*
* Оба живых потребителя проверяются здесь, потому что гейт один, а мест показа
* два: карточка `/` (StreetDealsCard) и HUD `/v2` (mapHistory dkpHeader.note).
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UseQueryResult } from "@tanstack/react-query";
import { StreetDealsCard } from "../StreetDealsCard";
import { mapHistory } from "../v2/mappers";
import { useSalesVsListings } from "@/lib/trade-in-api";
import type {
AggregatedEstimate,
SalesListingPair,
SalesVsListingsResponse,
} from "@/types/trade-in";
vi.mock("@/lib/trade-in-api", () => ({
useSalesVsListings: vi.fn(),
}));
// Текст ровно в той форме, в какой его строит бэкенд для случая «мало разных
// объявлений» (app/api/v1/trade_in.py, ветка n_distinct_listings < порога).
const GATED_EXPLANATION =
"Медианный торг не показываем: сделок 50, а разных объявлений для сравнения " +
"всего 1 — такой процент говорит о цене одной конкретной квартиры, а не о " +
"торге на улице.";
function makePair(overrides: Partial<SalesListingPair> = {}): SalesListingPair {
return {
deal_id: 1,
deal_date: "2026-03-15",
deal_price_rub: 6_200_000,
deal_price_per_m2: 138_000,
deal_area_m2: 45,
deal_rooms: 2,
deal_floor: 4,
deal_address: "Белинского, 32",
listing_id: 777,
listing_source: "avito",
listing_source_url: "https://avito.ru/777",
listing_date: "2026-01-30",
listing_price_rub: 6_900_000,
listing_price_per_m2: 153_000,
listing_area_m2: 45,
days_listing_to_deal: 44,
discount_pct: -10.1,
...overrides,
};
}
function makeResponse(
overrides: Partial<SalesVsListingsResponse> = {},
): SalesVsListingsResponse {
return {
street: "Белинского",
period_months: 24,
window_days: 180,
area_tolerance: 0.15,
total_deals: 50,
deals_with_listings: 50,
linkage_rate_pct: 100,
median_discount_pct: -10.1,
median_discount_explanation: null,
data_quality: "house_linked",
pairs: [makePair(), makePair({ deal_id: 2 })],
...overrides,
};
}
/** Минимальный estimate — карточка читает из него только адрес/площадь/комнаты. */
const ESTIMATE = {
target_address: "Белинского, 32",
area_m2: 45,
rooms: 2,
} as AggregatedEstimate;
function stubQuery(data: SalesVsListingsResponse) {
vi.mocked(useSalesVsListings).mockReturnValue({
data,
isLoading: false,
isError: false,
} as UseQueryResult<SalesVsListingsResponse>);
}
describe("StreetDealsCard — погашенный медианный торг", () => {
beforeEach(() => vi.mocked(useSalesVsListings).mockReset());
it("число прошло гейт — показываем его, объяснения нет", () => {
stubQuery(makeResponse());
render(<StreetDealsCard estimate={ESTIMATE} />);
// Именно строка-шапка: тот же «10.1%» есть и в бейджах отдельных пар,
// поэтому целимся в блок linkage-hint, а не в первое совпадение на странице.
expect(
screen.getByText(/сделок имеют исторический ASK/),
).toHaveTextContent("медианный торг 10.1%");
expect(screen.queryByText(/не показываем/)).not.toBeInTheDocument();
});
it("число погашено — на его месте объяснение с обоими числами", () => {
stubQuery(
makeResponse({
median_discount_pct: null,
median_discount_explanation: GATED_EXPLANATION,
}),
);
render(<StreetDealsCard estimate={ESTIMATE} />);
expect(screen.getByText(GATED_EXPLANATION)).toBeInTheDocument();
// Ни строки «· медианный торг», ни подставленного вместо неё нуля.
expect(screen.queryByText(/· медианный торг/)).not.toBeInTheDocument();
expect(screen.queryByText(/[+]?0\.0%/)).not.toBeInTheDocument();
// Остальная карточка на месте: гаснет одно число, а не виджет целиком.
expect(screen.getByText("Сделок за 24 мес")).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
});
});
describe("mapHistory (/v2) — погашенный медианный торг", () => {
it("объяснение попадает в note, числа торга там нет", () => {
const { dkpHeader } = mapHistory(
null,
null,
makeResponse({
median_discount_pct: null,
median_discount_explanation: GATED_EXPLANATION,
}),
);
expect(dkpHeader.note).toContain(GATED_EXPLANATION);
expect(dkpHeader.note).not.toContain("медианный торг ");
});
it("прошедшее гейт число попадает в note вместо объяснения", () => {
const { dkpHeader } = mapHistory(null, null, makeResponse());
// Разделитель дробной части задаёт локаль ru-RU — знак и цифры пиним, а
// запятую/точку нет, иначе тест ловил бы сборку ICU, а не поведение гейта.
expect(dkpHeader.note).toMatch(/медианный торг 10[.,]1%/);
expect(dkpHeader.note).not.toContain("не показываем");
});
});

2
tradein-mvp/frontend/vitest-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vitest/globals" />
/// <reference types="@testing-library/jest-dom" />

View file

@ -0,0 +1,21 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Mirror tsconfig "paths": { "@/*": ["./src/*"] } so test imports like
// `@/lib/wkt` resolve the same way they do in the Next.js build.
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

View file

@ -0,0 +1,3 @@
// Registers jest-dom matchers (toBeInTheDocument, toHaveStyle, …) on Vitest's
// `expect`. Imported via vitest.config.ts `setupFiles`.
import "@testing-library/jest-dom/vitest";