mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
2 Commits
ca2664b617
...
feat/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
b06adb8609
|
|||
|
f0e1953409
|
@@ -1,5 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
@@ -18,7 +19,11 @@ interface FeedResponse {
|
|||||||
errors: Array<{ sourceId: string; error: string }>
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
@@ -28,7 +33,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
return null
|
return contextEntries
|
||||||
},
|
},
|
||||||
async fetchItems() {
|
async fetchItems() {
|
||||||
return items
|
return items
|
||||||
@@ -142,3 +147,167 @@ describe("GET /api/feed", () => {
|
|||||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
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 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context, Hono } from "hono"
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
import { createMiddleware } from "hono/factory"
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
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/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||||
|
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetFeed(c: Context<Env>) {
|
async function handleGetFeed(c: Context<Env>) {
|
||||||
@@ -43,3 +45,74 @@ async function handleGetFeed(c: Context<Env>) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGetContext(c: Context<Env>) {
|
||||||
|
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) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
|
||||||
|
function isContextKeyPart(value: unknown): boolean {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
return Object.values(value).every(
|
||||||
|
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user