feat(mera-public): серверный слой витринных чисел лэндинга

Загрузка /api/public/mera/stats и /showcase для серверных секций лэндинга:
типы обеих ручек, честная деградация и форматирование величины вместе с
размером выборки.

Любой сбой (сервис недоступен, 500, не-JSON, пустой ответ, нет ключа) даёт
«величины нет», а не ноль: /stats отдаёт пустой набор, /showcase — null,
и ни одна из функций не бросает, чтобы отказ ручки стоил блока, а не всей
страницы. Число выходит наружу только через formatStat — вместе с sample_n
и note, поэтому потерять размер выборки по дороге в JSX нельзя.

Адрес серверного fetch — BACKEND_URL, тот же путь, которым Next уже ходит в
backend (rewrites в next.config.ts, 'internal SSR' в prod-компоуз), поэтому
без /trade-in: префикс добавляет Caddy для браузера, не бэкенд. Кэш —
revalidate 3600: пересчёт ночной, no-store жёг бы rate-limit ручки на всех
посетителей, кэш без срока показывал бы вчерашнее до деплоя.

Тесты двусторонние и фальсифицированы: подстановка нуля вместо null роняет
5 из 17, пустая витрина вместо null — 3 из 17.
This commit is contained in:
bot-backend 2026-08-29 19:43:01 +05:00
parent 67d9efbac4
commit 53328a217c
2 changed files with 382 additions and 0 deletions

View file

@ -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();
});
});

View file

@ -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 };
}