From fcd6bf5cbdb213797901e3591e053e2d20d89669 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 12:45:45 +0500 Subject: [PATCH] =?UTF-8?q?test(tradein/frontend):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5,=20=D0=BF=D0=BE=D0=B3=D0=B0=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B5=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE,=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20429?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У tradein-фронта не бежало ни одной проверки поведения: лэйн гейтил типы и статический анализ, а оба молчат про то, что видит пользователь. Три компонента, ломавшиеся за последние сутки, получают по проверке. - SaleShareList: колонка «Дом» при пустой серии (#2700 — пусто у всех 9457 домов) сверяется ЦЕЛИКОМ, потому что от «нарисовали дыру» отличает ровно хвостовой разделитель, а подстрочное совпадение его не видит. - StreetDealsCard + mapHistory: погашенный гейтом медианный торг (#2666/#2672) показан объяснением, а не пустотой и не нулём. Мест показа два (карточка `/` и HUD `/v2`), гейт один — проверяются оба. - LoginPage: 429 (#2712/#2717) даёт своё сообщение. Подменяется fetch, а не loginErrorMessage — работает настоящая apiFetch и настоящий HTTPError, то есть проверяется путь от кода ответа до текста на экране. Рядом 401 и 500: без них «429 показывает текст» прошло бы и на ветке, отвечающей одинаково на всё. Каждая проверка проверена на слом: снят .filter(Boolean) → 4 красных; убран блок median_discount_explanation и его половина note → 2 красных; удалена ветка 429 → 1 красный. Инфраструктура (vitest + jsdom + testing-library) скопирована у frontend/, не изобретена. Шаг подключён в ci-tradein.yml после type-check. Refs #2766 --- .forgejo/workflows/ci-tradein.yml | 14 ++ tradein-mvp/frontend/package.json | 10 +- .../app/login/__tests__/LoginPage.test.tsx | 111 +++++++++++++ .../trade-in/__tests__/SaleShareList.test.tsx | 106 +++++++++++++ .../__tests__/StreetDealsCard.test.tsx | 150 ++++++++++++++++++ tradein-mvp/frontend/vitest-env.d.ts | 2 + tradein-mvp/frontend/vitest.config.ts | 21 +++ tradein-mvp/frontend/vitest.setup.ts | 3 + 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx create mode 100644 tradein-mvp/frontend/vitest-env.d.ts create mode 100644 tradein-mvp/frontend/vitest.config.ts create mode 100644 tradein-mvp/frontend/vitest.setup.ts diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 5b228267..9315e1a6 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -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 diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index f71653ba..ef448501 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -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" } } diff --git a/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx new file mode 100644 index 00000000..d82503dc --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx @@ -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( + + + , + ); +} + +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(), + ); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx new file mode 100644 index 00000000..c3a0aa49 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx @@ -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 { + 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( + , + ); +} + +/** Текст последней содержательной ячейки строки — колонка «Дом». */ +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 эт."); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx new file mode 100644 index 00000000..c6e8a77d --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx @@ -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 { + 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 { + 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); +} + +describe("StreetDealsCard — погашенный медианный торг", () => { + beforeEach(() => vi.mocked(useSalesVsListings).mockReset()); + + it("число прошло гейт — показываем его, объяснения нет", () => { + stubQuery(makeResponse()); + render(); + // Именно строка-шапка: тот же «−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(); + + 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("не показываем"); + }); +}); diff --git a/tradein-mvp/frontend/vitest-env.d.ts b/tradein-mvp/frontend/vitest-env.d.ts new file mode 100644 index 00000000..edfa8b53 --- /dev/null +++ b/tradein-mvp/frontend/vitest-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/tradein-mvp/frontend/vitest.config.ts b/tradein-mvp/frontend/vitest.config.ts new file mode 100644 index 00000000..0a1dea5b --- /dev/null +++ b/tradein-mvp/frontend/vitest.config.ts @@ -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}"], + }, +}); diff --git a/tradein-mvp/frontend/vitest.setup.ts b/tradein-mvp/frontend/vitest.setup.ts new file mode 100644 index 00000000..8272da97 --- /dev/null +++ b/tradein-mvp/frontend/vitest.setup.ts @@ -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"; -- 2.45.3