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:
parent
5d93f13030
commit
f612401202
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,
|
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