diff --git a/apps/aelis-backend/src/engine/http.test.ts b/apps/aelis-backend/src/engine/http.test.ts index 7d75f03..e41d7b3 100644 --- a/apps/aelis-backend/src/engine/http.test.ts +++ b/apps/aelis-backend/src/engine/http.test.ts @@ -1,5 +1,6 @@ import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" +import { contextKey } from "@aelis/core" import { describe, expect, test } from "bun:test" import { Hono } from "hono" @@ -18,7 +19,11 @@ interface FeedResponse { errors: Array<{ sourceId: string; error: string }> } -function createStubSource(id: string, items: FeedItem[] = []): FeedSource { +function createStubSource( + id: string, + items: FeedItem[] = [], + contextEntries: readonly ContextEntry[] | null = null, +): FeedSource { return { id, async listActions(): Promise> { @@ -28,7 +33,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource { return undefined }, async fetchContext(): Promise { - return null + return contextEntries }, async fetchItems() { return items @@ -142,3 +147,147 @@ describe("GET /api/feed", () => { expect(body.errors[0]!.error).toBe("connection timeout") }) }) + +describe("GET /api/context", () => { + const weatherKey = contextKey("aelis.weather", "weather") + const weatherData = { temperature: 20, condition: "Clear" } + const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]] + + // The mock auth middleware always injects this hardcoded user ID + const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn" + + function buildContextApp(userId?: string) { + const manager = new UserSessionManager({ + providers: [() => createStubSource("weather", [], contextEntries)], + }) + const app = buildTestApp(manager, userId) + const session = manager.getOrCreate(mockUserId) + return { app, session } + } + + test("returns 401 without auth", async () => { + const manager = new UserSessionManager({ providers: [] }) + const app = buildTestApp(manager) + + const res = await app.request('/api/context?key=["aelis.weather","weather"]') + + expect(res.status).toBe(401) + }) + + test("returns 400 when key param is missing", async () => { + const { app } = buildContextApp("user-1") + + const res = await app.request("/api/context") + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("key") + }) + + test("returns 400 when key is invalid JSON", async () => { + const { app } = buildContextApp("user-1") + + const res = await app.request("/api/context?key=notjson") + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("key") + }) + + test("returns 400 when key is not an array", async () => { + const { app } = buildContextApp("user-1") + + const res = await app.request('/api/context?key="string"') + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("key") + }) + + test("returns 400 when match param is invalid", async () => { + const { app } = buildContextApp("user-1") + + const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid') + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("match") + }) + + test("returns exact match with match=exact", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact') + + expect(res.status).toBe(200) + const body = (await res.json()) as { match: string; value: unknown } + expect(body.match).toBe("exact") + expect(body.value).toEqual(weatherData) + }) + + test("returns 404 with match=exact when only prefix would match", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["aelis.weather"]&match=exact') + + expect(res.status).toBe(404) + }) + + test("returns prefix match with match=prefix", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix') + + expect(res.status).toBe(200) + const body = (await res.json()) as { + match: string + entries: Array<{ key: unknown[]; value: unknown }> + } + expect(body.match).toBe("prefix") + expect(body.entries).toHaveLength(1) + expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"]) + expect(body.entries[0]!.value).toEqual(weatherData) + }) + + test("default mode returns exact match when available", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["aelis.weather","weather"]') + + expect(res.status).toBe(200) + const body = (await res.json()) as { match: string; value: unknown } + expect(body.match).toBe("exact") + expect(body.value).toEqual(weatherData) + }) + + test("default mode falls back to prefix when no exact match", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["aelis.weather"]') + + expect(res.status).toBe(200) + const body = (await res.json()) as { + match: string + entries: Array<{ key: unknown[]; value: unknown }> + } + expect(body.match).toBe("prefix") + expect(body.entries).toHaveLength(1) + expect(body.entries[0]!.value).toEqual(weatherData) + }) + + test("returns 404 when neither exact nor prefix matches", async () => { + const { app, session } = buildContextApp("user-1") + await session.engine.refresh() + + const res = await app.request('/api/context?key=["nonexistent"]') + + expect(res.status).toBe(404) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("Context key not found") + }) +}) diff --git a/apps/aelis-backend/src/engine/http.ts b/apps/aelis-backend/src/engine/http.ts index 185926e..e2bc2c8 100644 --- a/apps/aelis-backend/src/engine/http.ts +++ b/apps/aelis-backend/src/engine/http.ts @@ -1,5 +1,6 @@ import type { Context, Hono } from "hono" +import { contextKey } from "@aelis/core" import { createMiddleware } from "hono/factory" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" @@ -26,6 +27,7 @@ export function registerFeedHttpHandlers( }) app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed) + app.get("/api/context", inject, authSessionMiddleware, handleGetContext) } async function handleGetFeed(c: Context) { @@ -43,3 +45,61 @@ async function handleGetFeed(c: Context) { })), }) } + +function handleGetContext(c: Context) { + const keyParam = c.req.query("key") + if (!keyParam) { + return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400) + } + + let parsed: unknown + try { + parsed = JSON.parse(keyParam) + } catch { + return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400) + } + + if (!Array.isArray(parsed)) { + return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400) + } + + const matchParam = c.req.query("match") + if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") { + return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400) + } + + const user = c.get("user")! + const sessionManager = c.get("sessionManager") + const session = sessionManager.getOrCreate(user.id) + const context = session.engine.currentContext() + const key = contextKey(...parsed) + + if (matchParam === "exact") { + const value = context.get(key) + if (value === undefined) { + return c.json({ error: "Context key not found" }, 404) + } + return c.json({ match: "exact", value }) + } + + if (matchParam === "prefix") { + const entries = context.find(key) + if (entries.length === 0) { + return c.json({ error: "Context key not found" }, 404) + } + return c.json({ match: "prefix", entries }) + } + + // Default: single find() covers both exact and prefix matches + const entries = context.find(key) + if (entries.length === 0) { + return c.json({ error: "Context key not found" }, 404) + } + + // If exactly one result with the same key length, treat as exact match + if (entries.length === 1 && entries[0]!.key.length === parsed.length) { + return c.json({ match: "exact", value: entries[0]!.value }) + } + + return c.json({ match: "prefix", entries }) +}