From ead18fc5e5f13df56c6e768ba9547e996b0c230a Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 17:07:24 +0300 Subject: [PATCH] Enforce [-1, +330] schedule window redirect guard per TZ 4.1.2-R11 --- src/routes/[lang]/schedule/_guards.test.ts | 49 +++++++ src/routes/[lang]/schedule/_guards.ts | 19 +++ .../[params]/[returnParams]/page.test.tsx | 137 ++++++++++++++++++ .../route/[params]/[returnParams]/page.tsx | 8 +- .../schedule/route/[params]/page.test.tsx | 116 +++++++++++++++ .../[lang]/schedule/route/[params]/page.tsx | 6 +- 6 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 src/routes/[lang]/schedule/_guards.test.ts create mode 100644 src/routes/[lang]/schedule/_guards.ts create mode 100644 src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx create mode 100644 src/routes/[lang]/schedule/route/[params]/page.test.tsx diff --git a/src/routes/[lang]/schedule/_guards.test.ts b/src/routes/[lang]/schedule/_guards.test.ts new file mode 100644 index 00000000..f3265de0 --- /dev/null +++ b/src/routes/[lang]/schedule/_guards.test.ts @@ -0,0 +1,49 @@ +/** + * Unit tests for scheduleDateRedirect guard utility. + * + * Clock frozen at 2026-05-15 (noon local). Schedule window: 2026-05-14 … 2027-04-10. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { scheduleDateRedirect } from "./_guards.js"; + +describe("scheduleDateRedirect (clock frozen 2026-05-15)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12)); // May 15 2026 noon + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns null for a date inside the window (today)", () => { + expect(scheduleDateRedirect("ru-ru", "20260515")).toBeNull(); + }); + + it("returns null for the earliest allowed date (today - 1)", () => { + expect(scheduleDateRedirect("ru-ru", "20260514")).toBeNull(); + }); + + it("returns null for the latest allowed date (today + 330)", () => { + // 2026-05-15 + 330 days = 2027-04-10 + expect(scheduleDateRedirect("ru-ru", "20270410")).toBeNull(); + }); + + it("redirects when date is too early (today - 2)", () => { + expect(scheduleDateRedirect("ru-ru", "20260513")).toBe("/ru-ru/schedule"); + }); + + it("redirects when date is too late (today + 331)", () => { + // 2026-05-15 + 331 days = 2027-04-11 + expect(scheduleDateRedirect("ru-ru", "20270411")).toBe("/ru-ru/schedule"); + }); + + it("uses the supplied locale in the redirect path", () => { + expect(scheduleDateRedirect("en-us", "20260101")).toBe("/en-us/schedule"); + }); + + it("returns null for a mid-window date (today + 100)", () => { + // 2026-05-15 + 100 = 2026-08-23 + expect(scheduleDateRedirect("ru-ru", "20260823")).toBeNull(); + }); +}); diff --git a/src/routes/[lang]/schedule/_guards.ts b/src/routes/[lang]/schedule/_guards.ts new file mode 100644 index 00000000..25d5979f --- /dev/null +++ b/src/routes/[lang]/schedule/_guards.ts @@ -0,0 +1,19 @@ +/** + * Shared date-window guard for Schedule routes per TZ §4.1.2 ¶3-4 / 4.1.2-R11. + * + * Out-of-window dates redirect to the Schedule start page instead of 404. + * Parse failures (malformed URLs) continue to produce 404 via existing logic. + */ + +import { isInScheduleWindow } from "@/shared/dateWindow.js"; + +/** + * Returns the redirect target path when the given yyyymmdd date falls outside + * the Schedule [-1, +330] day window. Returns null to allow normal render. + * + * Only called after a successful URL parse — malformed dates never reach here. + */ +export function scheduleDateRedirect(locale: string, yyyymmdd: string): string | null { + if (!isInScheduleWindow(yyyymmdd)) return `/${locale}/schedule`; + return null; +} diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx new file mode 100644 index 00000000..d5d73651 --- /dev/null +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx @@ -0,0 +1,137 @@ +/** + * Tests for the round-trip schedule route search page. + * + * Verifies that out-of-window dates (outbound OR inbound) redirect to + * /{locale}/schedule (TZ §4.1.2-R11), in-window dates render normally, + * and malformed URLs still produce 404. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +// Freeze clock: today = 2026-05-15. Schedule window: 2026-05-14 … 2027-04-10. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12)); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +// --- mocks --- + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "ru" } }), +})); + +vi.mock("@/env/index.js", () => ({ + getEnv: () => ({ PROD_ORIGIN: "https://aeroflot.ru" }), +})); + +vi.mock("@/ui/seo/SeoHead.js", () => ({ + SeoHead: () =>
, +})); + +vi.mock("@/ui/errors/ErrorPage.js", () => ({ + ErrorPage: ({ code }: { code: string }) =>
, +})); + +vi.mock("@/ui/flights/FlightListSkeleton.js", () => ({ + FlightListSkeleton: () =>
, +})); + +vi.mock( + "@/features/schedule/components/ScheduleSearchPage.js", + () => ({ ScheduleSearchPage: () =>
}), +); + +vi.mock("@/features/schedule/seo.js", () => ({ + buildScheduleSearchSeo: () => ({}), +})); + +const mockNavigate = vi.fn(); +vi.mock("@modern-js/runtime/router", () => ({ + useParams: vi.fn(), + Navigate: ({ to }: { to: string }) => { + mockNavigate(to); + return
; + }, +})); + +import { useParams } from "@modern-js/runtime/router"; +import ScheduleRoundTripSearchPage from "./page.js"; + +function renderWithParams(params: string, returnParams: string, lang = "ru-ru") { + vi.mocked(useParams).mockReturnValue({ params, returnParams, lang }); + return render(); +} + +// In-window segment: SVO-LED-20260515-20260515 (today, well within window) +// Out-of-window (future): SVO-LED-20270411-20270411 (today + 331) +// Out-of-window (past): SVO-LED-20260513-20260513 (today - 2) + +describe("ScheduleRoundTripSearchPage — date-window guard", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it("renders normally when both dates are in-window", () => { + renderWithParams( + "SVO-LED-20260515-20260515", + "LED-SVO-20260520-20260520", + ); + expect(screen.getByTestId("seo-head")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("redirects when outbound date is too late (> +330)", () => { + renderWithParams( + "SVO-LED-20270411-20270411", + "LED-SVO-20270415-20270415", + ); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + }); + + it("redirects when outbound date is too early (< -1)", () => { + renderWithParams( + "SVO-LED-20260513-20260513", + "LED-SVO-20260520-20260520", + ); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + }); + + it("redirects when inbound date is out-of-window while outbound is in-window", () => { + renderWithParams( + "SVO-LED-20260515-20260515", + "LED-SVO-20270411-20270411", + ); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + }); + + it("uses the route locale in the redirect path", () => { + renderWithParams( + "SVO-LED-20270411-20270411", + "LED-SVO-20270415-20270415", + "en-us", + ); + expect(mockNavigate).toHaveBeenCalledWith("/en-us/schedule"); + }); + + it("shows 404 for a malformed outbound URL", () => { + renderWithParams("SVO-LED", "LED-SVO-20260520-20260520"); + expect(screen.getByTestId("error-404")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("shows 404 for a malformed inbound URL", () => { + renderWithParams("SVO-LED-20260515-20260515", "LED-SVO"); + expect(screen.getByTestId("error-404")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); +}); diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx index 928fad3c..c33a3f10 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -6,7 +6,7 @@ */ import { lazy, Suspense } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useParams, Navigate } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { parseScheduleRouteParams } from "@/features/schedule/url.js"; import { buildScheduleSearchSeo } from "@/features/schedule/seo.js"; @@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; import { getEnv } from "@/env/index.js"; +import { scheduleDateRedirect } from "../../../_guards.js"; const ScheduleSearchPage = lazy(() => import("@/features/schedule/components/ScheduleSearchPage.js").then( @@ -33,6 +34,11 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element { if (!outbound || !inbound) return ; + const redirect = + scheduleDateRedirect(locale, outbound.dateFrom) ?? + scheduleDateRedirect(locale, inbound.dateFrom); + if (redirect) return ; + const canonicalOrigin = getEnv().PROD_ORIGIN; const scheduleParams = { type: "roundtrip" as const, outbound, inbound }; const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin); diff --git a/src/routes/[lang]/schedule/route/[params]/page.test.tsx b/src/routes/[lang]/schedule/route/[params]/page.test.tsx new file mode 100644 index 00000000..c4e6b755 --- /dev/null +++ b/src/routes/[lang]/schedule/route/[params]/page.test.tsx @@ -0,0 +1,116 @@ +/** + * Tests for the one-way schedule route search page. + * + * Verifies that out-of-window dates redirect to /{locale}/schedule (TZ §4.1.2-R11), + * in-window dates render normally, and malformed URLs still produce 404. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +// Freeze clock: today = 2026-05-15. Schedule window: 2026-05-14 … 2027-04-10. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 15, 12)); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +// --- mocks --- + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "ru" } }), +})); + +vi.mock("@/env/index.js", () => ({ + getEnv: () => ({ PROD_ORIGIN: "https://aeroflot.ru" }), +})); + +vi.mock("@/ui/seo/SeoHead.js", () => ({ + SeoHead: () =>
, +})); + +vi.mock("@/ui/errors/ErrorPage.js", () => ({ + ErrorPage: ({ code }: { code: string }) =>
, +})); + +vi.mock("@/ui/flights/FlightListSkeleton.js", () => ({ + FlightListSkeleton: () =>
, +})); + +vi.mock( + "@/features/schedule/components/ScheduleSearchPage.js", + () => ({ ScheduleSearchPage: () =>
}), +); + +vi.mock("@/features/schedule/seo.js", () => ({ + buildScheduleSearchSeo: () => ({}), +})); + +const mockNavigate = vi.fn(); +vi.mock("@modern-js/runtime/router", () => ({ + useParams: vi.fn(), + Navigate: ({ to }: { to: string }) => { + mockNavigate(to); + return
; + }, +})); + +import { useParams } from "@modern-js/runtime/router"; +import ScheduleRouteSearchPage from "./page.js"; + +function renderWithParams(params: string, lang = "ru-ru") { + vi.mocked(useParams).mockReturnValue({ params, lang }); + return render(); +} + +describe("ScheduleRouteSearchPage — date-window guard", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it("renders normally for an in-window date (today)", () => { + renderWithParams("SVO-LED-20260515-20260515"); + expect(screen.getByTestId("seo-head")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("renders normally for the earliest allowed date (today - 1)", () => { + renderWithParams("SVO-LED-20260514-20260514"); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("renders normally for the latest allowed date (today + 330)", () => { + // 2026-05-15 + 330 = 2027-04-10 + renderWithParams("SVO-LED-20270410-20270410"); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); + + it("redirects when date is too late (today + 331)", () => { + // 2026-05-15 + 331 = 2027-04-11 + renderWithParams("SVO-LED-20270411-20270411"); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + }); + + it("redirects when date is too early (today - 2)", () => { + renderWithParams("SVO-LED-20260513-20260513"); + expect(screen.getByTestId("navigate")).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + }); + + it("uses the route locale in the redirect path", () => { + renderWithParams("SVO-LED-20270411-20270411", "en-us"); + expect(mockNavigate).toHaveBeenCalledWith("/en-us/schedule"); + }); + + it("shows 404 for a malformed URL (missing date)", () => { + renderWithParams("SVO-LED"); + expect(screen.getByTestId("error-404")).toBeTruthy(); + expect(screen.queryByTestId("navigate")).toBeNull(); + }); +}); diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx index d8e6ec89..d505acd2 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -6,7 +6,7 @@ */ import { lazy, Suspense } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useParams, Navigate } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { parseScheduleRouteParams } from "@/features/schedule/url.js"; import { buildScheduleSearchSeo } from "@/features/schedule/seo.js"; @@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; import { getEnv } from "@/env/index.js"; +import { scheduleDateRedirect } from "../../_guards.js"; const ScheduleSearchPage = lazy(() => import("@/features/schedule/components/ScheduleSearchPage.js").then( @@ -30,6 +31,9 @@ export default function ScheduleRouteSearchPage(): JSX.Element { if (!parsed) return ; + const redirect = scheduleDateRedirect(locale, parsed.dateFrom); + if (redirect) return ; + const canonicalOrigin = getEnv().PROD_ORIGIN; const scheduleParams = { type: "route" as const, outbound: parsed }; const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);