diff --git a/backend/package.json b/backend/package.json index 3ea74e5..642ef78 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.0.1", + "version": "1.1.0", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/app.ts b/backend/src/app.ts index fa620b8..de1b070 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,7 +7,9 @@ import racesRouter from "./routes/races"; export function createApp(): express.Express { const app = express(); - app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] })); + app.use( + cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }), + ); app.use(express.json()); app.use(healthRouter); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 9e6edb1..2087d09 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -8,6 +8,11 @@ router.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok", version: getBackendVersion() }); }); +/** Версия для UI; путь без «health», чтобы реже резался фильтрами/прокси. */ +router.get("/meta", (_req: Request, res: Response) => { + res.json({ version: getBackendVersion() }); +}); + router.get("/ready", async (_req: Request, res: Response) => { const dbOk = await checkDbConnection(); if (dbOk) { diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 3fdedeb..694b1aa 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -17,6 +17,18 @@ test("GET /api/health returns ok (prefix without proxy strip)", async () => { assert.equal(res.body.status, "ok"); }); +test("GET /meta returns version for UI footer", async () => { + const res = await request(app).get("/meta").expect(200); + assert.equal(typeof res.body.version, "string"); + assert.ok(res.body.version.length > 0); +}); + +test("GET /api/meta mirrors GET /meta", async () => { + const res = await request(app).get("/api/meta").expect(200); + assert.equal(typeof res.body.version, "string"); + assert.ok(res.body.version.length > 0); +}); + test("GET /ready succeeds with mock database", async () => { const res = await request(app).get("/ready").expect(200); assert.equal(res.body.status, "ready"); diff --git a/frontend/package.json b/frontend/package.json index e52b06d..2919e45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.1.1", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/health.ts b/frontend/src/api/health.ts index a055ef7..978f5d6 100644 --- a/frontend/src/api/health.ts +++ b/frontend/src/api/health.ts @@ -5,6 +5,15 @@ export type HealthResponse = { version: string; }; +export type BackendMetaResponse = { + version: string; +}; + export async function getHealth(init?: RequestInit): Promise { return requestJson("/health", init); } + +/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */ +export async function getBackendMeta(init?: RequestInit): Promise { + return requestJson("/meta", init); +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index bbc99d4..27b1b13 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -8,16 +8,30 @@ function buildUrl(path: string): string { } async function parseResponseBody(response: Response): Promise { - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.includes("application/json")) { + const text = await response.text(); + if (!text.trim()) { return null; } - try { - return await response.json(); - } catch { - return null; + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } } + + const trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } + } + + return null; } const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 5f73a56..1cf165f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,5 +1,5 @@ export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export { ApiError, getApiErrorMessage } from "./errors"; -export type { HealthResponse } from "./health"; -export { getHealth } from "./health"; +export type { BackendMetaResponse, HealthResponse } from "./health"; +export { getBackendMeta, getHealth } from "./health"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; diff --git a/frontend/src/app/layouts/AppShellFooter.tsx b/frontend/src/app/layouts/AppShellFooter.tsx index d047480..38858e9 100644 --- a/frontend/src/app/layouts/AppShellFooter.tsx +++ b/frontend/src/app/layouts/AppShellFooter.tsx @@ -1,22 +1,29 @@ import { useEffect, useState } from "react"; -import { getHealth } from "../../api"; +import { getBackendMeta } from "../../api"; import { FRONTEND_VERSION } from "../../frontendVersion"; +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + export function AppShellFooter(): JSX.Element { const [backendVersion, setBackendVersion] = useState(null); useEffect(() => { const ac = new AbortController(); - void getHealth({ signal: ac.signal }) - .then((h) => { + void getBackendMeta({ signal: ac.signal }) + .then((meta) => { if (ac.signal.aborted) { return; } - const v = h.version; + const v = meta.version; setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана"); }) - .catch(() => { - if (ac.signal.aborted) { + .catch((err) => { + if (ac.signal.aborted || isAbortError(err)) { return; } setBackendVersion("недоступна");