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
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:
parent
0de22f4bc9
commit
a034d7a1c1
8 changed files with 416 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal file
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 эт.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
2
tradein-mvp/frontend/vitest-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vitest/globals" />
|
||||
/// <reference types="@testing-library/jest-dom" />
|
||||
21
tradein-mvp/frontend/vitest.config.ts
Normal file
21
tradein-mvp/frontend/vitest.config.ts
Normal 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}"],
|
||||
},
|
||||
});
|
||||
3
tradein-mvp/frontend/vitest.setup.ts
Normal file
3
tradein-mvp/frontend/vitest.setup.ts
Normal 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";
|
||||
Loading…
Add table
Reference in a new issue