diff --git a/apps/aris-backend/src/feed/service.test.ts b/apps/aris-backend/src/feed/service.test.ts deleted file mode 100644 index adce300..0000000 --- a/apps/aris-backend/src/feed/service.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, mock, test } from "bun:test" - -import { LocationService } from "../location/service.ts" -import { FeedEngineService } from "./service.ts" - -describe("FeedEngineService", () => { - test("engineForUser creates engine on first call", () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const engine = service.engineForUser("user-1") - - expect(engine).toBeDefined() - }) - - test("engineForUser returns same engine for same user", () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const engine1 = service.engineForUser("user-1") - const engine2 = service.engineForUser("user-1") - - expect(engine1).toBe(engine2) - }) - - test("engineForUser returns different engines for different users", () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const engine1 = service.engineForUser("user-1") - const engine2 = service.engineForUser("user-2") - - expect(engine1).not.toBe(engine2) - }) - - test("engineForUser registers sources from all providers", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const engine = service.engineForUser("user-1") - const result = await engine.refresh() - - expect(result.errors).toHaveLength(0) - }) - - test("engineForUser works with empty providers array", async () => { - const service = new FeedEngineService([]) - - const engine = service.engineForUser("user-1") - const result = await engine.refresh() - - expect(result.errors).toHaveLength(0) - expect(result.items).toHaveLength(0) - }) - - test("refresh returns feed result", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const result = await service.refresh("user-1") - - expect(result).toHaveProperty("context") - expect(result).toHaveProperty("items") - expect(result).toHaveProperty("errors") - expect(result.context.time).toBeInstanceOf(Date) - }) - - test("refresh uses location from LocationService", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - const location = { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - } - - // Create engine first, then update location - service.engineForUser("user-1") - locationService.updateUserLocation("user-1", location) - - const result = await service.refresh("user-1") - - expect(result.context.location).toEqual(location) - }) - - test("subscribe receives updates", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - const callback = mock() - - service.subscribe("user-1", callback) - - // Push location to trigger update - locationService.updateUserLocation("user-1", { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - }) - - // Wait for async update propagation - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(callback).toHaveBeenCalled() - }) - - test("subscribe returns unsubscribe function", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - const callback = mock() - - const unsubscribe = service.subscribe("user-1", callback) - - unsubscribe() - - locationService.updateUserLocation("user-1", { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - }) - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(callback).not.toHaveBeenCalled() - }) - - test("removeUser stops engine and removes it", async () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - const callback = mock() - - service.subscribe("user-1", callback) - - service.removeUser("user-1") - - // Push location - should not trigger update since engine is stopped - locationService.feedSourceForUser("user-1") - locationService.updateUserLocation("user-1", { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - }) - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(callback).not.toHaveBeenCalled() - }) - - test("removeUser allows new engine to be created", () => { - const locationService = new LocationService() - const service = new FeedEngineService([locationService]) - - const engine1 = service.engineForUser("user-1") - service.removeUser("user-1") - const engine2 = service.engineForUser("user-1") - - expect(engine1).not.toBe(engine2) - }) -}) diff --git a/apps/aris-backend/src/feed/service.ts b/apps/aris-backend/src/feed/service.ts deleted file mode 100644 index 137017f..0000000 --- a/apps/aris-backend/src/feed/service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { FeedEngine, type FeedResult, type FeedSource, type FeedSubscriber } from "@aris/core" - -/** - * Provides a FeedSource instance for a user. - */ -export interface FeedSourceProvider { - feedSourceForUser(userId: string): FeedSource -} - -/** - * Manages FeedEngine instances per user. - * - * Receives FeedSource instances from injected providers and wires them - * into per-user engines. Engines are auto-started on creation. - */ -export class FeedEngineService { - private engines = new Map() - - constructor(private readonly providers: FeedSourceProvider[]) {} - - /** - * Get or create a FeedEngine for a user. - * Automatically registers sources and starts the engine. - */ - engineForUser(userId: string): FeedEngine { - let engine = this.engines.get(userId) - if (!engine) { - engine = this.createEngine(userId) - this.engines.set(userId, engine) - } - return engine - } - - /** - * Refresh a user's feed. - */ - async refresh(userId: string): Promise { - const engine = this.engineForUser(userId) - return engine.refresh() - } - - /** - * Subscribe to feed updates for a user. - * Returns unsubscribe function. - */ - subscribe(userId: string, callback: FeedSubscriber): () => void { - const engine = this.engineForUser(userId) - return engine.subscribe(callback) - } - - /** - * Remove a user's FeedEngine. - * Stops the engine and cleans up resources. - */ - removeUser(userId: string): void { - const engine = this.engines.get(userId) - if (engine) { - engine.stop() - this.engines.delete(userId) - } - } - - private createEngine(userId: string): FeedEngine { - const engine = new FeedEngine() - - for (const provider of this.providers) { - const source = provider.feedSourceForUser(userId) - engine.register(source) - } - - engine.start() - - return engine - } -} diff --git a/apps/aris-backend/src/location/router.ts b/apps/aris-backend/src/location/router.ts index e35c602..0dcd47c 100644 --- a/apps/aris-backend/src/location/router.ts +++ b/apps/aris-backend/src/location/router.ts @@ -1,10 +1,7 @@ -import { TRPCError } from "@trpc/server" import { type } from "arktype" +import type { UserSessionManager } from "../session/index.ts" import type { TRPC } from "../trpc/router.ts" -import type { LocationService } from "./service.ts" - -import { UserNotFoundError } from "../lib/error.ts" const locationInput = type({ lat: "number", @@ -15,23 +12,17 @@ const locationInput = type({ export function createLocationRouter( t: TRPC, - { locationService }: { locationService: LocationService }, + { sessionManager }: { sessionManager: UserSessionManager }, ) { return t.router({ - update: t.procedure.input(locationInput).mutation(({ input, ctx }) => { - try { - locationService.updateUserLocation(ctx.user.id, { - lat: input.lat, - lng: input.lng, - accuracy: input.accuracy, - timestamp: input.timestamp, - }) - } catch (error) { - if (error instanceof UserNotFoundError) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }) - } - throw error - } + update: t.procedure.input(locationInput).mutation(async ({ input, ctx }) => { + const session = sessionManager.getOrCreate(ctx.user.id) + await session.engine.executeAction("aris.location", "update-location", { + lat: input.lat, + lng: input.lng, + accuracy: input.accuracy, + timestamp: input.timestamp, + }) }), }) } diff --git a/apps/aris-backend/src/location/service.test.ts b/apps/aris-backend/src/location/service.test.ts deleted file mode 100644 index bad7cfb..0000000 --- a/apps/aris-backend/src/location/service.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "bun:test" - -import { UserNotFoundError } from "../lib/error.ts" -import { LocationService } from "./service.ts" - -describe("LocationService", () => { - test("feedSourceForUser creates source on first call", () => { - const service = new LocationService() - const source = service.feedSourceForUser("user-1") - - expect(source).toBeDefined() - expect(source.id).toBe("aris.location") - }) - - test("feedSourceForUser returns same source for same user", () => { - const service = new LocationService() - 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 LocationService() - const source1 = service.feedSourceForUser("user-1") - const source2 = service.feedSourceForUser("user-2") - - expect(source1).not.toBe(source2) - }) - - test("updateUserLocation updates the source", () => { - const service = new LocationService() - const source = service.feedSourceForUser("user-1") - const location = { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - } - - service.updateUserLocation("user-1", location) - - expect(source.lastLocation).toEqual(location) - }) - - test("updateUserLocation throws if source does not exist", () => { - const service = new LocationService() - const location = { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - } - - expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError) - }) - - test("lastUserLocation returns null for unknown user", () => { - const service = new LocationService() - - expect(service.lastUserLocation("unknown")).toBeNull() - }) - - test("lastUserLocation returns last location", () => { - const service = new LocationService() - service.feedSourceForUser("user-1") - const location1 = { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - } - const location2 = { - lat: 52.0, - lng: -0.2, - accuracy: 5, - timestamp: new Date(), - } - - service.updateUserLocation("user-1", location1) - service.updateUserLocation("user-1", location2) - - expect(service.lastUserLocation("user-1")).toEqual(location2) - }) - - test("removeUser removes the source", () => { - const service = new LocationService() - service.feedSourceForUser("user-1") - const location = { - lat: 51.5074, - lng: -0.1278, - accuracy: 10, - timestamp: new Date(), - } - - service.updateUserLocation("user-1", location) - service.removeUser("user-1") - - expect(service.lastUserLocation("user-1")).toBeNull() - }) - - test("removeUser allows new source to be created", () => { - const service = new LocationService() - const source1 = service.feedSourceForUser("user-1") - - service.removeUser("user-1") - const source2 = service.feedSourceForUser("user-1") - - expect(source1).not.toBe(source2) - }) -}) diff --git a/apps/aris-backend/src/location/service.ts b/apps/aris-backend/src/location/service.ts deleted file mode 100644 index b271421..0000000 --- a/apps/aris-backend/src/location/service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { LocationSource, type Location } from "@aris/source-location" - -import type { FeedSourceProvider } from "../feed/service.ts" - -import { UserNotFoundError } from "../lib/error.ts" - -/** - * Manages LocationSource instances per user. - */ -export class LocationService implements FeedSourceProvider { - private sources = new Map() - - /** - * Get or create a LocationSource for a user. - * @param userId - The user's unique identifier - * @returns The user's LocationSource instance - */ - feedSourceForUser(userId: string): LocationSource { - let source = this.sources.get(userId) - if (!source) { - source = new LocationSource() - this.sources.set(userId, source) - } - return source - } - - /** - * Update location for a user. - * @param userId - The user's unique identifier - * @param location - The new location data - * @throws {UserNotFoundError} If no source exists for the user - */ - updateUserLocation(userId: string, location: Location): void { - const source = this.sources.get(userId) - if (!source) { - throw new UserNotFoundError(userId) - } - source.pushLocation(location) - } - - /** - * Get last known location for a user. - * @param userId - The user's unique identifier - * @returns The last location, or null if none exists - */ - lastUserLocation(userId: string): Location | null { - return this.sources.get(userId)?.lastLocation ?? null - } - - /** - * Remove a user's LocationSource. - * @param userId - The user's unique identifier - */ - removeUser(userId: string): void { - this.sources.delete(userId) - } -} diff --git a/apps/aris-backend/src/server.ts b/apps/aris-backend/src/server.ts index b9786b4..553a6f6 100644 --- a/apps/aris-backend/src/server.ts +++ b/apps/aris-backend/src/server.ts @@ -1,25 +1,27 @@ +import { LocationSource } from "@aris/source-location" import { trpcServer } from "@hono/trpc-server" import { Hono } from "hono" import { registerAuthHandlers } from "./auth/http.ts" -import { LocationService } from "./location/service.ts" +import { UserSessionManager } from "./session/index.ts" +import { WeatherSourceProvider } from "./weather/provider.ts" import { createContext } from "./trpc/context.ts" import { createTRPCRouter } from "./trpc/router.ts" -import { WeatherService } from "./weather/service.ts" function main() { - const locationService = new LocationService() + const sessionManager = new UserSessionManager([ + () => new LocationSource(), + new WeatherSourceProvider({ + credentials: { + privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, + keyId: process.env.WEATHERKIT_KEY_ID!, + teamId: process.env.WEATHERKIT_TEAM_ID!, + serviceId: process.env.WEATHERKIT_SERVICE_ID!, + }, + }), + ]) - const weatherService = new WeatherService({ - credentials: { - privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, - keyId: process.env.WEATHERKIT_KEY_ID!, - teamId: process.env.WEATHERKIT_TEAM_ID!, - serviceId: process.env.WEATHERKIT_SERVICE_ID!, - }, - }) - - const trpcRouter = createTRPCRouter({ locationService, weatherService }) + const trpcRouter = createTRPCRouter({ sessionManager }) const app = new Hono() diff --git a/apps/aris-backend/src/session/feed-source-provider.ts b/apps/aris-backend/src/session/feed-source-provider.ts new file mode 100644 index 0000000..ef1c1de --- /dev/null +++ b/apps/aris-backend/src/session/feed-source-provider.ts @@ -0,0 +1,9 @@ +import type { FeedSource } from "@aris/core" + +export interface FeedSourceProvider { + feedSourceForUser(userId: string): FeedSource +} + +export type FeedSourceProviderFn = (userId: string) => FeedSource + +export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn diff --git a/apps/aris-backend/src/session/index.ts b/apps/aris-backend/src/session/index.ts new file mode 100644 index 0000000..6f77741 --- /dev/null +++ b/apps/aris-backend/src/session/index.ts @@ -0,0 +1,7 @@ +export type { + FeedSourceProvider, + FeedSourceProviderFn, + FeedSourceProviderInput, +} from "./feed-source-provider.ts" +export { UserSession } from "./user-session.ts" +export { UserSessionManager } from "./user-session-manager.ts" diff --git a/apps/aris-backend/src/session/user-session-manager.test.ts b/apps/aris-backend/src/session/user-session-manager.test.ts new file mode 100644 index 0000000..187d2d2 --- /dev/null +++ b/apps/aris-backend/src/session/user-session-manager.test.ts @@ -0,0 +1,166 @@ +import { LocationSource } from "@aris/source-location" +import { describe, expect, mock, test } from "bun:test" + +import { WeatherSourceProvider } from "../weather/provider.ts" +import { UserSessionManager } from "./user-session-manager.ts" + +import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit" + +const mockWeatherClient: WeatherKitClient = { + fetch: async () => ({}) as WeatherKitResponse, +} + +describe("UserSessionManager", () => { + test("getOrCreate creates session on first call", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session = manager.getOrCreate("user-1") + + expect(session).toBeDefined() + expect(session.engine).toBeDefined() + }) + + test("getOrCreate returns same session for same user", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session1 = manager.getOrCreate("user-1") + const session2 = manager.getOrCreate("user-1") + + expect(session1).toBe(session2) + }) + + test("getOrCreate returns different sessions for different users", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session1 = manager.getOrCreate("user-1") + const session2 = manager.getOrCreate("user-2") + + expect(session1).not.toBe(session2) + }) + + test("each user gets independent source instances", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session1 = manager.getOrCreate("user-1") + const session2 = manager.getOrCreate("user-2") + + const source1 = session1.getSource("aris.location") + const source2 = session2.getSource("aris.location") + + expect(source1).not.toBe(source2) + }) + + test("remove destroys session and allows re-creation", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session1 = manager.getOrCreate("user-1") + manager.remove("user-1") + const session2 = manager.getOrCreate("user-1") + + expect(session1).not.toBe(session2) + }) + + test("remove is no-op for unknown user", () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + expect(() => manager.remove("unknown")).not.toThrow() + }) + + test("accepts function providers", async () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session = manager.getOrCreate("user-1") + const result = await session.engine.refresh() + + expect(result.errors).toHaveLength(0) + }) + + test("accepts object providers", () => { + const provider = new WeatherSourceProvider({ client: mockWeatherClient }) + const manager = new UserSessionManager([() => new LocationSource(), provider]) + + const session = manager.getOrCreate("user-1") + + expect(session.getSource("aris.weather")).toBeDefined() + }) + + test("accepts mixed providers", () => { + const provider = new WeatherSourceProvider({ client: mockWeatherClient }) + const manager = new UserSessionManager([() => new LocationSource(), provider]) + + const session = manager.getOrCreate("user-1") + + expect(session.getSource("aris.location")).toBeDefined() + expect(session.getSource("aris.weather")).toBeDefined() + }) + + test("refresh returns feed result through session", async () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session = manager.getOrCreate("user-1") + const result = await session.engine.refresh() + + expect(result).toHaveProperty("context") + expect(result).toHaveProperty("items") + expect(result).toHaveProperty("errors") + expect(result.context.time).toBeInstanceOf(Date) + }) + + test("location update via executeAction works", async () => { + const manager = new UserSessionManager([() => new LocationSource()]) + + const session = manager.getOrCreate("user-1") + await session.engine.executeAction("aris.location", "update-location", { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + }) + + const source = session.getSource("aris.location") + expect(source?.lastLocation?.lat).toBe(51.5074) + }) + + test("subscribe receives updates after location push", async () => { + const manager = new UserSessionManager([() => new LocationSource()]) + const callback = mock() + + const session = manager.getOrCreate("user-1") + session.engine.subscribe(callback) + + await session.engine.executeAction("aris.location", "update-location", { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + }) + + // Wait for async update propagation + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(callback).toHaveBeenCalled() + }) + + test("remove stops reactive updates", async () => { + const manager = new UserSessionManager([() => new LocationSource()]) + const callback = mock() + + const session = manager.getOrCreate("user-1") + session.engine.subscribe(callback) + + manager.remove("user-1") + + // Create new session and push location — old callback should not fire + const session2 = manager.getOrCreate("user-1") + await session2.engine.executeAction("aris.location", "update-location", { + lat: 51.5074, + lng: -0.1278, + accuracy: 10, + timestamp: new Date(), + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/apps/aris-backend/src/session/user-session-manager.ts b/apps/aris-backend/src/session/user-session-manager.ts new file mode 100644 index 0000000..441b0a1 --- /dev/null +++ b/apps/aris-backend/src/session/user-session-manager.ts @@ -0,0 +1,31 @@ +import type { FeedSourceProviderInput } from "./feed-source-provider.ts" +import { UserSession } from "./user-session.ts" + +export class UserSessionManager { + private sessions = new Map() + private readonly providers: FeedSourceProviderInput[] + + constructor(providers: FeedSourceProviderInput[]) { + this.providers = providers + } + + getOrCreate(userId: string): UserSession { + let session = this.sessions.get(userId) + if (!session) { + const sources = this.providers.map((p) => + typeof p === "function" ? p(userId) : p.feedSourceForUser(userId), + ) + session = new UserSession(sources) + this.sessions.set(userId, session) + } + return session + } + + remove(userId: string): void { + const session = this.sessions.get(userId) + if (session) { + session.destroy() + this.sessions.delete(userId) + } + } +} diff --git a/apps/aris-backend/src/session/user-session.test.ts b/apps/aris-backend/src/session/user-session.test.ts new file mode 100644 index 0000000..33c92da --- /dev/null +++ b/apps/aris-backend/src/session/user-session.test.ts @@ -0,0 +1,72 @@ +import type { ActionDefinition, Context, FeedSource } from "@aris/core" + +import { LocationSource } from "@aris/source-location" +import { describe, expect, test } from "bun:test" + +import { UserSession } from "./user-session.ts" + +function createStubSource(id: string): FeedSource { + return { + id, + async listActions(): Promise> { + return {} + }, + async executeAction(): Promise { + return undefined + }, + async fetchContext(): Promise | null> { + return null + }, + async fetchItems() { + return [] + }, + } +} + +describe("UserSession", () => { + test("registers sources and starts engine", async () => { + const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")]) + + const result = await session.engine.refresh() + + expect(result.errors).toHaveLength(0) + }) + + test("getSource returns registered source", () => { + const location = new LocationSource() + const session = new UserSession([location]) + + const result = session.getSource("aris.location") + + expect(result).toBe(location) + }) + + test("getSource returns undefined for unknown source", () => { + const session = new UserSession([createStubSource("test")]) + + expect(session.getSource("unknown")).toBeUndefined() + }) + + test("destroy stops engine and clears sources", () => { + const session = new UserSession([createStubSource("test")]) + + session.destroy() + + expect(session.getSource("test")).toBeUndefined() + }) + + test("engine.executeAction routes to correct source", async () => { + const location = new LocationSource() + const session = new UserSession([location]) + + await session.engine.executeAction("aris.location", "update-location", { + lat: 51.5, + lng: -0.1, + accuracy: 10, + timestamp: new Date(), + }) + + expect(location.lastLocation).toBeDefined() + expect(location.lastLocation!.lat).toBe(51.5) + }) +}) diff --git a/apps/aris-backend/src/session/user-session.ts b/apps/aris-backend/src/session/user-session.ts new file mode 100644 index 0000000..7e42a63 --- /dev/null +++ b/apps/aris-backend/src/session/user-session.ts @@ -0,0 +1,24 @@ +import { FeedEngine, type FeedSource } from "@aris/core" + +export class UserSession { + readonly engine: FeedEngine + private sources = new Map() + + constructor(sources: FeedSource[]) { + this.engine = new FeedEngine() + for (const source of sources) { + this.sources.set(source.id, source) + this.engine.register(source) + } + this.engine.start() + } + + getSource(sourceId: string): T | undefined { + return this.sources.get(sourceId) as T | undefined + } + + destroy(): void { + this.engine.stop() + this.sources.clear() + } +} diff --git a/apps/aris-backend/src/tfl/provider.ts b/apps/aris-backend/src/tfl/provider.ts new file mode 100644 index 0000000..cdd80b4 --- /dev/null +++ b/apps/aris-backend/src/tfl/provider.ts @@ -0,0 +1,19 @@ +import { TflSource, type ITflApi } from "@aris/source-tfl" + +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +export type TflSourceProviderOptions = + | { apiKey: string; client?: never } + | { apiKey?: never; client: ITflApi } + +export class TflSourceProvider implements FeedSourceProvider { + private readonly options: TflSourceProviderOptions + + constructor(options: TflSourceProviderOptions) { + this.options = options + } + + feedSourceForUser(_userId: string): TflSource { + return new TflSource(this.options) + } +} diff --git a/apps/aris-backend/src/tfl/service.test.ts b/apps/aris-backend/src/tfl/service.test.ts deleted file mode 100644 index be6d802..0000000 --- a/apps/aris-backend/src/tfl/service.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -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("aris.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("aris.tfl") - expect(service.feedSourceForUser("user-2").id).toBe("aris.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 deleted file mode 100644 index 7aa0e27..0000000 --- a/apps/aris-backend/src/tfl/service.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/apps/aris-backend/src/trpc/router.ts b/apps/aris-backend/src/trpc/router.ts index a4c151c..0141c52 100644 --- a/apps/aris-backend/src/trpc/router.ts +++ b/apps/aris-backend/src/trpc/router.ts @@ -1,16 +1,26 @@ import { initTRPC, TRPCError } from "@trpc/server" -import type { LocationService } from "../location/service.ts" -import type { WeatherService } from "../weather/service.ts" +import type { UserSessionManager } from "../session/index.ts" import type { Context } from "./context.ts" import { createLocationRouter } from "../location/router.ts" -interface AuthedContext { - user: NonNullable - session: NonNullable +export type TRPC = ReturnType + +export interface TRPCRouterDeps { + sessionManager: UserSessionManager } +export function createTRPCRouter({ sessionManager }: TRPCRouterDeps) { + const t = createTRPC() + + return t.router({ + location: createLocationRouter(t, { sessionManager }), + }) +} + +export type TRPCRouter = ReturnType + function createTRPC() { const t = initTRPC.context().create() @@ -32,19 +42,3 @@ function createTRPC() { } } -export type TRPC = ReturnType - -export interface TRPCRouterDeps { - locationService: LocationService - weatherService: WeatherService -} - -export function createTRPCRouter({ locationService }: TRPCRouterDeps) { - const t = createTRPC() - - return t.router({ - location: createLocationRouter(t, { locationService }), - }) -} - -export type TRPCRouter = ReturnType diff --git a/apps/aris-backend/src/weather/provider.ts b/apps/aris-backend/src/weather/provider.ts new file mode 100644 index 0000000..25d56e4 --- /dev/null +++ b/apps/aris-backend/src/weather/provider.ts @@ -0,0 +1,15 @@ +import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit" + +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +export class WeatherSourceProvider implements FeedSourceProvider { + private readonly options: WeatherSourceOptions + + constructor(options: WeatherSourceOptions) { + this.options = options + } + + feedSourceForUser(_userId: string): WeatherSource { + return new WeatherSource(this.options) + } +} diff --git a/apps/aris-backend/src/weather/service.test.ts b/apps/aris-backend/src/weather/service.test.ts deleted file mode 100644 index 8a4f64b..0000000 --- a/apps/aris-backend/src/weather/service.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { Context } from "@aris/core" - -import { LocationKey } from "@aris/source-location" -import { - Units, - WeatherFeedItemType, - type WeatherKitClient, - type WeatherKitResponse, -} from "@aris/source-weatherkit" -import { describe, expect, test } from "bun:test" - -import fixture from "../../../../packages/aris-source-weatherkit/fixtures/san-francisco.json" -import { WeatherService } from "./service.ts" - -const mockClient = createMockClient(fixture.response as WeatherKitResponse) - -function createMockClient(response: WeatherKitResponse): WeatherKitClient { - return { - fetch: async () => response, - } -} - -function createMockContext(location?: { lat: number; lng: number }): Context { - const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") } - if (location) { - ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() } - } - return ctx -} - -describe("WeatherService", () => { - test("feedSourceForUser creates source on first call", () => { - const service = new WeatherService({ client: mockClient }) - const source = service.feedSourceForUser("user-1") - - expect(source).toBeDefined() - expect(source.id).toBe("aris.weather") - }) - - test("feedSourceForUser returns same source for same user", () => { - const service = new WeatherService({ client: mockClient }) - 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 WeatherService({ client: mockClient }) - const source1 = service.feedSourceForUser("user-1") - const source2 = service.feedSourceForUser("user-2") - - expect(source1).not.toBe(source2) - }) - - test("feedSourceForUser applies hourly and daily limits", async () => { - const service = new WeatherService({ - client: mockClient, - hourlyLimit: 3, - dailyLimit: 2, - }) - const source = service.feedSourceForUser("user-1") - const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) - - const items = await source.fetchItems(context) - - const hourly = items.filter((i) => i.type === WeatherFeedItemType.hourly) - const daily = items.filter((i) => i.type === WeatherFeedItemType.daily) - - expect(hourly).toHaveLength(3) - expect(daily).toHaveLength(2) - }) - - test("feedSourceForUser applies units", async () => { - const service = new WeatherService({ - client: mockClient, - units: Units.imperial, - }) - const source = service.feedSourceForUser("user-1") - const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) - - const items = await source.fetchItems(context) - const current = items.find((i) => i.type === WeatherFeedItemType.current) - - expect(current).toBeDefined() - // Fixture has ~15.87°C, imperial should be ~60.6°F - expect(current!.data.temperature).toBeGreaterThan(50) - }) - - test("removeUser removes the source", () => { - const service = new WeatherService({ client: mockClient }) - service.feedSourceForUser("user-1") - - service.removeUser("user-1") - - // After removal, feedSourceForUser should create a new instance - const source2 = service.feedSourceForUser("user-1") - expect(source2).toBeDefined() - }) - - test("removeUser allows new source to be created", () => { - const service = new WeatherService({ client: mockClient }) - const source1 = service.feedSourceForUser("user-1") - - service.removeUser("user-1") - const source2 = service.feedSourceForUser("user-1") - - expect(source1).not.toBe(source2) - }) - - test("removeUser is no-op for unknown user", () => { - const service = new WeatherService({ client: mockClient }) - - expect(() => service.removeUser("unknown")).not.toThrow() - }) -}) diff --git a/apps/aris-backend/src/weather/service.ts b/apps/aris-backend/src/weather/service.ts deleted file mode 100644 index 9b2e9bf..0000000 --- a/apps/aris-backend/src/weather/service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit" - -import type { FeedSourceProvider } from "../feed/service.ts" - -/** - * Options forwarded to every per-user WeatherSource. - * Must include either `credentials` or `client` (same requirement as WeatherSourceOptions). - */ -export type WeatherServiceOptions = WeatherSourceOptions - -/** - * Manages WeatherSource instances per user. - */ -export class WeatherService implements FeedSourceProvider { - private sources = new Map() - private readonly options: WeatherServiceOptions - - constructor(options: WeatherServiceOptions) { - this.options = options - } - - /** - * Get or create a WeatherSource for a user. - */ - feedSourceForUser(userId: string): WeatherSource { - let source = this.sources.get(userId) - if (!source) { - source = new WeatherSource(this.options) - this.sources.set(userId, source) - } - return source - } - - /** - * Remove a user's WeatherSource. - */ - removeUser(userId: string): void { - this.sources.delete(userId) - } -}