Add 60 integration tests for Online Board feature

6 test files covering start page, flight search, flight details,
URL round-tripping, error handling, and SEO output verification.
All tests use mocked API and SignalR layers with jsdom environment.
This commit is contained in:
2026-04-15 08:55:24 +03:00
parent 5bcf23ee4e
commit 008bc3339c
7 changed files with 1342 additions and 0 deletions
@@ -0,0 +1,196 @@
/**
* Integration tests for error handling in the Online Board.
*
* Verifies error UI renders correctly for API errors (404, 500, timeout)
* and that retry triggers re-fetch.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OnlineBoardSearchPage } from "@/features/online-board/components/OnlineBoardSearchPage.js";
import { OnlineBoardDetailsPage } from "@/features/online-board/components/OnlineBoardDetailsPage.js";
import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js";
import type { IParsedFlightId } from "@/features/online-board/types.js";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "ru" },
}),
}));
const mockUseOnlineBoard = vi.fn();
vi.mock("@/features/online-board/hooks/useOnlineBoard.js", () => ({
useOnlineBoard: (...args: unknown[]) => mockUseOnlineBoard(...args),
}));
vi.mock("@/features/online-board/hooks/useLiveBoardSearch.js", () => ({
useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "idle" as const,
}),
}));
vi.mock("@/features/online-board/hooks/useCalendarDays.js", () => ({
useCalendarDays: () => ({
days: [],
loading: false,
}),
}));
const mockUseFlightDetails = vi.fn();
vi.mock("@/features/online-board/hooks/useFlightDetails.js", () => ({
useFlightDetails: (...args: unknown[]) => mockUseFlightDetails(...args),
}));
vi.mock("@/features/online-board/hooks/useLiveFlightDetails.js", () => ({
useLiveFlightDetails: () => ({
flight: null,
connectionStatus: "idle" as const,
}),
}));
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEPARTURE_PARAMS = {
type: "departure" as const,
station: "SVO",
date: "20250115",
};
const FLIGHT_ID: IParsedFlightId = {
carrier: "SU",
flightNumber: "0100",
date: "20250115",
};
// ---------------------------------------------------------------------------
// Tests — Search page errors
// ---------------------------------------------------------------------------
describe("Search page error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders error UI for HTTP 500", () => {
const refreshSpy = vi.fn();
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: new ApiHttpError("HTTP 500 from /board", 500),
refresh: refreshSpy,
});
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByTestId("search-error")).toBeTruthy();
expect(screen.getByText(/Failed to load flights/)).toBeTruthy();
});
it("renders error UI for HTTP 404", () => {
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: new ApiHttpError("HTTP 404", 404),
refresh: vi.fn(),
});
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByTestId("search-error")).toBeTruthy();
});
it("renders error UI for timeout", () => {
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: new ApiTimeoutError(5000),
refresh: vi.fn(),
});
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByTestId("search-error")).toBeTruthy();
});
it("renders retry button in error state", () => {
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: new ApiHttpError("HTTP 500", 500),
refresh: vi.fn(),
});
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByText("Retry")).toBeTruthy();
});
it("calls refresh when retry button is clicked", () => {
const refreshSpy = vi.fn();
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: new ApiHttpError("HTTP 500", 500),
refresh: refreshSpy,
});
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
fireEvent.click(screen.getByText("Retry"));
expect(refreshSpy).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// Tests — Details page errors
// ---------------------------------------------------------------------------
describe("Details page error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders error state for details API failure", () => {
mockUseFlightDetails.mockReturnValue({
flight: null,
loading: false,
error: new ApiHttpError("HTTP 500", 500),
});
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
});
it("renders not-found when details returns null", () => {
mockUseFlightDetails.mockReturnValue({
flight: null,
loading: false,
error: null,
});
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* Shared test fixtures for Online Board integration tests.
*
* Provides realistic mock data matching the API response shapes
* defined in src/features/online-board/types.ts.
*/
import type {
ISimpleFlight,
IDirectFlight,
IMultiLegFlight,
IFlightLeg,
IBoardResponse,
} from "@/features/online-board/types.js";
// ---------------------------------------------------------------------------
// Flight leg builder
// ---------------------------------------------------------------------------
function makeLeg(overrides: Partial<IFlightLeg> & { index: number }): IFlightLeg {
return {
index: overrides.index,
status: overrides.status ?? "Scheduled",
flyingTime: overrides.flyingTime ?? "03:30",
dayChange: overrides.dayChange ?? 0,
updated: overrides.updated ?? "2025-01-15T10:00:00Z",
equipment: overrides.equipment ?? { name: "Boeing 777-300ER", code: "77W" },
flags: overrides.flags ?? {
checkinAvailable: true,
returnToAirport: false,
routeChanged: false,
},
operatingBy: overrides.operatingBy ?? { carrier: "SU", flightNumber: "100" },
departure: overrides.departure ?? {
scheduled: {
airport: "Sheremetyevo",
airportCode: "SVO",
city: "Moscow",
cityCode: "MOW",
countryCode: "RU",
},
checkingStatus: "Open",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "10:00",
localTime: "10:00:00",
tzOffset: 3,
utc: "2025-01-15T07:00:00Z",
},
},
},
arrival: overrides.arrival ?? {
scheduled: {
airport: "John F. Kennedy",
airportCode: "JFK",
city: "New York",
cityCode: "NYC",
countryCode: "US",
},
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: "13:30",
localTime: "13:30:00",
tzOffset: -5,
utc: "2025-01-15T18:30:00Z",
},
},
},
};
}
// ---------------------------------------------------------------------------
// Flight fixtures
// ---------------------------------------------------------------------------
export const DIRECT_FLIGHT: IDirectFlight = {
id: "SU100-20250115",
flightId: {
carrier: "SU",
flightNumber: "0100",
suffix: "",
date: "20250115",
},
routeType: "Direct",
status: "Scheduled",
flyingTime: "10:30",
operatingBy: { carrier: "SU", flightNumber: "100" },
leg: makeLeg({ index: 0 }),
};
export const DIRECT_FLIGHT_2: IDirectFlight = {
id: "SU200-20250115",
flightId: {
carrier: "SU",
flightNumber: "0200",
suffix: "",
date: "20250115",
},
routeType: "Direct",
status: "InFlight",
flyingTime: "04:15",
operatingBy: { carrier: "SU", flightNumber: "200" },
leg: makeLeg({
index: 0,
status: "InFlight",
departure: {
scheduled: {
airport: "Sheremetyevo",
airportCode: "SVO",
city: "Moscow",
cityCode: "MOW",
countryCode: "RU",
},
checkingStatus: "Closed",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "08:00",
localTime: "08:00:00",
tzOffset: 3,
utc: "2025-01-15T05:00:00Z",
},
},
},
arrival: {
scheduled: {
airport: "Pulkovo",
airportCode: "LED",
city: "Saint Petersburg",
cityCode: "LED",
countryCode: "RU",
},
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: "09:30",
localTime: "09:30:00",
tzOffset: 3,
utc: "2025-01-15T06:30:00Z",
},
},
},
}),
};
export const MULTI_LEG_FLIGHT: IMultiLegFlight = {
id: "SU300-20250115",
flightId: {
carrier: "SU",
flightNumber: "0300",
suffix: "",
date: "20250115",
},
routeType: "MultiLeg",
status: "Scheduled",
flyingTime: "14:00",
operatingBy: { carrier: "SU", flightNumber: "300" },
legs: [
makeLeg({ index: 0 }),
makeLeg({
index: 1,
departure: {
scheduled: {
airport: "John F. Kennedy",
airportCode: "JFK",
city: "New York",
cityCode: "NYC",
countryCode: "US",
},
checkingStatus: "Open",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "16:00",
localTime: "16:00:00",
tzOffset: -5,
utc: "2025-01-15T21:00:00Z",
},
},
},
arrival: {
scheduled: {
airport: "Heathrow",
airportCode: "LHR",
city: "London",
cityCode: "LON",
countryCode: "GB",
},
times: {
scheduledArrival: {
dayChange: { value: 1, title: "+1" },
local: "06:00",
localTime: "06:00:00",
tzOffset: 0,
utc: "2025-01-16T06:00:00Z",
},
},
},
}),
],
};
export const ALL_FLIGHTS: ISimpleFlight[] = [
DIRECT_FLIGHT,
DIRECT_FLIGHT_2,
MULTI_LEG_FLIGHT,
];
// ---------------------------------------------------------------------------
// API response fixtures
// ---------------------------------------------------------------------------
export const BOARD_RESPONSE: IBoardResponse = {
data: {
partners: ["SU", "AF"],
routes: ALL_FLIGHTS,
daysOfFlight: ["2025-01-14", "2025-01-15", "2025-01-16"],
},
};
export const EMPTY_BOARD_RESPONSE: IBoardResponse = {
data: {
partners: [],
routes: [],
daysOfFlight: [],
},
};
export const SINGLE_FLIGHT_RESPONSE: IBoardResponse = {
data: {
partners: ["SU"],
routes: [DIRECT_FLIGHT],
daysOfFlight: ["2025-01-15"],
},
};
export const CALENDAR_DAYS = ["2025-01-14", "2025-01-15", "2025-01-16", "2025-01-17"];
@@ -0,0 +1,221 @@
/**
* Integration tests for the Online Board flight details page.
*
* Verifies flight info renders with mocked API data including
* flight number, status, legs, operating carrier, and SEO head.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { OnlineBoardDetailsPage } from "@/features/online-board/components/OnlineBoardDetailsPage.js";
import type { IParsedFlightId } from "@/features/online-board/types.js";
import { DIRECT_FLIGHT, MULTI_LEG_FLIGHT } from "./fixtures.js";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "ru" },
}),
}));
const mockUseFlightDetails = vi.fn();
vi.mock("@/features/online-board/hooks/useFlightDetails.js", () => ({
useFlightDetails: (...args: unknown[]) => mockUseFlightDetails(...args),
}));
const mockUseLiveFlightDetails = vi.fn();
vi.mock("@/features/online-board/hooks/useLiveFlightDetails.js", () => ({
useLiveFlightDetails: (...args: unknown[]) => mockUseLiveFlightDetails(...args),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const FLIGHT_ID: IParsedFlightId = {
carrier: "SU",
flightNumber: "0100",
date: "20250115",
};
function setupWithFlight(flight = DIRECT_FLIGHT) {
mockUseFlightDetails.mockReturnValue({
flight,
loading: false,
error: null,
});
mockUseLiveFlightDetails.mockReturnValue({
flight,
connectionStatus: "idle" as const,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Flight details page integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders flight details container", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-details")).toBeTruthy();
});
it("renders flight number heading", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
// Flight number appears in both heading and FlightCard
expect(screen.getAllByText("SU 0100").length).toBeGreaterThanOrEqual(1);
});
it("renders flight status", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
// Status appears in both overall status and leg status
expect(screen.getAllByText("Scheduled").length).toBeGreaterThanOrEqual(1);
});
it("renders flight legs for direct flight", () => {
setupWithFlight(DIRECT_FLIGHT);
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-legs")).toBeTruthy();
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
});
it("renders multiple legs for multi-leg flight", () => {
setupWithFlight(MULTI_LEG_FLIGHT);
render(
<OnlineBoardDetailsPage
flightId={{
carrier: "SU",
flightNumber: "0300",
date: "20250115",
}}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
expect(screen.getByTestId("flight-leg-1")).toBeTruthy();
});
it("renders operating carrier info", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("operating-carrier")).toBeTruthy();
expect(screen.getByText(/Operated by: SU/)).toBeTruthy();
});
it("renders flying time", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flying-time")).toBeTruthy();
});
it("renders error state when API fails", () => {
mockUseFlightDetails.mockReturnValue({
flight: null,
loading: false,
error: new Error("API error"),
});
mockUseLiveFlightDetails.mockReturnValue({
flight: null,
connectionStatus: "idle" as const,
});
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
});
it("renders not-found state when flight is null without error", () => {
mockUseFlightDetails.mockReturnValue({
flight: null,
loading: false,
error: null,
});
mockUseLiveFlightDetails.mockReturnValue({
flight: null,
connectionStatus: "idle" as const,
});
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
});
it("renders airport codes in leg stations", () => {
setupWithFlight();
render(
<OnlineBoardDetailsPage
flightId={FLIGHT_ID}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
// Airport codes appear in both FlightCard and FlightLegs
expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,249 @@
/**
* Integration tests for the Online Board search page.
*
* Verifies FlightList renders with mocked API data, connection status
* badge, and calendar strip.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { OnlineBoardSearchPage } from "@/features/online-board/components/OnlineBoardSearchPage.js";
import type { OnlineBoardSearchPageProps } from "@/features/online-board/components/OnlineBoardSearchPage.js";
import { ALL_FLIGHTS, CALENDAR_DAYS } from "./fixtures.js";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
}));
const mockUseOnlineBoard = vi.fn();
vi.mock("@/features/online-board/hooks/useOnlineBoard.js", () => ({
useOnlineBoard: (...args: unknown[]) => mockUseOnlineBoard(...args),
}));
const mockUseLiveBoardSearch = vi.fn();
vi.mock("@/features/online-board/hooks/useLiveBoardSearch.js", () => ({
useLiveBoardSearch: (...args: unknown[]) => mockUseLiveBoardSearch(...args),
}));
const mockUseCalendarDays = vi.fn();
vi.mock("@/features/online-board/hooks/useCalendarDays.js", () => ({
useCalendarDays: (...args: unknown[]) => mockUseCalendarDays(...args),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DEPARTURE_PARAMS: OnlineBoardSearchPageProps["params"] = {
type: "departure",
station: "SVO",
date: "20250115",
};
function setupMocksWithData() {
mockUseOnlineBoard.mockReturnValue({
flights: ALL_FLIGHTS,
loading: false,
error: null,
refresh: vi.fn(),
});
mockUseLiveBoardSearch.mockImplementation(
(_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "idle" as const,
}),
);
mockUseCalendarDays.mockReturnValue({
days: CALENDAR_DAYS,
loading: false,
});
}
function setupMocksEmpty() {
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
});
mockUseLiveBoardSearch.mockImplementation(
(_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "idle" as const,
}),
);
mockUseCalendarDays.mockReturnValue({
days: [],
loading: false,
});
}
function setupMocksLoading() {
mockUseOnlineBoard.mockReturnValue({
flights: [],
loading: true,
error: null,
refresh: vi.fn(),
});
mockUseLiveBoardSearch.mockImplementation(
(_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "idle" as const,
}),
);
mockUseCalendarDays.mockReturnValue({
days: [],
loading: true,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Flight search page integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders search page container with data", () => {
setupMocksWithData();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
it("renders flight cards when flights are returned", () => {
setupMocksWithData();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
// FlightCard renders the flight number as "SU 0100"
const flightCards = screen.getAllByText(/SU 0\d{3}/);
expect(flightCards.length).toBeGreaterThanOrEqual(2);
});
it("renders empty state when no flights returned", () => {
setupMocksEmpty();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByText("No flights found")).toBeTruthy();
});
it("renders calendar strip with available days", () => {
setupMocksWithData();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
const calendarStrip = screen.getByTestId("calendar-strip");
expect(calendarStrip).toBeTruthy();
// Verify calendar day buttons are present
for (const day of CALENDAR_DAYS) {
expect(screen.getByText(day)).toBeTruthy();
}
});
it("renders live connection badge when SignalR is live", () => {
mockUseOnlineBoard.mockReturnValue({
flights: ALL_FLIGHTS,
loading: false,
error: null,
refresh: vi.fn(),
});
mockUseLiveBoardSearch.mockImplementation(
(_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "live" as const,
}),
);
mockUseCalendarDays.mockReturnValue({ days: [], loading: false });
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByText("Live")).toBeTruthy();
});
it("renders offline badge when SignalR disconnects", () => {
mockUseOnlineBoard.mockReturnValue({
flights: ALL_FLIGHTS,
loading: false,
error: null,
refresh: vi.fn(),
});
mockUseLiveBoardSearch.mockImplementation(
(_params: unknown, initialFlights: unknown) => ({
flights: initialFlights,
connectionStatus: "offline" as const,
}),
);
mockUseCalendarDays.mockReturnValue({ days: [], loading: false });
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
expect(screen.getByText("Offline")).toBeTruthy();
});
it("passes correct search params for departure type", () => {
setupMocksWithData();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record<string, string>;
expect(callArgs).toEqual({
dateFrom: "20250115",
dateTo: "20250115",
departure: "SVO",
});
});
it("passes correct search params for flight type", () => {
setupMocksWithData();
render(
<OnlineBoardSearchPage
params={{
type: "flight",
carrier: "SU",
flightNumber: "100",
date: "20250115",
}}
/>,
);
const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record<string, string>;
expect(callArgs).toEqual({
dateFrom: "20250115",
dateTo: "20250115",
flightNumber: "SU100",
});
});
it("passes correct search params for route type", () => {
setupMocksWithData();
render(
<OnlineBoardSearchPage
params={{
type: "route",
departure: "SVO",
arrival: "JFK",
date: "20250115",
}}
/>,
);
const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record<string, string>;
expect(callArgs).toEqual({
dateFrom: "20250115",
dateTo: "20250115",
departure: "SVO",
arrival: "JFK",
});
});
it("renders loading skeleton when loading", () => {
setupMocksLoading();
const { container } = render(
<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />,
);
// FlightListSkeleton renders skeleton elements
const skeletons = container.querySelectorAll(".flight-list-skeleton__row");
expect(skeletons.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,155 @@
/**
* Integration tests for SEO output in the Online Board.
*
* Uses renderToString to verify that SeoHead produces expected
* title, description, canonical, OG tags, hreflang, and JSON-LD
* in the rendered HTML.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi } from "vitest";
import { renderToString } from "react-dom/server";
import React from "react";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import {
buildOnlineBoardStartSeo,
buildFlightSearchSeo,
buildDepartureSearchSeo,
buildFlightDetailsSeo,
} from "@/features/online-board/seo.js";
import { buildFlightJsonLd, buildFlightListJsonLd } from "@/features/online-board/json-ld.js";
import { DIRECT_FLIGHT, ALL_FLIGHTS } from "./fixtures.js";
// ---------------------------------------------------------------------------
// Stub translation function
// ---------------------------------------------------------------------------
const t = (key: string, opts?: Record<string, string>) => {
if (opts) {
let result = key;
for (const [k, v] of Object.entries(opts)) {
result += ` ${k}=${v}`;
}
return result;
}
return key;
};
const LOCALE = "ru";
const ORIGIN = "https://www.aeroflot.ru";
// ---------------------------------------------------------------------------
// Tests — SeoHead rendering
// ---------------------------------------------------------------------------
describe("SEO integration — SeoHead SSR output", () => {
it("renders title for start page", () => {
const seoProps = buildOnlineBoardStartSeo(t, LOCALE, ORIGIN);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain("SEO.BOARD.MAIN.TITLE");
});
it("renders meta description for start page", () => {
const seoProps = buildOnlineBoardStartSeo(t, LOCALE, ORIGIN);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain('name="description"');
expect(html).toContain("SEO.BOARD.MAIN.DESCRIPTION");
});
it("renders canonical link for start page", () => {
const seoProps = buildOnlineBoardStartSeo(t, LOCALE, ORIGIN);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain('rel="canonical"');
expect(html).toContain("https://www.aeroflot.ru/ru/onlineboard");
});
it("renders OG tags for flight search page", () => {
const seoProps = buildFlightSearchSeo(
t,
{ type: "flight", carrier: "SU", flightNumber: "100", date: "20250115" },
LOCALE,
ORIGIN,
);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain('property="og:title"');
expect(html).toContain('property="og:description"');
expect(html).toContain('property="og:url"');
expect(html).toContain('property="og:image"');
expect(html).toContain('property="og:type" content="website"');
expect(html).toContain('property="og:locale" content="ru"');
expect(html).toContain('property="og:site_name" content="Aeroflot"');
});
it("renders hreflang links for departure search page", () => {
const seoProps = buildDepartureSearchSeo(
t,
{ type: "departure", station: "SVO", date: "20250115" },
LOCALE,
ORIGIN,
);
const html = renderToString(React.createElement(SeoHead, seoProps));
// React renders the JSX prop `hrefLang` as the HTML attribute `hrefLang` (camelCase) in SSR
expect(html).toContain('hrefLang="ru"');
expect(html).toContain('hrefLang="en"');
expect(html).toContain('hrefLang="x-default"');
});
it("renders twitter card meta tag", () => {
const seoProps = buildOnlineBoardStartSeo(t, LOCALE, ORIGIN);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain('name="twitter:card" content="summary"');
});
it("renders canonical with correct flight URL for details", () => {
const seoProps = buildFlightDetailsSeo(t, DIRECT_FLIGHT, LOCALE, ORIGIN);
const html = renderToString(React.createElement(SeoHead, seoProps));
expect(html).toContain('rel="canonical"');
expect(html).toContain("https://www.aeroflot.ru/ru/onlineboard/SU0100-20250115");
});
});
// ---------------------------------------------------------------------------
// Tests — JSON-LD rendering
// ---------------------------------------------------------------------------
describe("SEO integration — JSON-LD output", () => {
it("produces valid JSON-LD for a single flight", () => {
const jsonLd = buildFlightJsonLd(DIRECT_FLIGHT);
const json = JSON.stringify(jsonLd);
expect(json).toContain("@type");
expect(json).toContain("Flight");
});
it("produces valid JSON-LD for flight list", () => {
const jsonLd = buildFlightListJsonLd(ALL_FLIGHTS, "Departure flights from SVO");
const json = JSON.stringify(jsonLd);
expect(json).toContain("@type");
expect(json).toContain("ItemList");
});
it("includes flight number in single flight JSON-LD", () => {
const jsonLd = buildFlightJsonLd(DIRECT_FLIGHT);
const json = JSON.stringify(jsonLd);
// Should contain the flight number somewhere in the JSON-LD
expect(json).toContain("SU");
});
it("includes departure and arrival airports in JSON-LD", () => {
const jsonLd = buildFlightJsonLd(DIRECT_FLIGHT);
const json = JSON.stringify(jsonLd);
expect(json).toContain("SVO");
expect(json).toContain("JFK");
});
});
@@ -0,0 +1,104 @@
/**
* Integration tests for the Online Board start page.
*
* Verifies the search form renders with all mode tabs and
* correct fields per search type.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OnlineBoardStartPage } from "@/features/online-board/components/OnlineBoardStartPage.js";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const navigateSpy = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => navigateSpy,
useParams: () => ({ lang: "ru" }),
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Start page integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the search form with data-testid", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("search-form")).toBeTruthy();
});
it("renders all 4 search mode tabs", () => {
render(<OnlineBoardStartPage />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(4);
const labels = radios.map((r) => (r as HTMLInputElement).value);
expect(labels).toEqual(["flight", "departure", "arrival", "route"]);
});
it("defaults to flight search mode", () => {
render(<OnlineBoardStartPage />);
const flightRadio = screen.getByDisplayValue("flight") as HTMLInputElement;
expect(flightRadio.checked).toBe(true);
});
it("shows flight number input in flight mode", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
});
it("shows departure airport input in departure mode", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("departure"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
});
it("shows arrival airport input in arrival mode", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("arrival"));
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
});
it("shows both departure and arrival inputs in route mode", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("route"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
});
it("navigates to correct URL on flight search submit", () => {
render(<OnlineBoardStartPage />);
const input = screen.getByTestId("flight-number-input");
fireEvent.change(input, { target: { value: "SU100" } });
const form = screen.getByTestId("search-form");
fireEvent.submit(form);
expect(navigateSpy).toHaveBeenCalledTimes(1);
const url = navigateSpy.mock.calls[0]?.[0] as string;
expect(url).toMatch(/^\/ru\/onlineboard\/flight\/SU0100-\d{8}$/);
});
it("navigates to correct URL on departure search submit", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByDisplayValue("departure"));
const input = screen.getByTestId("departure-airport-input");
fireEvent.change(input, { target: { value: "SVO" } });
const form = screen.getByTestId("search-form");
fireEvent.submit(form);
expect(navigateSpy).toHaveBeenCalledTimes(1);
const url = navigateSpy.mock.calls[0]?.[0] as string;
expect(url).toMatch(/^\/ru\/onlineboard\/departure\/SVO-\d{8}$/);
});
});
@@ -0,0 +1,178 @@
/**
* Integration tests for URL round-tripping.
*
* Verifies: build URL from params -> parse URL -> params match original.
* Also verifies that parsed params produce correct API call parameters.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import {
buildOnlineBoardUrl,
parseOnlineBoardUrl,
type OnlineBoardParams,
} from "@/features/online-board/url.js";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("URL round-trip integration", () => {
const roundTrip = (params: OnlineBoardParams) => {
const url = buildOnlineBoardUrl(params);
const parsed = parseOnlineBoardUrl(url);
return { url, parsed };
};
it("round-trips start page", () => {
const params: OnlineBoardParams = { type: "start" };
const { parsed } = roundTrip(params);
expect(parsed).toEqual({ type: "start" });
});
it("round-trips flight search", () => {
const params: OnlineBoardParams = {
type: "flight",
carrier: "SU",
flightNumber: "100",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/flight/SU0100-20250115");
expect(parsed).not.toBeNull();
expect(parsed!.type).toBe("flight");
if (parsed!.type === "flight") {
expect(parsed!.carrier).toBe("SU");
// Flight number gets zero-padded to 4 digits during build
expect(parsed!.flightNumber).toBe("0100");
expect(parsed!.date).toBe("20250115");
}
});
it("round-trips flight search with suffix", () => {
const params: OnlineBoardParams = {
type: "flight",
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/flight/SU0100A-20250115");
expect(parsed).not.toBeNull();
if (parsed!.type === "flight") {
expect(parsed!.suffix).toBe("A");
}
});
it("round-trips departure search", () => {
const params: OnlineBoardParams = {
type: "departure",
station: "SVO",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/departure/SVO-20250115");
expect(parsed).toEqual(params);
});
it("round-trips departure search with time range", () => {
const params: OnlineBoardParams = {
type: "departure",
station: "SVO",
date: "20250115",
timeFrom: "0800",
timeTo: "1200",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/departure/SVO-20250115-08001200");
expect(parsed).toEqual(params);
});
it("round-trips arrival search", () => {
const params: OnlineBoardParams = {
type: "arrival",
station: "JFK",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/arrival/JFK-20250115");
expect(parsed).toEqual(params);
});
it("round-trips route search", () => {
const params: OnlineBoardParams = {
type: "route",
departure: "SVO",
arrival: "JFK",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/route/SVO-JFK-20250115");
expect(parsed).toEqual(params);
});
it("round-trips route search with time range", () => {
const params: OnlineBoardParams = {
type: "route",
departure: "SVO",
arrival: "JFK",
date: "20250115",
timeFrom: "0600",
timeTo: "1800",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/route/SVO-JFK-20250115-06001800");
expect(parsed).toEqual(params);
});
it("round-trips details URL", () => {
const params: OnlineBoardParams = {
type: "details",
carrier: "SU",
flightNumber: "100",
date: "20250115",
};
const { url, parsed } = roundTrip(params);
expect(url).toBe("onlineboard/SU0100-20250115");
expect(parsed).not.toBeNull();
expect(parsed!.type).toBe("details");
});
it("returns null for invalid URL", () => {
expect(parseOnlineBoardUrl("")).toBeNull();
expect(parseOnlineBoardUrl("not-a-board-url")).toBeNull();
expect(parseOnlineBoardUrl("onlineboard/invalid/")).toBeNull();
});
it("returns null for malformed flight params", () => {
expect(parseOnlineBoardUrl("onlineboard/flight/")).toBeNull();
expect(parseOnlineBoardUrl("onlineboard/flight/SU-notadate")).toBeNull();
});
it("handles leading slash in parse", () => {
const parsed = parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115");
expect(parsed).toEqual({
type: "departure",
station: "SVO",
date: "20250115",
});
});
it("build then parse preserves search semantics for all 4 search types", () => {
const searchTypes: OnlineBoardParams[] = [
{ type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" },
{ type: "departure", station: "SVO", date: "20250115" },
{ type: "arrival", station: "JFK", date: "20250115" },
{ type: "route", departure: "SVO", arrival: "JFK", date: "20250115" },
];
for (const params of searchTypes) {
const url = buildOnlineBoardUrl(params);
const parsed = parseOnlineBoardUrl(url);
expect(parsed).not.toBeNull();
expect(parsed!.type).toBe(params.type);
}
});
});