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); + } + }); +});