Add @aris/data-source-tfl package

TfL data source for tube, overground, and Elizabeth line alerts.

- Fetches line statuses and filters to minor/major delays and closures
- Sorts alerts by severity, then by proximity to user location
- Caches station data after first fetch
- Uses arktype for API response validation
- Supports API injection for testing

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-01-16 23:57:29 +00:00
parent 90fd137b77
commit 8ec8b9a13e
10 changed files with 41132 additions and 5 deletions

View File

@@ -0,0 +1,206 @@
import { describe, expect, test } from "bun:test"
import type { Context } from "@aris/core"
import { TflDataSource } from "./data-source.ts"
import type { ITflApi, TflLineStatus } from "./tfl-api.ts"
import type { StationLocation, TflLineId } from "./types.ts"
import fixtures from "../fixtures/tfl-responses.json"
// Mock API that returns fixture data
class FixtureTflApi implements ITflApi {
async fetchLineStatuses(_lines?: TflLineId[]): Promise<TflLineStatus[]> {
const statuses: TflLineStatus[] = []
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
for (const status of line.lineStatuses as Record<string, unknown>[]) {
const severityCode = status.statusSeverity as number
const severity = this.mapSeverity(severityCode)
if (severity) {
statuses.push({
lineId: line.id as TflLineId,
lineName: line.name as string,
severity,
description: (status.reason as string) ?? (status.statusSeverityDescription as string),
})
}
}
}
return statuses
}
async fetchStations(): Promise<StationLocation[]> {
const stationMap = new Map<string, StationLocation>()
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
for (const stop of stops as Record<string, unknown>[]) {
const id = stop.naptanId as string
const existing = stationMap.get(id)
if (existing) {
if (!existing.lines.includes(lineId as TflLineId)) {
existing.lines.push(lineId as TflLineId)
}
} else {
stationMap.set(id, {
id,
name: stop.commonName as string,
lat: stop.lat as number,
lng: stop.lon as number,
lines: [lineId as TflLineId],
})
}
}
}
return Array.from(stationMap.values())
}
private mapSeverity(code: number): "minor-delays" | "major-delays" | "closure" | null {
const map: Record<number, "minor-delays" | "major-delays" | "closure" | null> = {
1: "closure",
2: "closure",
3: "closure",
4: "closure",
5: "closure",
6: "major-delays",
7: "major-delays",
8: "major-delays",
9: "minor-delays",
10: null,
}
return map[code] ?? null
}
}
const createContext = (location?: { lat: number; lng: number }): Context => ({
time: new Date("2026-01-15T12:00:00Z"),
location: location ? { ...location, accuracy: 10 } : undefined,
})
describe("TfL Feed Items (using fixture data)", () => {
const api = new FixtureTflApi()
test("query returns feed items array", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext(), {})
expect(Array.isArray(items)).toBe(true)
})
test("feed items have correct base structure", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
for (const item of items) {
expect(typeof item.id).toBe("string")
expect(item.id).toMatch(/^tfl-alert-/)
expect(item.type).toBe("tfl-alert")
expect(typeof item.priority).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
}
})
test("feed items have correct data structure", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
for (const item of items) {
expect(typeof item.data.line).toBe("string")
expect(typeof item.data.lineName).toBe("string")
expect(["minor-delays", "major-delays", "closure"]).toContain(item.data.severity)
expect(typeof item.data.description).toBe("string")
expect(item.data.closestStationDistance === null || typeof item.data.closestStationDistance === "number").toBe(
true,
)
}
})
test("feed item ids are unique", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext(), {})
const ids = items.map((item) => item.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
test("feed items are sorted by priority descending", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext(), {})
for (let i = 1; i < items.length; i++) {
const prev = items[i - 1]!
const curr = items[i]!
expect(prev.priority).toBeGreaterThanOrEqual(curr.priority)
}
})
test("priority values match severity levels", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext(), {})
const severityPriority: Record<string, number> = {
closure: 100,
"major-delays": 80,
"minor-delays": 60,
}
for (const item of items) {
expect(item.priority).toBe(severityPriority[item.data.severity]!)
}
})
test("closestStationDistance is number when location provided", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext({ lat: 51.5074, lng: -0.1278 }), {})
for (const item of items) {
expect(typeof item.data.closestStationDistance).toBe("number")
expect(item.data.closestStationDistance!).toBeGreaterThan(0)
}
})
test("closestStationDistance is null when no location provided", async () => {
const dataSource = new TflDataSource(api)
const items = await dataSource.query(createContext(), {})
for (const item of items) {
expect(item.data.closestStationDistance).toBeNull()
}
})
})
describe("TfL Fixture Data Shape", () => {
test("fixtures have expected structure", () => {
expect(typeof fixtures.fetchedAt).toBe("string")
expect(Array.isArray(fixtures.lineStatuses)).toBe(true)
expect(typeof fixtures.stopPoints).toBe("object")
})
test("line statuses have required fields", () => {
for (const line of fixtures.lineStatuses as Record<string, unknown>[]) {
expect(typeof line.id).toBe("string")
expect(typeof line.name).toBe("string")
expect(Array.isArray(line.lineStatuses)).toBe(true)
for (const status of line.lineStatuses as Record<string, unknown>[]) {
expect(typeof status.statusSeverity).toBe("number")
expect(typeof status.statusSeverityDescription).toBe("string")
}
}
})
test("stop points have required fields", () => {
for (const [lineId, stops] of Object.entries(fixtures.stopPoints)) {
expect(typeof lineId).toBe("string")
expect(Array.isArray(stops)).toBe(true)
for (const stop of stops as Record<string, unknown>[]) {
expect(typeof stop.naptanId).toBe("string")
expect(typeof stop.commonName).toBe("string")
expect(typeof stop.lat).toBe("number")
expect(typeof stop.lon).toBe("number")
}
}
})
})