diff --git a/tests/integration/online-board/error-handling.test.tsx b/tests/integration/online-board/error-handling.test.tsx
new file mode 100644
index 00000000..3cba82e9
--- /dev/null
+++ b/tests/integration/online-board/error-handling.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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(
+ ,
+ );
+ expect(screen.getByTestId("flight-details-error")).toBeTruthy();
+ });
+
+ it("renders not-found when details returns null", () => {
+ mockUseFlightDetails.mockReturnValue({
+ flight: null,
+ loading: false,
+ error: null,
+ });
+
+ render(
+ ,
+ );
+ expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
+ });
+});
diff --git a/tests/integration/online-board/fixtures.ts b/tests/integration/online-board/fixtures.ts
new file mode 100644
index 00000000..ed4a35be
--- /dev/null
+++ b/tests/integration/online-board/fixtures.ts
@@ -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 & { 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"];
diff --git a/tests/integration/online-board/flight-details.test.tsx b/tests/integration/online-board/flight-details.test.tsx
new file mode 100644
index 00000000..8a8ee4f3
--- /dev/null
+++ b/tests/integration/online-board/flight-details.test.tsx
@@ -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(
+ ,
+ );
+ expect(screen.getByTestId("flight-details")).toBeTruthy();
+ });
+
+ it("renders flight number heading", () => {
+ setupWithFlight();
+ render(
+ ,
+ );
+ // Flight number appears in both heading and FlightCard
+ expect(screen.getAllByText("SU 0100").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders flight status", () => {
+ setupWithFlight();
+ render(
+ ,
+ );
+ // 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
+ expect(screen.getByTestId("flight-leg-1")).toBeTruthy();
+ });
+
+ it("renders operating carrier info", () => {
+ setupWithFlight();
+ render(
+ ,
+ );
+ expect(screen.getByTestId("operating-carrier")).toBeTruthy();
+ expect(screen.getByText(/Operated by: SU/)).toBeTruthy();
+ });
+
+ it("renders flying time", () => {
+ setupWithFlight();
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
+ });
+
+ it("renders airport codes in leg stations", () => {
+ setupWithFlight();
+ render(
+ ,
+ );
+ // Airport codes appear in both FlightCard and FlightLegs
+ expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/tests/integration/online-board/flight-search.test.tsx b/tests/integration/online-board/flight-search.test.tsx
new file mode 100644
index 00000000..9a2501e3
--- /dev/null
+++ b/tests/integration/online-board/flight-search.test.tsx
@@ -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();
+ expect(screen.getByTestId("online-board-search")).toBeTruthy();
+ });
+
+ it("renders flight cards when flights are returned", () => {
+ setupMocksWithData();
+ render();
+ // 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();
+ expect(screen.getByText("No flights found")).toBeTruthy();
+ });
+
+ it("renders calendar strip with available days", () => {
+ setupMocksWithData();
+ render();
+ 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();
+ 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();
+ expect(screen.getByText("Offline")).toBeTruthy();
+ });
+
+ it("passes correct search params for departure type", () => {
+ setupMocksWithData();
+ render();
+
+ const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record;
+ expect(callArgs).toEqual({
+ dateFrom: "20250115",
+ dateTo: "20250115",
+ departure: "SVO",
+ });
+ });
+
+ it("passes correct search params for flight type", () => {
+ setupMocksWithData();
+ render(
+ ,
+ );
+
+ const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record;
+ expect(callArgs).toEqual({
+ dateFrom: "20250115",
+ dateTo: "20250115",
+ flightNumber: "SU100",
+ });
+ });
+
+ it("passes correct search params for route type", () => {
+ setupMocksWithData();
+ render(
+ ,
+ );
+
+ const callArgs = mockUseOnlineBoard.mock.calls[0]?.[0] as Record;
+ expect(callArgs).toEqual({
+ dateFrom: "20250115",
+ dateTo: "20250115",
+ departure: "SVO",
+ arrival: "JFK",
+ });
+ });
+
+ it("renders loading skeleton when loading", () => {
+ setupMocksLoading();
+ const { container } = render(
+ ,
+ );
+ // FlightListSkeleton renders skeleton elements
+ const skeletons = container.querySelectorAll(".flight-list-skeleton__row");
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/integration/online-board/seo-integration.test.tsx b/tests/integration/online-board/seo-integration.test.tsx
new file mode 100644
index 00000000..603dfbd6
--- /dev/null
+++ b/tests/integration/online-board/seo-integration.test.tsx
@@ -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) => {
+ 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");
+ });
+});
diff --git a/tests/integration/online-board/start-page.test.tsx b/tests/integration/online-board/start-page.test.tsx
new file mode 100644
index 00000000..f1d13248
--- /dev/null
+++ b/tests/integration/online-board/start-page.test.tsx
@@ -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();
+ expect(screen.getByTestId("search-form")).toBeTruthy();
+ });
+
+ it("renders all 4 search mode tabs", () => {
+ render();
+ 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();
+ const flightRadio = screen.getByDisplayValue("flight") as HTMLInputElement;
+ expect(flightRadio.checked).toBe(true);
+ });
+
+ it("shows flight number input in flight mode", () => {
+ render();
+ expect(screen.getByTestId("flight-number-input")).toBeTruthy();
+ });
+
+ it("shows departure airport input in departure mode", () => {
+ render();
+ fireEvent.click(screen.getByDisplayValue("departure"));
+ expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
+ });
+
+ it("shows arrival airport input in arrival mode", () => {
+ render();
+ fireEvent.click(screen.getByDisplayValue("arrival"));
+ expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
+ });
+
+ it("shows both departure and arrival inputs in route mode", () => {
+ render();
+ 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();
+ 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();
+ 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}$/);
+ });
+});
diff --git a/tests/integration/online-board/url-roundtrip.test.tsx b/tests/integration/online-board/url-roundtrip.test.tsx
new file mode 100644
index 00000000..134cbdf5
--- /dev/null
+++ b/tests/integration/online-board/url-roundtrip.test.tsx
@@ -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);
+ }
+ });
+});