Enforce [-1, +330] schedule window redirect guard per TZ 4.1.2-R11
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: () => <div data-testid="seo-head" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/errors/ErrorPage.js", () => ({
|
||||
ErrorPage: ({ code }: { code: string }) => <div data-testid={`error-${code}`} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/flights/FlightListSkeleton.js", () => ({
|
||||
FlightListSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/features/schedule/components/ScheduleSearchPage.js",
|
||||
() => ({ ScheduleSearchPage: () => <div data-testid="schedule-search" /> }),
|
||||
);
|
||||
|
||||
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 <div data-testid="navigate" data-to={to} />;
|
||||
},
|
||||
}));
|
||||
|
||||
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(<ScheduleRoundTripSearchPage />);
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -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 <ErrorPage code="404" />;
|
||||
|
||||
const redirect =
|
||||
scheduleDateRedirect(locale, outbound.dateFrom) ??
|
||||
scheduleDateRedirect(locale, inbound.dateFrom);
|
||||
if (redirect) return <Navigate to={redirect} replace />;
|
||||
|
||||
const canonicalOrigin = getEnv().PROD_ORIGIN;
|
||||
const scheduleParams = { type: "roundtrip" as const, outbound, inbound };
|
||||
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
|
||||
|
||||
@@ -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: () => <div data-testid="seo-head" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/errors/ErrorPage.js", () => ({
|
||||
ErrorPage: ({ code }: { code: string }) => <div data-testid={`error-${code}`} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/flights/FlightListSkeleton.js", () => ({
|
||||
FlightListSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/features/schedule/components/ScheduleSearchPage.js",
|
||||
() => ({ ScheduleSearchPage: () => <div data-testid="schedule-search" /> }),
|
||||
);
|
||||
|
||||
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 <div data-testid="navigate" data-to={to} />;
|
||||
},
|
||||
}));
|
||||
|
||||
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(<ScheduleRouteSearchPage />);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <ErrorPage code="404" />;
|
||||
|
||||
const redirect = scheduleDateRedirect(locale, parsed.dateFrom);
|
||||
if (redirect) return <Navigate to={redirect} replace />;
|
||||
|
||||
const canonicalOrigin = getEnv().PROD_ORIGIN;
|
||||
const scheduleParams = { type: "route" as const, outbound: parsed };
|
||||
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
|
||||
|
||||
Reference in New Issue
Block a user