diff --git a/apps/aris-backend/package.json b/apps/aris-backend/package.json index e33baf2..6d8abd4 100644 --- a/apps/aris-backend/package.json +++ b/apps/aris-backend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@aris/core": "workspace:*", "@aris/source-location": "workspace:*", + "@aris/source-tfl": "workspace:*", "@aris/source-weatherkit": "workspace:*", "@hono/trpc-server": "^0.3", "@trpc/server": "^11", diff --git a/apps/aris-backend/src/tfl/service.test.ts b/apps/aris-backend/src/tfl/service.test.ts new file mode 100644 index 0000000..32611e6 --- /dev/null +++ b/apps/aris-backend/src/tfl/service.test.ts @@ -0,0 +1,206 @@ +import type { Context } from "@aris/core" +import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl" + +import { describe, expect, test } from "bun:test" + +import { UserNotFoundError } from "../lib/error.ts" +import { TflService } from "./service.ts" + +class StubTflApi implements ITflApi { + private statuses: TflLineStatus[] + + constructor(statuses: TflLineStatus[] = []) { + this.statuses = statuses + } + + async fetchLineStatuses(lines?: TflLineId[]): Promise { + if (lines) { + return this.statuses.filter((s) => lines.includes(s.lineId)) + } + return this.statuses + } + + async fetchStations(): Promise { + return [ + { + id: "940GZZLUKSX", + name: "King's Cross", + lat: 51.5308, + lng: -0.1238, + lines: ["northern", "victoria"], + }, + ] + } +} + +function createContext(): Context { + return { time: new Date("2026-01-15T12:00:00Z") } +} + +const sampleStatuses: TflLineStatus[] = [ + { + lineId: "northern", + lineName: "Northern", + severity: "minor-delays", + description: "Minor delays on the Northern line", + }, + { + lineId: "victoria", + lineName: "Victoria", + severity: "major-delays", + description: "Severe delays on the Victoria line", + }, + { + lineId: "central", + lineName: "Central", + severity: "closure", + description: "Central line suspended", + }, +] + +describe("TflService", () => { + test("feedSourceForUser creates source on first call", () => { + const service = new TflService(new StubTflApi()) + const source = service.feedSourceForUser("user-1") + + expect(source).toBeDefined() + expect(source.id).toBe("tfl") + }) + + test("feedSourceForUser returns same source for same user", () => { + const service = new TflService(new StubTflApi()) + const source1 = service.feedSourceForUser("user-1") + const source2 = service.feedSourceForUser("user-1") + + expect(source1).toBe(source2) + }) + + test("feedSourceForUser returns different sources for different users", () => { + const service = new TflService(new StubTflApi()) + const source1 = service.feedSourceForUser("user-1") + const source2 = service.feedSourceForUser("user-2") + + expect(source1).not.toBe(source2) + }) + + test("updateLinesOfInterest mutates the existing source in place", () => { + const service = new TflService(new StubTflApi()) + const original = service.feedSourceForUser("user-1") + + service.updateLinesOfInterest("user-1", ["northern", "victoria"]) + const after = service.feedSourceForUser("user-1") + + expect(after).toBe(original) + }) + + test("updateLinesOfInterest throws if source does not exist", () => { + const service = new TflService(new StubTflApi()) + + expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError) + }) + + test("removeUser removes the source", () => { + const service = new TflService(new StubTflApi()) + const source1 = service.feedSourceForUser("user-1") + + service.removeUser("user-1") + const source2 = service.feedSourceForUser("user-1") + + expect(source1).not.toBe(source2) + }) + + test("removeUser clears line configuration", async () => { + const api = new StubTflApi(sampleStatuses) + const service = new TflService(api) + service.feedSourceForUser("user-1") + service.updateLinesOfInterest("user-1", ["northern"]) + + service.removeUser("user-1") + const items = await service.feedSourceForUser("user-1").fetchItems(createContext()) + + expect(items.length).toBe(3) + }) + + test("shares single api instance across users", () => { + const api = new StubTflApi() + const service = new TflService(api) + + service.feedSourceForUser("user-1") + service.feedSourceForUser("user-2") + + expect(service.feedSourceForUser("user-1").id).toBe("tfl") + expect(service.feedSourceForUser("user-2").id).toBe("tfl") + }) + + describe("returned source fetches items", () => { + test("source returns feed items from api", async () => { + const api = new StubTflApi(sampleStatuses) + const service = new TflService(api) + const source = service.feedSourceForUser("user-1") + + const items = await source.fetchItems(createContext()) + + expect(items.length).toBe(3) + for (const item of items) { + expect(item.type).toBe("tfl-alert") + expect(item.id).toMatch(/^tfl-alert-/) + expect(typeof item.priority).toBe("number") + expect(item.timestamp).toBeInstanceOf(Date) + } + }) + + test("source returns items sorted by priority descending", async () => { + const api = new StubTflApi(sampleStatuses) + const service = new TflService(api) + const source = service.feedSourceForUser("user-1") + + const items = await source.fetchItems(createContext()) + + for (let i = 1; i < items.length; i++) { + expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority) + } + }) + + test("source returns empty array when no disruptions", async () => { + const api = new StubTflApi([]) + const service = new TflService(api) + const source = service.feedSourceForUser("user-1") + + const items = await source.fetchItems(createContext()) + + expect(items).toEqual([]) + }) + + test("updateLinesOfInterest filters items to configured lines", async () => { + const api = new StubTflApi(sampleStatuses) + const service = new TflService(api) + + const before = await service.feedSourceForUser("user-1").fetchItems(createContext()) + expect(before.length).toBe(3) + + service.updateLinesOfInterest("user-1", ["northern"]) + const after = await service.feedSourceForUser("user-1").fetchItems(createContext()) + + expect(after.length).toBe(1) + expect(after[0]!.data.line).toBe("northern") + }) + + test("different users get independent line configs", async () => { + const api = new StubTflApi(sampleStatuses) + const service = new TflService(api) + service.feedSourceForUser("user-1") + service.feedSourceForUser("user-2") + + service.updateLinesOfInterest("user-1", ["northern"]) + service.updateLinesOfInterest("user-2", ["central"]) + + const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext()) + const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext()) + + expect(items1.length).toBe(1) + expect(items1[0]!.data.line).toBe("northern") + expect(items2.length).toBe(1) + expect(items2[0]!.data.line).toBe("central") + }) + }) +}) diff --git a/apps/aris-backend/src/tfl/service.ts b/apps/aris-backend/src/tfl/service.ts new file mode 100644 index 0000000..7aa0e27 --- /dev/null +++ b/apps/aris-backend/src/tfl/service.ts @@ -0,0 +1,40 @@ +import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl" + +import type { FeedSourceProvider } from "../feed/service.ts" + +import { UserNotFoundError } from "../lib/error.ts" + +/** + * Manages per-user TflSource instances with individual line configuration. + */ +export class TflService implements FeedSourceProvider { + private sources = new Map() + + constructor(private readonly api: ITflApi) {} + + feedSourceForUser(userId: string): TflSource { + let source = this.sources.get(userId) + if (!source) { + source = new TflSource({ client: this.api }) + this.sources.set(userId, source) + } + return source + } + + /** + * Update monitored lines for a user. Mutates the existing TflSource + * so that references held by FeedEngine remain valid. + * @throws {UserNotFoundError} If no source exists for the user + */ + updateLinesOfInterest(userId: string, lines: TflLineId[]): void { + const source = this.sources.get(userId) + if (!source) { + throw new UserNotFoundError(userId) + } + source.setLinesOfInterest(lines) + } + + removeUser(userId: string): void { + this.sources.delete(userId) + } +} diff --git a/bun.lock b/bun.lock index c3085e8..9f3ee3f 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "dependencies": { "@aris/core": "workspace:*", "@aris/source-location": "workspace:*", + "@aris/source-tfl": "workspace:*", "@aris/source-weatherkit": "workspace:*", "@hono/trpc-server": "^0.3", "@trpc/server": "^11", diff --git a/packages/aris-source-tfl/src/index.ts b/packages/aris-source-tfl/src/index.ts index c0a8d2b..2ef5cc2 100644 --- a/packages/aris-source-tfl/src/index.ts +++ b/packages/aris-source-tfl/src/index.ts @@ -2,6 +2,7 @@ export { TflSource } from "./tfl-source.ts" export { TflApi } from "./tfl-api.ts" export type { TflLineId } from "./tfl-api.ts" export type { + ITflApi, StationLocation, TflAlertData, TflAlertFeedItem, diff --git a/packages/aris-source-tfl/src/tfl-source.test.ts b/packages/aris-source-tfl/src/tfl-source.test.ts index 6a463be..e85704e 100644 --- a/packages/aris-source-tfl/src/tfl-source.test.ts +++ b/packages/aris-source-tfl/src/tfl-source.test.ts @@ -112,6 +112,49 @@ describe("TflSource", () => { }) }) + describe("setLinesOfInterest", () => { + const lineFilteringApi: ITflApi = { + async fetchLineStatuses(lines?: TflLineId[]): Promise { + const all: TflLineStatus[] = [ + { + lineId: "northern", + lineName: "Northern", + severity: "minor-delays", + description: "Delays", + }, + { lineId: "central", lineName: "Central", severity: "closure", description: "Closed" }, + ] + return lines ? all.filter((s) => lines.includes(s.lineId)) : all + }, + async fetchStations(): Promise { + return [] + }, + } + + test("changes which lines are fetched", async () => { + const source = new TflSource({ client: lineFilteringApi }) + const before = await source.fetchItems(createContext()) + expect(before.length).toBe(2) + + source.setLinesOfInterest(["northern"]) + const after = await source.fetchItems(createContext()) + + expect(after.length).toBe(1) + expect(after[0]!.data.line).toBe("northern") + }) + + test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => { + const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] }) + const filtered = await source.fetchItems(createContext()) + expect(filtered.length).toBe(1) + + source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST]) + const all = await source.fetchItems(createContext()) + + expect(all.length).toBe(2) + }) + }) + describe("fetchItems", () => { test("returns feed items array", async () => { const source = new TflSource({ client: api }) diff --git a/packages/aris-source-tfl/src/tfl-source.ts b/packages/aris-source-tfl/src/tfl-source.ts index a1e15c2..ad69a99 100644 --- a/packages/aris-source-tfl/src/tfl-source.ts +++ b/packages/aris-source-tfl/src/tfl-source.ts @@ -42,18 +42,46 @@ const SEVERITY_PRIORITY: Record = { * ``` */ export class TflSource implements FeedSource { + static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [ + "bakerloo", + "central", + "circle", + "district", + "hammersmith-city", + "jubilee", + "metropolitan", + "northern", + "piccadilly", + "victoria", + "waterloo-city", + "lioness", + "mildmay", + "windrush", + "weaver", + "suffragette", + "liberty", + "elizabeth", + ] + readonly id = "tfl" readonly dependencies = ["location"] private readonly client: ITflApi - private readonly lines?: TflLineId[] + private lines: TflLineId[] constructor(options: TflSourceOptions) { if (!options.client && !options.apiKey) { throw new Error("Either client or apiKey must be provided") } this.client = options.client ?? new TflApi(options.apiKey!) - this.lines = options.lines + this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST] + } + + /** + * Update the set of monitored lines. Takes effect on the next fetchItems call. + */ + setLinesOfInterest(lines: TflLineId[]): void { + this.lines = lines } async fetchItems(context: Context): Promise {