Add schedule SEO and JSON-LD tests (Phase 3D)
Tests cover start page, search, and details SEO builders plus Flight/ItemList JSON-LD schema generation for schedule pages.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Phase 3D -- Schedule SEO + JSON-LD Tests
|
||||||
|
|
||||||
|
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||||
|
> **Depends on:** 3C (seo.ts, json-ld.ts already created)
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. `src/features/schedule/seo.test.ts` -- SEO builder tests
|
||||||
|
2. `src/features/schedule/json-ld.test.ts` -- JSON-LD builder tests
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Flight, ItemList } from "schema-dts";
|
||||||
|
import { buildScheduleFlightJsonLd, buildScheduleFlightListJsonLd } from "./json-ld.js";
|
||||||
|
import type { ISimpleFlight } from "./types.js";
|
||||||
|
|
||||||
|
function makeDirectFlight(overrides: Partial<{
|
||||||
|
carrier: string;
|
||||||
|
flightNumber: string;
|
||||||
|
depCode: string;
|
||||||
|
depAirport: string;
|
||||||
|
arrCode: string;
|
||||||
|
arrAirport: string;
|
||||||
|
depTime: string;
|
||||||
|
arrTime: string;
|
||||||
|
}> = {}): ISimpleFlight {
|
||||||
|
return {
|
||||||
|
id: "test-schedule-1",
|
||||||
|
routeType: "Direct",
|
||||||
|
flightId: {
|
||||||
|
carrier: overrides.carrier ?? "SU",
|
||||||
|
flightNumber: overrides.flightNumber ?? "0012",
|
||||||
|
suffix: "",
|
||||||
|
date: "20220527",
|
||||||
|
},
|
||||||
|
flyingTime: "1h 30m",
|
||||||
|
operatingBy: {},
|
||||||
|
status: "Scheduled",
|
||||||
|
leg: {
|
||||||
|
index: 0,
|
||||||
|
status: "Scheduled",
|
||||||
|
flyingTime: "1h 30m",
|
||||||
|
updated: "2022-05-27T10:00:00Z",
|
||||||
|
dayChange: 0,
|
||||||
|
equipment: { name: "Airbus A320", code: "320" },
|
||||||
|
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||||
|
operatingBy: {},
|
||||||
|
departure: {
|
||||||
|
scheduled: {
|
||||||
|
airport: overrides.depAirport ?? "Sheremetyevo",
|
||||||
|
airportCode: overrides.depCode ?? "SVO",
|
||||||
|
city: "Moscow",
|
||||||
|
cityCode: "MOW",
|
||||||
|
countryCode: "RU",
|
||||||
|
},
|
||||||
|
checkingStatus: "closed",
|
||||||
|
times: {
|
||||||
|
scheduledDeparture: {
|
||||||
|
dayChange: { value: 0, title: "" },
|
||||||
|
local: overrides.depTime ?? "2022-05-27T10:00:00",
|
||||||
|
localTime: "10:00",
|
||||||
|
tzOffset: 3,
|
||||||
|
utc: "2022-05-27T07:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
scheduled: {
|
||||||
|
airport: overrides.arrAirport ?? "Pulkovo",
|
||||||
|
airportCode: overrides.arrCode ?? "LED",
|
||||||
|
city: "Saint Petersburg",
|
||||||
|
cityCode: "LED",
|
||||||
|
countryCode: "RU",
|
||||||
|
},
|
||||||
|
times: {
|
||||||
|
scheduledArrival: {
|
||||||
|
dayChange: { value: 0, title: "" },
|
||||||
|
local: overrides.arrTime ?? "2022-05-27T11:30:00",
|
||||||
|
localTime: "11:30",
|
||||||
|
tzOffset: 3,
|
||||||
|
utc: "2022-05-27T08:30:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildScheduleFlightJsonLd", () => {
|
||||||
|
it("returns a Flight schema with correct @type", () => {
|
||||||
|
const flight = makeDirectFlight();
|
||||||
|
const result = buildScheduleFlightJsonLd(flight);
|
||||||
|
|
||||||
|
expect(result["@type"]).toBe("Flight");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets flightNumber in IATA format", () => {
|
||||||
|
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0012" });
|
||||||
|
const result = buildScheduleFlightJsonLd(flight);
|
||||||
|
|
||||||
|
expect(result.flightNumber).toBe("SU0012");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps departure airport", () => {
|
||||||
|
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
|
||||||
|
const result = buildScheduleFlightJsonLd(flight);
|
||||||
|
|
||||||
|
expect(result.departureAirport).toEqual({
|
||||||
|
"@type": "Airport",
|
||||||
|
iataCode: "SVO",
|
||||||
|
name: "Sheremetyevo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps arrival airport", () => {
|
||||||
|
const flight = makeDirectFlight({ arrCode: "LED", arrAirport: "Pulkovo" });
|
||||||
|
const result = buildScheduleFlightJsonLd(flight);
|
||||||
|
|
||||||
|
expect(result.arrivalAirport).toEqual({
|
||||||
|
"@type": "Airport",
|
||||||
|
iataCode: "LED",
|
||||||
|
name: "Pulkovo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is type-compatible with schema-dts Flight", () => {
|
||||||
|
const flight = makeDirectFlight();
|
||||||
|
const result = buildScheduleFlightJsonLd(flight);
|
||||||
|
const _typed: Flight = result;
|
||||||
|
expect(_typed["@type"]).toBe("Flight");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildScheduleFlightListJsonLd", () => {
|
||||||
|
it("returns an ItemList schema", () => {
|
||||||
|
const flights = [makeDirectFlight()];
|
||||||
|
const result = buildScheduleFlightListJsonLd(flights, "Schedule SVO to LED");
|
||||||
|
|
||||||
|
expect(result["@type"]).toBe("ItemList");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the description", () => {
|
||||||
|
const flights = [makeDirectFlight()];
|
||||||
|
const result = buildScheduleFlightListJsonLd(flights, "Schedule SVO to LED");
|
||||||
|
|
||||||
|
expect(result.description).toBe("Schedule SVO to LED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps each flight as a ListItem with position", () => {
|
||||||
|
const flights = [
|
||||||
|
makeDirectFlight({ carrier: "SU", flightNumber: "0012" }),
|
||||||
|
makeDirectFlight({ carrier: "SU", flightNumber: "0013" }),
|
||||||
|
];
|
||||||
|
const result = buildScheduleFlightListJsonLd(flights, "Flights");
|
||||||
|
const items = result.itemListElement as unknown as Array<{ position: number }>;
|
||||||
|
|
||||||
|
expect(Array.isArray(items)).toBe(true);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]).toHaveProperty("position", 1);
|
||||||
|
expect(items[1]).toHaveProperty("position", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty flight list", () => {
|
||||||
|
const result = buildScheduleFlightListJsonLd([], "No flights");
|
||||||
|
|
||||||
|
expect(result["@type"]).toBe("ItemList");
|
||||||
|
expect(result.itemListElement).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is type-compatible with schema-dts ItemList", () => {
|
||||||
|
const result = buildScheduleFlightListJsonLd([], "test");
|
||||||
|
const _typed: ItemList = result;
|
||||||
|
expect(_typed["@type"]).toBe("ItemList");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildScheduleStartSeo,
|
||||||
|
buildScheduleSearchSeo,
|
||||||
|
buildScheduleDetailsSeo,
|
||||||
|
} from "./seo.js";
|
||||||
|
import type { ISimpleFlight, IScheduleFlightId } from "./types.js";
|
||||||
|
import type { IFlightLeg } from "../online-board/types.js";
|
||||||
|
|
||||||
|
/** Stub t() that returns the key + interpolation vars for assertion. */
|
||||||
|
function stubT(key: string, opts?: Record<string, unknown>): string {
|
||||||
|
if (opts && Object.keys(opts).length > 0) {
|
||||||
|
const vars = Object.entries(opts)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(",");
|
||||||
|
return `${key}|${vars}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANONICAL = "https://www.aeroflot.ru";
|
||||||
|
|
||||||
|
describe("buildScheduleStartSeo", () => {
|
||||||
|
it("uses MAIN translation keys", () => {
|
||||||
|
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toBe("SEO.SCHEDULE.MAIN.TITLE");
|
||||||
|
expect(result.description).toBe("SEO.SCHEDULE.MAIN.DESCRIPTION");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets canonical to /{locale}/schedule", () => {
|
||||||
|
const result = buildScheduleStartSeo(stubT, "en", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.canonical).toBe("https://www.aeroflot.ru/en/schedule");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes hreflang with 10 entries", () => {
|
||||||
|
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.hreflang).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets og tags", () => {
|
||||||
|
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.og.title).toBe(result.title);
|
||||||
|
expect(result.og.type).toBe("website");
|
||||||
|
expect(result.og.locale).toBe("ru");
|
||||||
|
expect(result.og.siteName).toBe("Aeroflot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets twitter card", () => {
|
||||||
|
const result = buildScheduleStartSeo(stubT, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.twitter).toBeDefined();
|
||||||
|
expect(result.twitter!.card).toBe("summary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildScheduleSearchSeo", () => {
|
||||||
|
const params = {
|
||||||
|
type: "route" as const,
|
||||||
|
outbound: {
|
||||||
|
departure: "MOW",
|
||||||
|
arrival: "KUF",
|
||||||
|
dateFrom: "20220425",
|
||||||
|
dateTo: "20220501",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses SEARCH translation keys with interpolation", () => {
|
||||||
|
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("SEO.SCHEDULE.SEARCH.TITLE");
|
||||||
|
expect(result.title).toContain("departureCity=MOW");
|
||||||
|
expect(result.title).toContain("arrivalCity=KUF");
|
||||||
|
expect(result.title).toContain("dateFrom=25.04.2022");
|
||||||
|
expect(result.title).toContain("dateTo=01.05.2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses city names when provided", () => {
|
||||||
|
const result = buildScheduleSearchSeo(
|
||||||
|
stubT,
|
||||||
|
params,
|
||||||
|
"ru",
|
||||||
|
CANONICAL,
|
||||||
|
{ departure: "Moscow", arrival: "Samara" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.title).toContain("departureCity=Moscow");
|
||||||
|
expect(result.title).toContain("arrivalCity=Samara");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets canonical to the route search URL", () => {
|
||||||
|
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.canonical).toBe(
|
||||||
|
"https://www.aeroflot.ru/ru/schedule/route/MOW-KUF-20220425-20220501",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes hreflang entries", () => {
|
||||||
|
const result = buildScheduleSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.hreflang).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles round-trip params", () => {
|
||||||
|
const rtParams = {
|
||||||
|
type: "roundtrip" as const,
|
||||||
|
outbound: {
|
||||||
|
departure: "MOW",
|
||||||
|
arrival: "KUF",
|
||||||
|
dateFrom: "20220425",
|
||||||
|
dateTo: "20220501",
|
||||||
|
},
|
||||||
|
inbound: {
|
||||||
|
departure: "KUF",
|
||||||
|
arrival: "MOW",
|
||||||
|
dateFrom: "20220502",
|
||||||
|
dateTo: "20220508",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildScheduleSearchSeo(stubT, rtParams, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.canonical).toContain(
|
||||||
|
"schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildScheduleDetailsSeo", () => {
|
||||||
|
const flightIds: IScheduleFlightId[] = [
|
||||||
|
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const flight: ISimpleFlight = {
|
||||||
|
id: "SU0012-20220527",
|
||||||
|
routeType: "Direct",
|
||||||
|
flightId: {
|
||||||
|
carrier: "SU",
|
||||||
|
flightNumber: "0012",
|
||||||
|
suffix: "",
|
||||||
|
date: "20220527",
|
||||||
|
},
|
||||||
|
flyingTime: "1h 30m",
|
||||||
|
operatingBy: {},
|
||||||
|
status: "Scheduled",
|
||||||
|
leg: {
|
||||||
|
index: 0,
|
||||||
|
status: "Scheduled",
|
||||||
|
flyingTime: "1h 30m",
|
||||||
|
updated: "2022-05-27T10:00:00Z",
|
||||||
|
dayChange: 0,
|
||||||
|
equipment: { name: "Airbus A320", code: "320" },
|
||||||
|
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||||
|
operatingBy: {},
|
||||||
|
departure: {
|
||||||
|
scheduled: {
|
||||||
|
airport: "Sheremetyevo",
|
||||||
|
airportCode: "SVO",
|
||||||
|
city: "Moscow",
|
||||||
|
cityCode: "MOW",
|
||||||
|
countryCode: "RU",
|
||||||
|
},
|
||||||
|
checkingStatus: "closed",
|
||||||
|
times: {
|
||||||
|
scheduledDeparture: {
|
||||||
|
dayChange: { value: 0, title: "" },
|
||||||
|
local: "2022-05-27T10:00:00",
|
||||||
|
localTime: "10:00",
|
||||||
|
tzOffset: 3,
|
||||||
|
utc: "2022-05-27T07:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
scheduled: {
|
||||||
|
airport: "Pulkovo",
|
||||||
|
airportCode: "LED",
|
||||||
|
city: "Saint Petersburg",
|
||||||
|
cityCode: "LED",
|
||||||
|
countryCode: "RU",
|
||||||
|
},
|
||||||
|
times: {
|
||||||
|
scheduledArrival: {
|
||||||
|
dayChange: { value: 0, title: "" },
|
||||||
|
local: "2022-05-27T11:30:00",
|
||||||
|
localTime: "11:30",
|
||||||
|
tzOffset: 3,
|
||||||
|
utc: "2022-05-27T08:30:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("uses DETAILS translation keys", () => {
|
||||||
|
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||||
|
|
||||||
|
expect(result.title).toContain("SEO.SCHEDULE.DETAILS.TITLE");
|
||||||
|
expect(result.title).toContain("flights=SU 0012");
|
||||||
|
expect(result.title).toContain("date=27.05.2022");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets canonical to the details URL", () => {
|
||||||
|
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||||
|
|
||||||
|
expect(result.canonical).toBe(
|
||||||
|
"https://www.aeroflot.ru/ru/schedule/SU0012-20220527",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes hreflang entries", () => {
|
||||||
|
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||||
|
|
||||||
|
expect(result.hreflang).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets og.type to article for details", () => {
|
||||||
|
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
|
||||||
|
|
||||||
|
expect(result.og.type).toBe("article");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple flights in display", () => {
|
||||||
|
const multiFlightIds: IScheduleFlightId[] = [
|
||||||
|
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
|
||||||
|
{ carrier: "SU", flightNumber: "0013", date: "20220527" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, multiFlightIds);
|
||||||
|
|
||||||
|
expect(result.title).toContain("SU 0012, SU 0013");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user