2026-03-10 19:19:23 +00:00
|
|
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
2026-02-24 22:30:13 +00:00
|
|
|
|
2026-03-13 00:17:54 +00:00
|
|
|
import { contextKey } from "@aelis/core"
|
2026-02-24 22:30:13 +00:00
|
|
|
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<string, unknown>
|
|
|
|
|
}>
|
|
|
|
|
errors: Array<{ sourceId: string; error: string }>
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:17:54 +00:00
|
|
|
function createStubSource(
|
|
|
|
|
id: string,
|
|
|
|
|
items: FeedItem[] = [],
|
|
|
|
|
contextEntries: readonly ContextEntry[] | null = null,
|
|
|
|
|
): FeedSource {
|
2026-02-24 22:30:13 +00:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
async executeAction(): Promise<unknown> {
|
|
|
|
|
return undefined
|
|
|
|
|
},
|
2026-03-01 22:52:41 +00:00
|
|
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
2026-03-13 00:17:54 +00:00
|
|
|
return contextEntries
|
2026-02-24 22:30:13 +00:00
|
|
|
},
|
|
|
|
|
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 () => {
|
2026-03-05 02:01:30 +00:00
|
|
|
const manager = new UserSessionManager({ providers: [] })
|
2026-02-24 22:30:13 +00:00
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-03-05 02:01:30 +00:00
|
|
|
const manager = new UserSessionManager({
|
|
|
|
|
providers: [() => createStubSource("test", items)],
|
|
|
|
|
})
|
2026-02-24 22:30:13 +00:00
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-03-05 02:01:30 +00:00
|
|
|
const manager = new UserSessionManager({
|
|
|
|
|
providers: [() => createStubSource("test", items)],
|
|
|
|
|
})
|
2026-02-24 22:30:13 +00:00
|
|
|
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")
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-03-05 02:01:30 +00:00
|
|
|
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
2026-02-24 22:30:13 +00:00
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-13 00:17:54 +00:00
|
|
|
|
|
|
|
|
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 key contains invalid element types", async () => {
|
|
|
|
|
const { app } = buildContextApp("user-1")
|
|
|
|
|
|
|
|
|
|
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
|
|
|
|
|
|
|
|
|
expect(res.status).toBe(400)
|
|
|
|
|
const body = (await res.json()) as { error: string }
|
|
|
|
|
expect(body.error).toContain("key")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("returns 400 when key is an empty array", async () => {
|
|
|
|
|
const { app } = buildContextApp("user-1")
|
|
|
|
|
|
|
|
|
|
const res = await app.request("/api/context?key=[]")
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
})
|