Compare commits
1 commit
main
...
feat/b2c-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 53328a217c |
2 changed files with 382 additions and 0 deletions
|
|
@ -0,0 +1,205 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
fetchLandingStats,
|
||||
fetchShowcase,
|
||||
formatStat,
|
||||
type LandingStat,
|
||||
type LandingStats,
|
||||
} from "../public-api";
|
||||
|
||||
/**
|
||||
* Гейт честной деградации серверного слоя витрины.
|
||||
*
|
||||
* Проверяется не «функция что-то вернула», а ровно то, ради чего слой написан:
|
||||
* при любом сбое на витрину не попадает НИ ОДНОГО числа, которого никто не
|
||||
* измерял. Кейсы двусторонние — рядом с провальным входом стоит успешный,
|
||||
* иначе тест зеленел бы и на функции `() => null`.
|
||||
*
|
||||
* Фальсификация (проделана вручную 29.08.2026): вернуть из `formatStat` при
|
||||
* отсутствии величины `{ text: "0", sample: null, ... }` вместо `null` —
|
||||
* краснеют «ключа нет», «пустой ответ», «сеть легла», «500»; вернуть из
|
||||
* `fetchLandingStats` при сбое заглушку с нулями — краснеют те же четыре.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Intl.NumberFormat("ru-RU") разделяет разряды НЕРАЗРЫВНЫМ пробелом, и наша
|
||||
* единица приклеивается таким же — сравнивать с обычным пробелом в литерале
|
||||
* нельзя. Нормализуем, чтобы ожидания в тесте читались глазами.
|
||||
*/
|
||||
const norm = (s: string | null | undefined) => s?.replace(/[ ]/g, " ").replace(/−/g, "-");
|
||||
|
||||
const OK: LandingStats = {
|
||||
estimates_total: {
|
||||
value: 1123,
|
||||
sample_n: 1123,
|
||||
note: "оценок за период",
|
||||
computed_at: "2026-08-29T03:00:00+00:00",
|
||||
},
|
||||
};
|
||||
|
||||
function mockFetch(impl: () => Promise<unknown>) {
|
||||
vi.stubGlobal("fetch", vi.fn(impl));
|
||||
}
|
||||
|
||||
/** Ответ ручки с произвольным статусом и телом. */
|
||||
function response(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("fetchLandingStats: сбой не превращается в число", () => {
|
||||
it("исправная ручка отдаёт метрику вместе с выборкой", async () => {
|
||||
mockFetch(async () => response(200, OK));
|
||||
const out = formatStat(await fetchLandingStats(), "estimates_total");
|
||||
expect(norm(out?.text)).toBe("1 123");
|
||||
expect(norm(out?.sample)).toBe("n = 1 123");
|
||||
expect(out?.note).toBe("оценок за период");
|
||||
expect(out?.computedAt).toBe("2026-08-29T03:00:00+00:00");
|
||||
});
|
||||
|
||||
it("пустой объект — величины нет, а не ноль", async () => {
|
||||
mockFetch(async () => response(200, {}));
|
||||
const stats = await fetchLandingStats();
|
||||
expect(stats).toEqual({});
|
||||
expect(formatStat(stats, "estimates_total")).toBeNull();
|
||||
});
|
||||
|
||||
it("нужного ключа в ответе нет — величины нет", async () => {
|
||||
mockFetch(async () => response(200, OK));
|
||||
expect(formatStat(await fetchLandingStats(), "deals_total_12m")).toBeNull();
|
||||
});
|
||||
|
||||
it("сеть легла — пустой набор, а не подстановка", async () => {
|
||||
mockFetch(() => Promise.reject(new TypeError("fetch failed")));
|
||||
const stats = await fetchLandingStats();
|
||||
expect(stats).toEqual({});
|
||||
expect(formatStat(stats, "estimates_total")).toBeNull();
|
||||
});
|
||||
|
||||
it("500 — пустой набор, а не подстановка", async () => {
|
||||
mockFetch(async () => response(500, { detail: "boom" }));
|
||||
const stats = await fetchLandingStats();
|
||||
expect(stats).toEqual({});
|
||||
expect(formatStat(stats, "estimates_total")).toBeNull();
|
||||
});
|
||||
|
||||
it("200 с не-JSON телом — пустой набор", async () => {
|
||||
mockFetch(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token < in JSON");
|
||||
},
|
||||
}) as unknown as Response,
|
||||
);
|
||||
expect(await fetchLandingStats()).toEqual({});
|
||||
});
|
||||
|
||||
it("страница не падает: функция не бросает ни на одном виде сбоя", async () => {
|
||||
const failures = [
|
||||
() => Promise.reject(new TypeError("fetch failed")),
|
||||
async () => response(503, null),
|
||||
async () => response(200, null),
|
||||
];
|
||||
for (const impl of failures) {
|
||||
mockFetch(impl);
|
||||
await expect(fetchLandingStats()).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStat: n нельзя потерять, ноль нельзя выдумать", () => {
|
||||
const stat = (over: Partial<LandingStat>): LandingStats => ({
|
||||
m: {
|
||||
value: 48.1,
|
||||
sample_n: 6276,
|
||||
note: null,
|
||||
computed_at: "2026-08-29T03:00:00+00:00",
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
it("число едет вместе с подписью выборки", () => {
|
||||
const out = formatStat(stat({}), "m", { unit: "%", digits: 1, sampleWord: "объявлениям" });
|
||||
expect(norm(out?.text)).toBe("48,1 %");
|
||||
expect(norm(out?.sample)).toBe("по 6 276 объявлениям");
|
||||
});
|
||||
|
||||
it("отрицательное значение не теряет знак", () => {
|
||||
const out = formatStat(stat({ value: -2.17, sample_n: 3018 }), "m", { digits: 2 });
|
||||
expect(norm(out?.text)).toBe("-2,17");
|
||||
expect(norm(out?.sample)).toBe("n = 3 018");
|
||||
});
|
||||
|
||||
it("value = null — величины нет", () => {
|
||||
expect(formatStat(stat({ value: null }), "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("пустая строка — величины нет", () => {
|
||||
expect(formatStat(stat({ value: " " }), "m")).toBeNull();
|
||||
});
|
||||
|
||||
it("sample_n = null — величина есть, подписи нет (но не выдуманный ноль)", () => {
|
||||
const out = formatStat(stat({ sample_n: null }), "m");
|
||||
expect(norm(out?.text)).toBe("48,1");
|
||||
expect(out?.sample).toBeNull();
|
||||
});
|
||||
|
||||
it("ноль как ИЗМЕРЕННАЯ величина показывается — это не то же, что отсутствие", () => {
|
||||
expect(norm(formatStat(stat({ value: 0, sample_n: 12 }), "m")?.text)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchShowcase: нечего показать — секции нет", () => {
|
||||
const SHOWCASE = {
|
||||
computed_at: "2026-08-29T03:00:00+00:00",
|
||||
deals: [
|
||||
{
|
||||
district: "Пионерский",
|
||||
rooms: 2,
|
||||
area_m2: 54.3,
|
||||
floor: 5,
|
||||
total_floors: 9,
|
||||
deal_quarter: "II квартал 2026",
|
||||
predicted_rub: 7_100_000,
|
||||
fact_rub: 7_250_000,
|
||||
err_pct: -2.1,
|
||||
n_analogs: 14,
|
||||
note: "",
|
||||
},
|
||||
],
|
||||
stats: null,
|
||||
};
|
||||
|
||||
it("строки есть — витрина едет целиком", async () => {
|
||||
mockFetch(async () => response(200, SHOWCASE));
|
||||
const out = await fetchShowcase();
|
||||
expect(out?.deals).toHaveLength(1);
|
||||
expect(out?.deals[0].district).toBe("Пионерский");
|
||||
});
|
||||
|
||||
it("пустой список — null, а не витрина из нуля строк", async () => {
|
||||
mockFetch(async () => response(200, { computed_at: null, deals: [], stats: null }));
|
||||
expect(await fetchShowcase()).toBeNull();
|
||||
});
|
||||
|
||||
it("500 — null", async () => {
|
||||
mockFetch(async () => response(500, {}));
|
||||
expect(await fetchShowcase()).toBeNull();
|
||||
});
|
||||
|
||||
it("сеть легла — null, без исключения", async () => {
|
||||
mockFetch(() => Promise.reject(new TypeError("fetch failed")));
|
||||
await expect(fetchShowcase()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -148,3 +148,180 @@ export async function fetchCoverage(
|
|||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* СЕРВЕРНАЯ ЧАСТЬ: витринные числа лэндинга.
|
||||
*
|
||||
* Всё выше — браузерное (форма оценки). Всё ниже вызывается ТОЛЬКО из
|
||||
* серверных компонентов, поэтому здесь нет "use client" и нет пользовательских
|
||||
* AbortSignal'ов.
|
||||
*
|
||||
* ПОЧЕМУ АДРЕС ДРУГОЙ, ЧЕМ У ФОРМЫ. `fetch` на сервере не знает origin'а —
|
||||
* относительный `/trade-in/api/...` там просто не резолвится. В дереве это уже
|
||||
* решено ровно одним способом: `BACKEND_URL` (next.config.ts rewrites,
|
||||
* `tradein-mvp/docker-compose.prod.yml:471` — комментарий там дословно
|
||||
* «internal SSR»). Тот же env, тот же адрес контейнера: Next ходит в backend
|
||||
* напрямую, минуя Caddy, поэтому префикса `/trade-in` здесь нет — его
|
||||
* добавляет Caddy для браузера, а не бэкенд (роутер примонтирован на
|
||||
* `/api/public/mera`, backend/app/main.py:298).
|
||||
*
|
||||
* КЭШ — `revalidate: 3600`. Числа пересчитывает ночная задача раз в сутки
|
||||
* (app/tasks/landing_stats.py), поэтому `no-store` (запрос на каждый заход
|
||||
* анонима) слал бы бэкенду одинаковые вопросы и упирался бы в его же лимит
|
||||
* 30/окно — на всех посетителей сразу, включая тех, кому нужна форма.
|
||||
* Кэш без срока — другая крайность: пересчёт прошёл, а витрина показывает
|
||||
* вчерашнее до следующего деплоя. Час — это и потолок отставания от ночного
|
||||
* пересчёта, и, что важнее, потолок времени, на который залипает НЕУДАЧА:
|
||||
* Data Cache Next'а статусы не разбирает, так что промах тоже живёт час, но
|
||||
* не дольше.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const SSR_API_BASE = `${process.env.BACKEND_URL ?? "http://localhost:8000"}/api/public/mera`;
|
||||
|
||||
/** Одна витринная величина. Зеркалит `LandingStat` бэкенда. */
|
||||
export interface LandingStat {
|
||||
value: number | string | null;
|
||||
sample_n: number | null;
|
||||
note: string | null;
|
||||
computed_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ `/stats`. Отсутствие ключа — это отсутствие ВЕЛИЧИНЫ: бэкенд не пишет
|
||||
* строку, когда мерить нечего. Ноль на её месте был бы утверждением, которого
|
||||
* никто не измерял, поэтому тип и допускает `undefined` — читать значения
|
||||
* напрямую нельзя, только через `formatStat`.
|
||||
*/
|
||||
export type LandingStats = Readonly<Record<string, LandingStat | undefined>>;
|
||||
|
||||
export interface ShowcaseDeal {
|
||||
district: string | null;
|
||||
rooms: number;
|
||||
area_m2: number;
|
||||
floor: number | null;
|
||||
total_floors: number | null;
|
||||
deal_quarter: string;
|
||||
predicted_rub: number;
|
||||
fact_rub: number;
|
||||
err_pct: number;
|
||||
n_analogs: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
export interface ShowcaseStats {
|
||||
considered: number;
|
||||
priced: number;
|
||||
no_prediction: number;
|
||||
incomplete: number;
|
||||
eligible: number;
|
||||
written: number;
|
||||
with_district: number;
|
||||
rejection_rule: string;
|
||||
}
|
||||
|
||||
export interface ShowcaseResponse {
|
||||
computed_at: string | null;
|
||||
deals: ShowcaseDeal[];
|
||||
stats: ShowcaseStats | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET, который НЕ бросает. Любой сбой — недоступный сервис, 500, не-JSON —
|
||||
* это `null`. Альтернатива (проброс исключения) уронила бы серверный рендер
|
||||
* целиком: посетитель получил бы 500 вместо страницы без одного блока.
|
||||
*/
|
||||
async function fetchOrNull<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${SSR_API_BASE}${path}`, {
|
||||
// Таймаут, а не бесконечное ожидание: висящий бэкенд иначе держит рендер
|
||||
// страницы, и «блок не показался» превращается в «сайт не открывается».
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Витринные метрики. Недоступная ручка = пустой набор, а не набор нулей:
|
||||
* дальше каждый ключ одинаково честно отвечает «величины нет».
|
||||
*/
|
||||
export async function fetchLandingStats(): Promise<LandingStats> {
|
||||
const data = await fetchOrNull<LandingStats>("/stats");
|
||||
return data !== null && typeof data === "object" ? data : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не
|
||||
* отработал, строк нет) — секция не рендерится. Пустой массив отдельным
|
||||
* состоянием не является: «витрина из нуля строк» на странице не отличима от
|
||||
* поломки, а `stats` без строк подписывать нечего.
|
||||
*/
|
||||
export async function fetchShowcase(): Promise<ShowcaseResponse | null> {
|
||||
const data = await fetchOrNull<ShowcaseResponse>("/showcase");
|
||||
if (data === null || !Array.isArray(data.deals) || data.deals.length === 0) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Величина, готовая к выводу. Число и размер выборки едут ОДНИМ объектом
|
||||
* намеренно: другого способа получить отформатированное число нет, поэтому
|
||||
* потерять n по дороге в JSX нельзя — можно только осознанно не отрисовать
|
||||
* поле, и это будет видно в разметке.
|
||||
*/
|
||||
export interface FormattedStat {
|
||||
/** Значение с единицей, если её передали: «1 123», «48,1 %». */
|
||||
text: string;
|
||||
/** Подпись выборки — «по 1 123 оценкам». null, если бэкенд не прислал n. */
|
||||
sample: string | null;
|
||||
/** Что именно измерено — формулировкой бэкенда, не нашей. */
|
||||
note: string | null;
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
const ru = (digits: number) => new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits });
|
||||
|
||||
/**
|
||||
* Достаёт метрику по ключу. `null` — во ВСЕХ случаях, когда числа нет: ключа
|
||||
* не прислали, `value` пустой, ручка не ответила. Ни ноля, ни прочерка, ни
|
||||
* последнего известного значения — блок вызывающего просто не рендерится.
|
||||
*
|
||||
* @param unit единица, приклеиваемая к числу («%», «дн.») — через неразрывный
|
||||
* пробел, чтобы не отрывалась переносом строки.
|
||||
* @param sampleWord слово подписи выборки («оценкам», «объявлениям»); без него
|
||||
* подпись всё равно будет, но безымянной — «n = 1 123».
|
||||
*/
|
||||
export function formatStat(
|
||||
stats: LandingStats,
|
||||
key: string,
|
||||
{ unit, digits = 2, sampleWord }: { unit?: string; digits?: number; sampleWord?: string } = {},
|
||||
): FormattedStat | null {
|
||||
const stat = stats[key];
|
||||
if (!stat || stat.value === null || stat.value === undefined) return null;
|
||||
|
||||
let text: string;
|
||||
if (typeof stat.value === "number") {
|
||||
if (!Number.isFinite(stat.value)) return null;
|
||||
text = ru(digits).format(stat.value);
|
||||
} else if (typeof stat.value === "string") {
|
||||
if (stat.value.trim() === "") return null;
|
||||
text = stat.value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (unit) text = `${text} ${unit}`;
|
||||
|
||||
const n = stat.sample_n;
|
||||
const sample =
|
||||
typeof n === "number" && Number.isFinite(n)
|
||||
? sampleWord
|
||||
? `по ${ru(0).format(n)} ${sampleWord}`
|
||||
: `n = ${ru(0).format(n)}`
|
||||
: null;
|
||||
|
||||
return { text, sample, note: stat.note ?? null, computedAt: stat.computed_at };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue