diff --git a/apps/aris-backend/src/auth/session-middleware.ts b/apps/aris-backend/src/auth/session-middleware.ts index cc4aba3..752b1a7 100644 --- a/apps/aris-backend/src/auth/session-middleware.ts +++ b/apps/aris-backend/src/auth/session-middleware.ts @@ -1,4 +1,4 @@ -import type { Context, Next } from "hono" +import type { Context, MiddlewareHandler, Next } from "hono" import type { AuthSession, AuthUser } from "./session.ts" @@ -9,6 +9,10 @@ export interface SessionVariables { session: AuthSession | null } +export type AuthSessionEnv = { Variables: SessionVariables } + +export type AuthSessionMiddleware = MiddlewareHandler + declare module "hono" { interface ContextVariableMap extends SessionVariables {} } @@ -55,3 +59,18 @@ export async function getSessionFromHeaders( const session = await auth.api.getSession({ headers }) return session } + +/** + * Test-only middleware that injects a fake user and session. + * Pass userId to simulate an authenticated request, or omit to get 401. + */ +export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware { + return async (c: Context, next: Next): Promise => { + if (!userId) { + return c.json({ error: "Unauthorized" }, 401) + } + c.set("user", { id: userId } as AuthUser) + c.set("session", { id: "mock-session" } as AuthSession) + await next() + } +} diff --git a/apps/aris-backend/src/feed/http.test.ts b/apps/aris-backend/src/feed/http.test.ts new file mode 100644 index 0000000..48fbca2 --- /dev/null +++ b/apps/aris-backend/src/feed/http.test.ts @@ -0,0 +1,140 @@ +import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core" + +import { describe, expect, test } from "bun:test" +import { Hono } from "hono" + +import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" +import { UserSessionManager } from "../session/index.ts" +import { registerFeedHttpHandlers } from "./http.ts" + +interface FeedResponse { + items: Array<{ + id: string + type: string + priority: number + timestamp: string + data: Record + }> + errors: Array<{ sourceId: string; error: string }> +} + +function createStubSource(id: string, items: FeedItem[] = []): FeedSource { + return { + id, + async listActions(): Promise> { + return {} + }, + async executeAction(): Promise { + return undefined + }, + async fetchContext(): Promise | null> { + return null + }, + async fetchItems() { + return items + }, + } +} + +function buildTestApp(sessionManager: UserSessionManager, userId?: string) { + const app = new Hono() + registerFeedHttpHandlers(app, { + sessionManager, + authSessionMiddleware: mockAuthSessionMiddleware(userId), + }) + return app +} + +describe("GET /api/feed", () => { + test("returns 401 without auth", async () => { + const manager = new UserSessionManager([]) + const app = buildTestApp(manager) + + const res = await app.request("/api/feed") + + expect(res.status).toBe(401) + }) + + test("returns cached feed when available", async () => { + const items: FeedItem[] = [ + { + id: "item-1", + type: "test", + priority: 0.8, + timestamp: new Date("2025-01-01T00:00:00.000Z"), + data: { value: 42 }, + }, + ] + const manager = new UserSessionManager([() => createStubSource("test", items)]) + const app = buildTestApp(manager, "user-1") + + // Prime the cache + const session = manager.getOrCreate("user-1") + await session.engine.refresh() + expect(session.engine.lastFeed()).not.toBeNull() + + const res = await app.request("/api/feed") + + expect(res.status).toBe(200) + const body = (await res.json()) as FeedResponse + expect(body.items).toHaveLength(1) + expect(body.items[0]!.id).toBe("item-1") + expect(body.items[0]!.type).toBe("test") + expect(body.items[0]!.priority).toBe(0.8) + expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z") + expect(body.errors).toHaveLength(0) + }) + + test("forces refresh when no cached feed", async () => { + const items: FeedItem[] = [ + { + id: "fresh-1", + type: "test", + priority: 0.5, + timestamp: new Date("2025-06-01T12:00:00.000Z"), + data: { fresh: true }, + }, + ] + const manager = new UserSessionManager([() => createStubSource("test", items)]) + const app = buildTestApp(manager, "user-1") + + // No prior refresh — lastFeed() returns null, handler should call refresh() + const res = await app.request("/api/feed") + + expect(res.status).toBe(200) + const body = (await res.json()) as FeedResponse + expect(body.items).toHaveLength(1) + expect(body.items[0]!.id).toBe("fresh-1") + expect(body.items[0]!.data.fresh).toBe(true) + expect(body.errors).toHaveLength(0) + }) + + test("serializes source errors as message strings", async () => { + const failingSource: FeedSource = { + id: "failing", + async listActions() { + return {} + }, + async executeAction() { + return undefined + }, + async fetchContext() { + return null + }, + async fetchItems() { + throw new Error("connection timeout") + }, + } + const manager = new UserSessionManager([() => failingSource]) + const app = buildTestApp(manager, "user-1") + + const res = await app.request("/api/feed") + + expect(res.status).toBe(200) + const body = (await res.json()) as FeedResponse + expect(body.items).toHaveLength(0) + expect(body.errors).toHaveLength(1) + expect(body.errors[0]!.sourceId).toBe("failing") + expect(body.errors[0]!.error).toBe("connection timeout") + }) +}) diff --git a/apps/aris-backend/src/feed/http.ts b/apps/aris-backend/src/feed/http.ts new file mode 100644 index 0000000..e269663 --- /dev/null +++ b/apps/aris-backend/src/feed/http.ts @@ -0,0 +1,41 @@ +import type { Context, Hono } from "hono" + +import { createMiddleware } from "hono/factory" + +import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" +import type { UserSessionManager } from "../session/index.ts" + +type Env = { Variables: { sessionManager: UserSessionManager } } + +interface FeedHttpHandlersDeps { + sessionManager: UserSessionManager + authSessionMiddleware: AuthSessionMiddleware +} + +export function registerFeedHttpHandlers( + app: Hono, + { sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps, +) { + const inject = createMiddleware(async (c, next) => { + c.set("sessionManager", sessionManager) + await next() + }) + + app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed) +} + +async function handleGetFeed(c: Context) { + const user = c.get("user")! + const sessionManager = c.get("sessionManager") + const session = sessionManager.getOrCreate(user.id) + + const feed = session.engine.lastFeed() ?? (await session.engine.refresh()) + + return c.json({ + items: feed.items, + errors: feed.errors.map((e) => ({ + sourceId: e.sourceId, + error: e.error.message, + })), + }) +} diff --git a/apps/aris-backend/src/server.ts b/apps/aris-backend/src/server.ts index 5b907a4..a350cce 100644 --- a/apps/aris-backend/src/server.ts +++ b/apps/aris-backend/src/server.ts @@ -2,6 +2,8 @@ import { LocationSource } from "@aris/source-location" import { Hono } from "hono" import { registerAuthHandlers } from "./auth/http.ts" +import { requireSession } from "./auth/session-middleware.ts" +import { registerFeedHttpHandlers } from "./feed/http.ts" import { registerLocationHttpHandlers } from "./location/http.ts" import { UserSessionManager } from "./session/index.ts" import { WeatherSourceProvider } from "./weather/provider.ts" @@ -24,6 +26,7 @@ function main() { app.get("/health", (c) => c.json({ status: "ok" })) registerAuthHandlers(app) + registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession }) registerLocationHttpHandlers(app, { sessionManager }) return app