From 54e4b0dcf745dc4fc9a3cefb1381b85105911179 Mon Sep 17 00:00:00 2001 From: kenneth Date: Fri, 13 Feb 2026 19:21:09 +0000 Subject: [PATCH] feat(backend): add TflService Manages per-user TflSource instances with individual line configuration. Implements FeedSourceProvider so it can be wired into FeedEngineService. Adds TflSource.setLines() so line config can be mutated in place, keeping engine references valid. Also exports ITflApi from @aris/source-tfl for testability. Co-authored-by: Ona --- apps/aris-backend/package.json | 1 + apps/aris-backend/src/tfl/service.test.ts | 206 ++++++++++++++++++ apps/aris-backend/src/tfl/service.ts | 40 ++++ bun.lock | 1 + packages/aris-source-tfl/src/index.ts | 1 + .../aris-source-tfl/src/tfl-source.test.ts | 43 ++++ packages/aris-source-tfl/src/tfl-source.ts | 32 ++- 7 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 apps/aris-backend/src/tfl/service.test.ts create mode 100644 apps/aris-backend/src/tfl/service.ts 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 {