diff --git a/apps/aris-backend/src/feed/service.test.ts b/apps/aris-backend/src/feed/service.test.ts new file mode 100644 index 0000000..adce300 --- /dev/null +++ b/apps/aris-backend/src/feed/service.test.ts @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000..137017f --- /dev/null +++ b/apps/aris-backend/src/feed/service.ts @@ -0,0 +1,75 @@ +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/service.ts b/apps/aris-backend/src/location/service.ts index f82672b..b271421 100644 --- a/apps/aris-backend/src/location/service.ts +++ b/apps/aris-backend/src/location/service.ts @@ -1,11 +1,13 @@ 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 { +export class LocationService implements FeedSourceProvider { private sources = new Map() /**