diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts new file mode 100644 index 00000000..86c63e22 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts @@ -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) { + 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): 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(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/public-api.ts b/tradein-mvp/frontend/src/app/mera-public/public-api.ts index 84651541..48236aea 100644 --- a/tradein-mvp/frontend/src/app/mera-public/public-api.ts +++ b/tradein-mvp/frontend/src/app/mera-public/public-api.ts @@ -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>; + +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(path: string): Promise { + 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 { + const data = await fetchOrNull("/stats"); + return data !== null && typeof data === "object" ? data : {}; +} + +/** + * Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не + * отработал, строк нет) — секция не рендерится. Пустой массив отдельным + * состоянием не является: «витрина из нуля строк» на странице не отличима от + * поломки, а `stats` без строк подписывать нечего. + */ +export async function fetchShowcase(): Promise { + const data = await fetchOrNull("/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 }; +}