mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
feat(backend): wire feed renderers into API
Add FeedRenderer and FeedRendererProvider to support server-side rendering of feed items via ?render=json-render query param. - FeedRenderer maps sourceId to FeedItemRenderer, renders matching items and drops the rest - FeedRendererProvider mirrors FeedSourceProvider pattern for per-user renderer construction - UserSession exposes renderer, handler converts JrxNode to Spec - Returns 400 for unknown render format, 500 if renderer missing Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
import type {
|
||||
ActionDefinition,
|
||||
ContextEntry,
|
||||
FeedItem,
|
||||
FeedItemRenderer,
|
||||
FeedSource,
|
||||
} from "@aelis/core"
|
||||
import type { Spec } from "@json-render/core"
|
||||
|
||||
import { contextKey } from "@aelis/core"
|
||||
import { JRX_NODE } from "@nym.sh/jrx"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { FeedRenderer } from "../session/feed-renderer.ts"
|
||||
import { UserSessionManager } from "../session/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./http.ts"
|
||||
|
||||
@@ -19,6 +28,17 @@ interface FeedResponse {
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
interface RenderedFeedResponse {
|
||||
items: Array<{
|
||||
id: string
|
||||
type: string
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
ui: Spec
|
||||
}>
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
function createStubSource(
|
||||
id: string,
|
||||
items: FeedItem[] = [],
|
||||
@@ -150,6 +170,168 @@ describe("GET /api/feed", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /api/feed?render=json-render", () => {
|
||||
const stubRenderer: FeedItemRenderer = (item) => ({
|
||||
$$typeof: JRX_NODE,
|
||||
type: "FeedCard",
|
||||
props: {},
|
||||
children: [
|
||||
{
|
||||
$$typeof: JRX_NODE,
|
||||
type: "SansSerifText",
|
||||
props: { content: `Rendered: ${item.data.value}` },
|
||||
children: [],
|
||||
key: undefined,
|
||||
visible: undefined,
|
||||
on: undefined,
|
||||
repeat: undefined,
|
||||
watch: undefined,
|
||||
},
|
||||
],
|
||||
key: undefined,
|
||||
visible: undefined,
|
||||
on: undefined,
|
||||
repeat: undefined,
|
||||
watch: undefined,
|
||||
})
|
||||
|
||||
const rendererProvider = {
|
||||
feedRendererForUser: () => new FeedRenderer({ "test-source": stubRenderer }),
|
||||
}
|
||||
|
||||
test("returns rendered items with ui field as Spec", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test-source",
|
||||
type: "renderable",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: "hello" },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
rendererProvider,
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed?render=json-render")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as RenderedFeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("item-1")
|
||||
expect(body.items[0]!.ui).toBeDefined()
|
||||
expect(body.items[0]!.ui.root).toBeDefined()
|
||||
expect(body.items[0]!.ui.elements).toBeDefined()
|
||||
})
|
||||
|
||||
test("drops items without a renderer", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "renderable-1",
|
||||
sourceId: "test-source",
|
||||
type: "renderable",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: "yes" },
|
||||
},
|
||||
{
|
||||
id: "unrenderable-1",
|
||||
sourceId: "other-source",
|
||||
type: "no-renderer",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: "no" },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
rendererProvider,
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed?render=json-render")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as RenderedFeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("renderable-1")
|
||||
})
|
||||
|
||||
test("returns empty items when no renderers match", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "other-source",
|
||||
type: "no-renderer",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
rendererProvider,
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed?render=json-render")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as RenderedFeedResponse
|
||||
expect(body.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("returns 400 for unknown render format", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test")],
|
||||
rendererProvider,
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed?render=unknown")
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toContain("unknown")
|
||||
})
|
||||
|
||||
test("returns 500 when renderer is not available", async () => {
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test")],
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed?render=json-render")
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toContain("not available")
|
||||
})
|
||||
|
||||
test("without render param returns raw items (no ui field)", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test-source",
|
||||
type: "renderable",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
rendererProvider,
|
||||
})
|
||||
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(1)
|
||||
expect(body.items[0]!).not.toHaveProperty("ui")
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /api/context", () => {
|
||||
const weatherKey = contextKey("aelis.weather", "weather")
|
||||
const weatherData = { temperature: 20, condition: "Clear" }
|
||||
|
||||
Reference in New Issue
Block a user