ograph/packages/engine/src/index.test.ts
小墨 bc12a4bb18 feat(engine): API Key read access for events, objects, projections (#36)
- Auth middleware now accepts API Key for GET on data endpoints
  (/events, /objects, /projections, /event-defs, /projection-defs, /object-defs)
- Admin write ops (POST defs, reactions, api-keys) still require API_TOKEN
- Valid API Key + admin op → 403 Forbidden
- Invalid token → 401 Unauthorized
- Add 5 tests for API Key read access (#37)

closes #36
2026-04-13 09:51:09 +00:00

2666 lines
100 KiB
TypeScript

// OGraph v2.4 Tests
// object_defs 回归单表 + version 表加 parent_hash + value_schema NOT NULL
import { describe, it, expect, beforeEach } from 'vitest'
import appExport from './index'
const API_TOKEN = 'test-token-secret'
const VERSION = '2.4.0'
// ============================================
// D1 Mock
// ============================================
function createD1Mock(tables: Record<string, Record<string, unknown>[]>) {
function findRows(sql: string, binds: unknown[]): Record<string, unknown>[] {
const sqlLower = sql.toLowerCase().trim()
if (sqlLower.startsWith('select')) {
const tableMatch = sql.match(/FROM\s+(\w+)/i)
if (!tableMatch) return []
const table = tableMatch[1]
let rows = tables[table] ?? []
let bindIdx = 0
// Handle JOIN queries (supports multiple JOINs)
if (sqlLower.includes('join')) {
const fromAliasMatch = sql.match(/FROM\s+(\w+)\s+(\w+)/i)
const fromAlias = fromAliasMatch && fromAliasMatch[2].toUpperCase() !== 'JOIN' ? fromAliasMatch[2] : null
const joinRegex = /JOIN\s+(\w+)\s+(\w+)\s+ON\s+((?:(?!JOIN).)+?)(?=\s+JOIN|\s+WHERE|\s+ORDER|\s+LIMIT|\s*$)/gi
let joinMatches = [...sql.matchAll(joinRegex)]
if (joinMatches.length === 0) {
const simpleJoinMatch = sql.match(/JOIN\s+(\w+)\s+(\w+)\s+ON\s+(.+?)(?:\s+WHERE|\s+ORDER|\s+LIMIT|\s*$)/i)
if (simpleJoinMatch) joinMatches = [simpleJoinMatch as unknown as RegExpExecArray]
}
for (const jm of joinMatches) {
const joinTable = jm[1]
const joinAlias = jm[2]
const onClause = jm[3].trim()
const onConditions = onClause.split(/\s+AND\s+/i)
const joinTableRows = tables[joinTable] || []
// Pre-scan for ? placeholders to know which binds to consume
const condBindIndices: number[] = []
for (const cond of onConditions) {
if (/=\s*\?/.test(cond)) {
condBindIndices.push(bindIdx++)
}
}
// Reset bindIdx to re-consume during actual matching
bindIdx -= condBindIndices.length
const newRows: Record<string, unknown>[] = []
for (const row of rows) {
for (const joinRow of joinTableRows) {
let allMatch = true
let localBindIdx = bindIdx
for (const cond of onConditions) {
const eqParts = cond.match(/(\w+)\.(\w+)\s*=\s*(?:(\w+)\.(\w+)|(\?))/i)
if (!eqParts) {
allMatch = false
break
}
const [, lAlias, lCol, rAlias, rCol, isParam] = eqParts
const resolveVal = (alias: string, col: string) => {
if (alias === joinAlias) return joinRow[col]
if (alias === fromAlias || alias === table) return row[col]
return row[col]
}
if (isParam === '?') {
const lVal = resolveVal(lAlias, lCol)
const paramVal = binds[localBindIdx++]
if (lVal !== paramVal) {
allMatch = false
break
}
} else if (rAlias && rCol) {
const lVal = resolveVal(lAlias, lCol)
const rVal = resolveVal(rAlias!, rCol!)
if (lVal !== rVal) {
allMatch = false
break
}
}
}
if (allMatch) newRows.push({ ...row, ...joinRow })
}
}
bindIdx += condBindIndices.length
rows = newRows
}
}
const whereMatch = sql.match(/WHERE\s+(.+?)(?:\s+ORDER|\s+LIMIT|\s+$|$)/is)
if (whereMatch) {
const whereClause = whereMatch[1].trim()
const conditions = whereClause.split(/\s+AND\s+/i)
for (const cond of conditions) {
if (cond === '1=1') continue
const inMatch = cond.match(/(\w+(?:\.\w+)?)\s+IN\s*\(([^)]+)\)/i)
if (inMatch) {
const colExpr = inMatch[1]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const placeholders = inMatch[2].split(',').map((p) => p.trim())
const inValues: unknown[] = []
for (const p of placeholders) {
if (p === '?') inValues.push(binds[bindIdx++])
}
rows = rows.filter((r) => inValues.includes(r[col]))
continue
}
const gtMatch = cond.match(/(\w+(?:\.\w+)?)\s*>\s*\?/)
if (gtMatch) {
const colExpr = gtMatch[1]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const val = binds[bindIdx++] as number
rows = rows.filter((r) => (r[col] as number) > val)
continue
}
const eqMatch = cond.match(/(\w+(?:\.\w+)?)\s*=\s*\?/)
if (eqMatch) {
const colExpr = eqMatch[1]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const val = binds[bindIdx++]
rows = rows.filter((r) => r[col] === val)
}
}
}
const orderMatch = sql.match(/ORDER\s+BY\s+(.+?)(?:\s+LIMIT|\s*$)/i)
if (orderMatch) {
const orderClauses = orderMatch[1].split(',').map((c) => c.trim())
const orderSpecs = orderClauses.map((clause) => {
const parts = clause.split(/\s+/)
const colExpr = parts[0]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const dir = (parts[1] || 'ASC').toUpperCase()
return { col, dir }
})
rows = [...rows].sort((a, b) => {
for (const { col, dir } of orderSpecs) {
const aVal = a[col]
const bVal = b[col]
if (aVal < bVal) return dir === 'ASC' ? -1 : 1
if (aVal > bVal) return dir === 'ASC' ? 1 : -1
}
return 0
})
}
// Handle SELECT DISTINCT (non-COUNT)
if (sqlLower.includes('select distinct') && !sqlLower.includes('count(distinct')) {
const distinctColMatch = sql.match(/SELECT\s+DISTINCT\s+(\w+(?:\.\w+)?)/i)
if (distinctColMatch) {
const colExpr = distinctColMatch[1]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const seen = new Set()
rows = rows.filter((r) => {
if (seen.has(r[col])) return false
seen.add(r[col])
return true
})
}
}
// Handle COUNT(*)
if (sqlLower.includes('count(*)') || sqlLower.includes('count(distinct')) {
const aliasMatch = sql.match(/COUNT\([^)]*\)\s+(?:as\s+)?(\w+)/i)
const alias = aliasMatch ? aliasMatch[1] : 'count'
const distinctMatch = sql.match(/COUNT\(DISTINCT\s+(\w+(?:\.\w+)?)\)/i)
if (distinctMatch) {
const colExpr = distinctMatch[1]
const col = colExpr.includes('.') ? colExpr.split('.')[1] : colExpr
const uniqueVals = new Set(rows.map((r) => r[col]))
return [{ [alias]: uniqueVals.size }]
}
return [{ [alias]: rows.length }]
}
// Handle LIMIT and OFFSET
const limitMatch = sql.match(/LIMIT\s+(\?|\d+)(?:\s+OFFSET\s+(\?|\d+))?/i)
if (limitMatch) {
const limitVal = limitMatch[1] === '?' ? (binds[bindIdx++] as number) : parseInt(limitMatch[1], 10)
const offsetVal = limitMatch[2]
? limitMatch[2] === '?'
? (binds[bindIdx++] as number)
: parseInt(limitMatch[2], 10)
: 0
rows = rows.slice(offsetVal, offsetVal + limitVal)
}
return rows
}
return []
}
const autoIncrementCounters: Record<string, number> = {}
const autoIncrementTables = new Set(['objects', 'events', 'reactions', 'reaction_logs', 'api_keys', 'request_logs'])
return {
prepare: (sql: string) => {
const binds: unknown[] = []
return {
bind(...args: unknown[]) {
binds.push(...args)
return this
},
run() {
const sqlLower = sql.toLowerCase().trim()
let lastRowId = 0
if (sqlLower.startsWith('insert')) {
const tableMatch = sql.match(/INTO\s+(\w+)/i)
if (tableMatch) {
const table = tableMatch[1]
if (!tables[table]) tables[table] = []
const valuesMatch = sql.match(/VALUES\s*\(([^)]+)\)/i)
if (valuesMatch) {
const placeholders = valuesMatch[1].split(',').map((p) => p.trim())
const columnsMatch = sql.match(/\(([^)]+)\)\s*VALUES/i)
const columns = columnsMatch ? columnsMatch[1].split(',').map((c) => c.trim()) : []
const row: Record<string, unknown> = {}
columns.forEach((col, i) => {
if (placeholders[i] === '?') {
row[col] = binds[i]
}
})
if (sqlLower.includes('or replace')) {
const compositePKs: Record<string, string[]> = { reaction_kv: ['reaction_id', 'key'] }
const pkCols = compositePKs[table] || [columns[0]]
const existingIdx = tables[table].findIndex((r) => pkCols.every((col) => r[col] === row[col]))
if (existingIdx >= 0) {
tables[table][existingIdx] = row
} else {
tables[table].push(row)
}
} else if (sqlLower.includes('or ignore')) {
const pkCol = columns[0]
const exists = tables[table].find((r) => r[pkCol] === row[pkCol])
if (!exists) tables[table].push(row)
} else if (sqlLower.includes('on conflict')) {
const conflictMatch = sql.match(/ON\s+CONFLICT\s*\(([^)]+)\)/i)
if (conflictMatch) {
const conflictCols = conflictMatch[1].split(',').map((c) => c.trim())
const existingIdx = tables[table].findIndex((r) => conflictCols.every((col) => r[col] === row[col]))
if (existingIdx >= 0) {
const updateMatch = sql.match(/DO\s+UPDATE\s+SET\s+(.+?)$/i)
if (updateMatch) {
const updates = updateMatch[1].split(',').map((u) => u.trim())
for (const u of updates) {
const setMatch = u.match(/(\w+)\s*=\s*excluded\.(\w+)/i)
if (setMatch) {
tables[table][existingIdx][setMatch[1]] = row[setMatch[2]]
}
}
}
} else {
tables[table].push(row)
}
}
} else {
if (autoIncrementTables.has(table)) {
if (!autoIncrementCounters[table]) autoIncrementCounters[table] = 0
autoIncrementCounters[table]++
row.id = autoIncrementCounters[table]
lastRowId = autoIncrementCounters[table]
}
tables[table].push(row)
}
}
}
} else if (sqlLower.startsWith('update')) {
const tableMatch = sql.match(/UPDATE\s+(\w+)/i)
if (tableMatch) {
const table = tableMatch[1]
const setMatch = sql.match(/SET\s+(.+?)\s+WHERE/i)
const whereMatch = sql.match(/WHERE\s+(.+)/i)
if (setMatch && whereMatch) {
const setClauses = setMatch[1].split(',').map((s) => s.trim())
const whereCond = whereMatch[1].trim()
const whereEq = whereCond.match(/(\w+)\s*=\s*\?/)
let setBindIdx = 0
const setUpdates: Array<{ col: string; val: unknown }> = []
for (const clause of setClauses) {
const m = clause.match(/(\w+)\s*=\s*\?/)
if (m) setUpdates.push({ col: m[1], val: binds[setBindIdx++] })
}
if (whereEq) {
const whereCol = whereEq[1]
const whereVal = binds[setBindIdx]
for (const row of tables[table] || []) {
if (row[whereCol] === whereVal) {
for (const u of setUpdates) row[u.col] = u.val
}
}
}
}
}
} else if (sqlLower.startsWith('delete')) {
const tableMatch = sql.match(/FROM\s+(\w+)/i)
if (tableMatch) {
const table = tableMatch[1]
const whereMatch = sql.match(/WHERE\s+(.+)/i)
if (whereMatch) {
const cond = whereMatch[1].trim()
const notInMatch = cond.match(
/(\w+)\s*=\s*\?\s+AND\s+(\w+)\s+NOT\s+IN\s*\(\s*SELECT\s+(\w+)\s+FROM\s+(\w+)\s+WHERE\s+(\w+)\s*=\s*\?\s+ORDER\s+BY\s+(\w+)\s+DESC\s+LIMIT\s+(\d+)\s*\)/i,
)
if (notInMatch) {
const filterCol = notInMatch[1]
const filterVal = binds[0]
const idCol = notInMatch[2]
const subIdCol = notInMatch[3]
const subFilterCol = notInMatch[5]
const subFilterVal = binds[1]
const orderCol = notInMatch[6]
const limitNum = parseInt(notInMatch[7], 10)
const subRows = (tables[table] || [])
.filter((r) => r[subFilterCol] === subFilterVal)
.sort((a, b) => (b[orderCol] as number) - (a[orderCol] as number))
.slice(0, limitNum)
const keepIds = new Set(subRows.map((r) => r[subIdCol]))
tables[table] = (tables[table] || []).filter(
(r) => !(r[filterCol] === filterVal && !keepIds.has(r[idCol])),
)
} else {
const conditions = cond.split(/\s+AND\s+/i)
if (conditions.length > 1) {
let delBindIdx = 0
tables[table] = (tables[table] || []).filter((r) => {
let matchAll = true
let localIdx = delBindIdx
for (const c of conditions) {
const em = c.match(/(\w+)\s*=\s*\?/)
if (em) {
if (r[em[1]] !== binds[localIdx++]) matchAll = false
}
}
return !matchAll
})
delBindIdx += conditions.length
} else {
const eqMatch = cond.match(/(\w+)\s*=\s*\?/)
if (eqMatch) {
const col = eqMatch[1]
const val = binds[0]
tables[table] = (tables[table] || []).filter((r) => r[col] !== val)
}
}
}
} else {
tables[table] = []
}
}
}
return { success: true, meta: { last_row_id: lastRowId } }
},
all<T>() {
const rows = findRows(sql, binds)
return { results: rows as T[] }
},
first<T>() {
const rows = findRows(sql, binds)
return rows[0] as T | null
},
}
},
} as unknown as D1Database
}
// ============================================
// Test Setup
// ============================================
let app: ReturnType<typeof appExport>
let db: D1Database
let tables: Record<string, Record<string, unknown>[]>
beforeEach(() => {
tables = {
object_defs: [],
event_def_versions: [],
event_def_names: [],
projection_def_versions: [],
projection_def_names: [],
projection_def_sources: [],
objects: [],
events: [],
event_refs: [],
projections: [],
reactions: [],
reaction_logs: [],
api_keys: [],
reaction_kv: [],
request_logs: [],
}
db = createD1Mock(tables)
app = appExport
})
function req(method: string, path: string, body?: unknown, token = API_TOKEN) {
const headers: HeadersInit = { 'Content-Type': 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
return new Request(`http://test${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
}
// ============================================
// Health & Auth
// ============================================
describe('Health', () => {
it('GET /health returns ok + version', async () => {
const res = await app.fetch(req('GET', '/health'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ status: 'ok', version: VERSION })
})
})
describe('Auth', () => {
it('returns 401 without token', async () => {
const res = await app.fetch(req('POST', '/object-defs', { name: 'test' }, ''), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(401)
})
it('returns 401 with wrong token', async () => {
const res = await app.fetch(req('POST', '/object-defs', { name: 'test' }, 'wrong'), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(401)
})
})
// ============================================
// Schema
// ============================================
describe('Schema', () => {
it('GET /schema returns empty arrays on empty database', async () => {
const res = await app.fetch(req('GET', '/schema', undefined, ''), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.object_defs).toEqual([])
expect(json.event_defs).toEqual([])
expect(json.projection_defs).toEqual([])
})
it('GET /schema returns full schema after definitions are created', async () => {
// Create object defs
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
// Create event def
const eventSchema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema: eventSchema }), {
DB: db,
API_TOKEN: API_TOKEN,
})
// Create projection def
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// GET /schema (no auth needed)
const res = await app.fetch(req('GET', '/schema', undefined, ''), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
// object_defs
expect(json.object_defs).toHaveLength(2)
expect(json.object_defs.map((d: any) => d.name).sort()).toEqual(['agent', 'task'])
// event_defs
expect(json.event_defs).toHaveLength(1)
expect(json.event_defs[0].name).toBe('task_assigned')
expect(json.event_defs[0].hash).toBeDefined()
expect(json.event_defs[0].schema).toEqual(eventSchema)
// projection_defs
expect(json.projection_defs).toHaveLength(1)
expect(json.projection_defs[0].name).toBe('current_assignee')
expect(json.projection_defs[0].params).toEqual({ task_id: { type: 'ref' } })
expect(json.projection_defs[0].value_schema).toEqual({ type: 'ref' })
})
it('GET /schema does not require auth', async () => {
// Request with no token at all
const request = new Request('http://test/schema', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
const res = await app.fetch(request, { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.object_defs).toBeDefined()
expect(json.event_defs).toBeDefined()
expect(json.projection_defs).toBeDefined()
})
})
// ============================================
// Object Defs
// ============================================
describe('Object Defs', () => {
it('POST /object-defs creates object_def (no version/hash)', async () => {
const res = await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.name).toBe('agent')
expect(json).not.toHaveProperty('hash') // v2.4: no hash for object_defs
expect(tables.object_defs).toHaveLength(1)
expect(tables.object_defs[0]).toEqual({ name: 'agent' })
})
it('POST /object-defs with same name is idempotent (INSERT OR IGNORE)', async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const res2 = await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res2.status).toBe(201)
expect(tables.object_defs).toHaveLength(1)
})
it('GET /object-defs lists defs without hash', async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/object-defs'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.object_defs).toHaveLength(2)
expect(json.total).toBe(2)
expect(json.object_defs[0]).toEqual({ name: 'agent' })
})
})
// ============================================
// Objects
// ============================================
describe('Objects', () => {
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /objects creates object with type=name and integer id', async () => {
const res = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(typeof json.id).toBe('number')
expect(json.type).toBe('agent')
expect(tables.objects).toHaveLength(1)
expect(tables.objects[0].type).toBe('agent')
})
it('GET /objects/:id returns object', async () => {
const created = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const { id } = await created.json()
const res = await app.fetch(req('GET', `/objects/${id}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.id).toBe(id)
expect(json.type).toBe('agent')
})
it('GET /objects?type=X filters by name directly', async () => {
await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/objects?type=agent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.objects).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.objects[0].type).toBe('agent')
})
it('GET /objects with pagination (limit & offset)', async () => {
await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/objects?limit=1&offset=0'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.objects).toHaveLength(1)
expect(json.total).toBe(3)
})
})
// ============================================
// Event Defs
// ============================================
describe('Event Defs', () => {
it('POST /event-defs creates version + name pointer (with parent_hash=null)', async () => {
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
const res = await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(201)
const json = await res.json()
expect(json.name).toBe('task_assigned')
expect(json.hash).toBeDefined()
expect(tables.event_def_versions).toHaveLength(1)
expect(tables.event_def_versions[0].parent_hash).toBeNull() // 首版
expect(tables.event_def_versions[0].name).toBe('task_assigned') // v2.4: version 存 name
expect(tables.event_def_names).toHaveLength(1)
})
it('POST /event-defs with different schema creates new version (with parent_hash)', async () => {
const schema1 = { properties: { participant: { type: 'ref' as const } } }
const schema2 = { properties: { participant: { type: 'string' as const } } }
const res1 = await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema: schema1 }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const { hash: hash1 } = await res1.json()
const res2 = await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema: schema2 }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res2.status).toBe(201)
expect(tables.event_def_versions).toHaveLength(2) // two different hashes
const v2 = tables.event_def_versions.find((v) => v.hash !== hash1)
expect(v2?.parent_hash).toBe(hash1) // v2.4: parent_hash 指向前一版本
expect(tables.event_def_names).toHaveLength(1) // name points to latest
})
it('GET /event-defs lists defs with parent_hash', async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/event-defs'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.event_defs).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.event_defs[0].name).toBe('task_assigned')
expect(json.event_defs[0].hash).toBeDefined()
expect(json.event_defs[0].parent_hash).toBeNull()
expect(json.event_defs[0].schema).toEqual(schema)
})
})
// ============================================
// Events
// ============================================
describe('Events', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /events creates event with resolved type_hash and type_name', async () => {
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(201)
const json = await res.json()
expect(typeof json.event.id).toBe('number')
expect(json.event.type_hash).toBeDefined()
expect(json.event.type_name).toBe('task_assigned')
expect(json.event.payload).toEqual(payload)
expect(tables.events).toHaveLength(1)
expect(tables.event_refs).toHaveLength(2)
})
it('POST /events validates ref type against object.type name (not hash)', async () => {
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(201)
})
it('GET /events/:id returns event with type_name', async () => {
const created = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const { event } = await created.json()
const res = await app.fetch(req('GET', `/events/${event.id}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.id).toBe(event.id)
expect(json.type_name).toBe('task_assigned')
expect(json.payload).toEqual({ participant: agentId, subject: taskId })
})
it('GET /events?ref=X returns events by ref with type_name', async () => {
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{
DB: db,
API_TOKEN: API_TOKEN,
},
)
const res = await app.fetch(req('GET', `/events?ref=${taskId}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.events).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.events[0].type_name).toBe('task_assigned')
})
})
// ============================================
// Projection Defs (v2.4: value_schema + initial_value NOT NULL)
// ============================================
describe('Projection Defs', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /projection-defs requires value_schema and initial_value', async () => {
const body = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
const res = await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.name).toBe('current_assignee')
expect(json.hash).toBeDefined()
expect(tables.projection_def_versions).toHaveLength(1)
expect(tables.projection_def_versions[0].parent_hash).toBeNull()
expect(tables.projection_def_versions[0].name).toBe('current_assignee')
})
it('POST /projection-defs with different definition creates new version (with parent_hash)', async () => {
const body1 = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
const body2 = {
...body1,
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.id',
},
],
}
const res1 = await app.fetch(req('POST', '/projection-defs', body1), { DB: db, API_TOKEN: API_TOKEN })
const { hash: hash1 } = await res1.json()
const res2 = await app.fetch(req('POST', '/projection-defs', body2), { DB: db, API_TOKEN: API_TOKEN })
expect(res2.status).toBe(201)
expect(tables.projection_def_versions).toHaveLength(2)
const v2 = tables.projection_def_versions.find((v) => v.hash !== hash1)
expect(v2?.parent_hash).toBe(hash1)
})
it('GET /projection-defs lists defs with value_schema, sources and parent_hash', async () => {
const body = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/projection-defs'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.projection_defs).toHaveLength(1)
expect(json.total).toBe(1)
expect(json.projection_defs[0].name).toBe('current_assignee')
expect(json.projection_defs[0].value_schema).toEqual({ type: 'ref' })
expect(json.projection_defs[0].initial_value).toBe('')
expect(json.projection_defs[0].parent_hash).toBeNull()
expect(json.projection_defs[0].sources).toHaveLength(1)
expect(json.projection_defs[0].sources[0].bindings).toEqual({ subject: '$task_id' })
expect(json.projection_defs[0].sources[0].expression).toBe('event.participant')
})
})
// ============================================
// Projections (value NOT NULL)
// ============================================
describe('Projections', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('GET /projections/:name returns initial_value when no events', async () => {
const res = await app.fetch(req('GET', `/projections/current_assignee?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(200)
const json = await res.json()
expect(json.value).toBe('')
expect(tables.projections).toHaveLength(1)
expect(tables.projections[0].value).toBe('""')
})
it('GET /projections/:name computes value lazily after event', async () => {
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{
DB: db,
API_TOKEN: API_TOKEN,
},
)
const res = await app.fetch(req('GET', `/projections/current_assignee?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(200)
const json = await res.json()
expect(json.value).toBe(agentId)
})
})
// ============================================
// Reactions
// ============================================
describe('Reactions', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /reactions creates reaction with resolved projection_def_hash', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: 1 },
webhook_url: 'https://hook.example.com/notify',
}
const res = await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.id).toBeDefined()
expect(json.projection_def_hash).toBeDefined()
expect(tables.reactions).toHaveLength(1)
})
it('GET /reactions lists reactions', async () => {
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: 1 },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const res = await app.fetch(req('GET', '/reactions'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.reactions).toHaveLength(1)
expect(json.total).toBe(1)
})
it('DELETE /reactions/:id deletes reaction', async () => {
const created = await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: 1 },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const { id } = await created.json()
const res = await app.fetch(req('DELETE', `/reactions/${id}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
expect(tables.reactions).toHaveLength(0)
})
})
// ============================================
// E2E: Event → Projection Update → Reactions Fired
// ============================================
describe('E2E: Reaction Chain', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /events fires reactions', async () => {
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(201)
const json = await res.json()
expect(json.reactions_fired).toBeGreaterThan(0)
})
it('reaction_results include old_value and new_value (#213)', async () => {
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json = await res.json()
expect(json.reaction_results).toHaveLength(1)
expect(json.reaction_results[0].old_value).toBe('')
expect(json.reaction_results[0].new_value).toBe(agentId)
expect(json.reaction_results[0].projection_def).toBe('current_assignee')
})
it('reaction does not fire when projection value unchanged (#213)', async () => {
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const res1 = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const json1 = await res1.json()
expect(json1.reactions_fired).toBe(1)
const res2 = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const json2 = await res2.json()
expect(json2.reactions_fired).toBe(0)
expect(json2.reaction_results).toHaveLength(0)
})
it('emit_event reaction creates new event when projection changes (#213 phase 2)', async () => {
await app.fetch(
req('POST', '/event-defs', {
name: 'task_assignee_changed',
schema: { properties: { task: { type: 'ref' as const, object_type: 'task' } } },
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const task2Res = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const task2Id = (await task2Res.json()).id
const reactionRes = await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'emit_event',
emit_event_type: 'task_assignee_changed',
emit_payload_template: '{ "task": params.task_id }',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(reactionRes.status).toBe(201)
const reaction = await reactionRes.json()
expect(reaction.action).toBe('emit_event')
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const json = await res.json()
expect(json.reactions_fired).toBe(1)
const eventsRes = await app.fetch(req('GET', `/events?ref=${taskId}`), { DB: db, API_TOKEN: API_TOKEN })
const eventsJson = await eventsRes.json()
expect(eventsJson.events.length).toBeGreaterThanOrEqual(1)
})
})
// ============================================
// GET Single Routes
// ============================================
describe('GET /object-defs/:name', () => {
it('returns object def by name', async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/object-defs/agent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('agent')
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/object-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /event-defs/:name', () => {
it('returns event def with schema by name', async () => {
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/event-defs/task_assigned'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('task_assigned')
expect(json.hash).toBeDefined()
expect(json.schema).toEqual(schema)
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/event-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /projection-defs/:name', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('returns projection def with sources/params/value_schema by name', async () => {
const body = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
const res = await app.fetch(req('GET', '/projection-defs/current_assignee'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.name).toBe('current_assignee')
expect(json.hash).toBeDefined()
expect(json.value_schema).toEqual({ type: 'ref' })
expect(json.initial_value).toBe('')
expect(json.sources).toHaveLength(1)
expect(json.params).toEqual({ task_id: { type: 'ref' } })
})
it('returns 404 for nonexistent name', async () => {
const res = await app.fetch(req('GET', '/projection-defs/nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('GET /reactions/:id', () => {
beforeEach(async () => {
const schema = { properties: { participant: { type: 'ref' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('returns reaction by id', async () => {
const created = await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: 1 },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const { id } = await created.json()
const res = await app.fetch(req('GET', `/reactions/${id}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.id).toBe(id)
expect(json.projection_def_hash).toBeDefined()
expect(json.params).toEqual({ task_id: 1 })
expect(json.action).toBe('webhook')
})
it('returns 404 for nonexistent id', async () => {
const res = await app.fetch(req('GET', '/reactions/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
})
describe('Error Cases: Object Defs', () => {
it('POST /object-defs missing name → 400', async () => {
const res = await app.fetch(req('POST', '/object-defs', {}), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('Missing name')
})
it('POST /object-defs empty name → 400', async () => {
const res = await app.fetch(req('POST', '/object-defs', { name: '' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('Missing name')
})
})
describe('Error Cases: Objects', () => {
it('POST /objects type not defined → 400', async () => {
const res = await app.fetch(req('POST', '/objects', { type: 'nonexistent' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('Object type nonexistent not defined')
})
it('GET /objects/:id not found → 404', async () => {
const res = await app.fetch(req('GET', '/objects/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
it('GET /objects?type=nonexistent → returns empty array', async () => {
const res = await app.fetch(req('GET', '/objects?type=nonexistent'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.objects).toEqual([])
expect(json.total).toBe(0)
})
})
describe('Error Cases: Event Defs', () => {
it('POST /event-defs missing schema → 400', async () => {
const res = await app.fetch(req('POST', '/event-defs', { name: 'test' }), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /event-defs schema without properties → 400', async () => {
const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema: {} }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('properties')
})
it('POST /event-defs invalid property type → 400', async () => {
const schema = { properties: { field: { type: 'invalid' } } }
const res = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('Invalid property type')
})
it('POST /event-defs duplicate schema → idempotent (same hash)', async () => {
const schema = { properties: { field: { type: 'string' as const } } }
const res1 = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json1 = await res1.json()
const res2 = await app.fetch(req('POST', '/event-defs', { name: 'test', schema }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json2 = await res2.json()
expect(json1.hash).toBe(json2.hash)
})
})
describe('Error Cases: Events', () => {
let agentId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
count: { type: 'number' as const },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'test_event', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /events type not defined → 400', async () => {
const res = await app.fetch(req('POST', '/events', { type: 'nonexistent', payload: {} }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('Event type nonexistent not defined')
})
it('POST /events ref nonexistent object → 400', async () => {
const res = await app.fetch(req('POST', '/events', { type: 'test_event', payload: { participant: 99999 } }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('does not exist')
})
it('POST /events ref type mismatch → 400', async () => {
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const taskId = (await taskRes.json()).id
const res = await app.fetch(req('POST', '/events', { type: 'test_event', payload: { participant: taskId } }), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('type mismatch')
})
it('POST /events payload property wrong type → 400', async () => {
const res = await app.fetch(
req('POST', '/events', { type: 'test_event', payload: { participant: agentId, count: 'string' } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('must be number')
})
it('GET /events/:id not found → 404', async () => {
const res = await app.fetch(req('GET', '/events/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(404)
const json = await res.json()
expect(json.error.code).toBe('NOT_FOUND')
})
it('GET /events without ref → returns all events', async () => {
const res = await app.fetch(req('GET', '/events'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.events).toBeDefined()
expect(json.total).toBeDefined()
})
it('GET /events?ref=nonexistent → returns empty array', async () => {
const res = await app.fetch(req('GET', '/events?ref=99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.events).toEqual([])
expect(json.total).toBe(0)
})
})
describe('Incremental Query: GET /events?after=N', () => {
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'item', schema: {} }), { DB: db, API_TOKEN: API_TOKEN })
const schema = { properties: { subject: { type: 'ref' as const, object_type: 'item' }, note: { type: 'string' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'item_noted', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('GET /events?after=N returns only events with id > N', async () => {
const objRes = await app.fetch(req('POST', '/objects', { type: 'item' }), { DB: db, API_TOKEN: API_TOKEN })
const itemId = ((await objRes.json()) as any).id
// Emit 3 events
const eventIds: number[] = []
for (let i = 0; i < 3; i++) {
const r = await app.fetch(
req('POST', '/events', { type: 'item_noted', payload: { subject: itemId, note: `note${i}` } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const j = (await r.json()) as any
eventIds.push(j.event.id)
}
// Query after first event
const res = await app.fetch(req('GET', `/events?after=${eventIds[0]}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = (await res.json()) as any
expect(json.total).toBe(2)
expect(json.events.length).toBe(2)
// Should be ASC order
expect(json.events[0].id).toBe(eventIds[1])
expect(json.events[1].id).toBe(eventIds[2])
})
it('GET /events?ref=X&after=N returns only ref events with id > N', async () => {
const objRes = await app.fetch(req('POST', '/objects', { type: 'item' }), { DB: db, API_TOKEN: API_TOKEN })
const itemId = ((await objRes.json()) as any).id
// Emit 3 events for this item
const eventIds: number[] = []
for (let i = 0; i < 3; i++) {
const r = await app.fetch(
req('POST', '/events', { type: 'item_noted', payload: { subject: itemId, note: `v${i}` } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const j = (await r.json()) as any
eventIds.push(j.event.id)
}
// Query ref + after
const res = await app.fetch(req('GET', `/events?ref=${itemId}&after=${eventIds[0]}`), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
const json = (await res.json()) as any
expect(json.total).toBe(2)
expect(json.events.length).toBe(2)
expect(json.events[0].id).toBe(eventIds[1])
})
})
describe('Error Cases: Projection Defs', () => {
beforeEach(async () => {
const schema = { properties: { field: { type: 'string' as const } } }
await app.fetch(req('POST', '/event-defs', { name: 'test_event', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /projection-defs missing value_schema → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
sources: [{ event_def: 'test_event', bindings: {}, expression: '"value"' }],
params: {},
initial_value: '',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /projection-defs missing initial_value → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
sources: [{ event_def: 'test_event', bindings: {}, expression: '"value"' }],
params: {},
value_schema: { type: 'string' },
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
})
it('POST /projection-defs missing sources → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
params: {},
value_schema: { type: 'string' },
initial_value: '',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('sources')
})
it('POST /projection-defs invalid value_schema type → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
sources: [{ event_def: 'test_event', bindings: {}, expression: '"value"' }],
params: {},
value_schema: { type: 'invalid' },
initial_value: '',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('Invalid value_schema type')
})
it('POST /projection-defs initial_value type mismatch → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
sources: [{ event_def: 'test_event', bindings: {}, expression: '"value"' }],
params: {},
value_schema: { type: 'number' },
initial_value: 'not_a_number',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('initial_value must be number')
})
it('POST /projection-defs source with nonexistent event → 400', async () => {
const res = await app.fetch(
req('POST', '/projection-defs', {
name: 'test_proj',
sources: [{ event_def: 'nonexistent', bindings: {}, expression: '"value"' }],
params: {},
value_schema: { type: 'string' },
initial_value: '',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('INVALID_INPUT')
expect(json.error.message).toContain('Event type nonexistent not defined')
})
it('POST /projection-defs duplicate definition → idempotent (same hash)', async () => {
const body = {
name: 'test_proj',
sources: [{ event_def: 'test_event', bindings: {}, expression: '"value"' }],
params: {},
value_schema: { type: 'string' },
initial_value: '',
}
const res1 = await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
const json1 = await res1.json()
const res2 = await app.fetch(req('POST', '/projection-defs', body), { DB: db, API_TOKEN: API_TOKEN })
const json2 = await res2.json()
expect(json1.hash).toBe(json2.hash)
})
})
describe('Error Cases: Projections', () => {
it('GET /projections/:name not found → 500', async () => {
const res = await app.fetch(req('GET', '/projections/nonexistent?param=value'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Projection def nonexistent not found')
})
})
describe('Error Cases: Reactions', () => {
it('POST /reactions projection_def not found → 500', async () => {
const res = await app.fetch(
req('POST', '/reactions', {
projection_def: 'nonexistent',
params: {},
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
expect(res.status).toBe(500)
const json = await res.json()
expect(json.error.code).toBe('INTERNAL_ERROR')
expect(json.error.message).toContain('Projection def nonexistent not found')
})
it('DELETE /reactions/:id nonexistent → 200 (idempotent)', async () => {
const res = await app.fetch(req('DELETE', '/reactions/99999'), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(200)
})
})
// ============================================
// Reaction Logs (#217)
// ============================================
describe('Reaction Logs', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
})
it('reaction log created on fire with status=success', async () => {
const payload = { participant: agentId, subject: taskId }
const eventRes = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const eventJson = await eventRes.json()
expect(eventJson.reactions_fired).toBe(1)
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
expect(logsRes.status).toBe(200)
const logsJson = await logsRes.json()
expect(logsJson.reaction_logs.length).toBeGreaterThanOrEqual(1)
const successLog = logsJson.reaction_logs.find((l: any) => l.status === 'success')
expect(successLog).toBeDefined()
expect(successLog.projection_def).toBe('current_assignee')
expect(successLog.action).toBe('webhook')
})
it('reaction log skipped on no change', async () => {
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const skippedLog = logsJson.reaction_logs.find((l: any) => l.status === 'skipped')
expect(skippedLog).toBeDefined()
})
it('log includes correct projection_def, old_value, new_value', async () => {
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const log = logsJson.reaction_logs.find((l: any) => l.status === 'success')
expect(log).toBeDefined()
expect(log.projection_def).toBe('current_assignee')
expect(log.old_value).toBe('')
expect(log.new_value).toBe(agentId)
})
it('filter by reaction_id works', async () => {
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const reactionId = tables.reactions[0].id
const filteredRes = await app.fetch(req('GET', `/reaction-logs?reaction_id=${reactionId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
const filteredJson = await filteredRes.json()
expect(filteredJson.reaction_logs.length).toBeGreaterThanOrEqual(1)
expect(filteredJson.reaction_logs.every((l: any) => l.reaction_id === reactionId)).toBe(true)
const emptyRes = await app.fetch(req('GET', '/reaction-logs?reaction_id=99999'), {
DB: db,
API_TOKEN: API_TOKEN,
})
const emptyJson = await emptyRes.json()
expect(emptyJson.reaction_logs).toHaveLength(0)
expect(emptyJson.total).toBe(0)
})
})
// ============================================
// Reaction Handler (#220)
// ============================================
describe('Reaction Handler', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
const projDef = {
name: 'current_assignee',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
})
it('POST /reactions with action=handler + handler_code succeeds', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code: 'log("hello")',
}
const res = await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.action).toBe('handler')
expect(json.handler_code).toBe('log("hello")')
})
it('POST /reactions with action=handler without handler_code → 400', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
}
const res = await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
expect(res.status).toBe(400)
const json = await res.json()
expect(json.error.code).toBe('MISSING_FIELD')
expect(json.error.message).toContain('handler_code is required')
})
it('handler receives context (old_value, new_value, params, event)', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code:
'log("old=" + JSON.stringify(old_value)); log("new=" + JSON.stringify(new_value)); log("params=" + JSON.stringify(params)); log("event_id=" + event.id)',
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json = await res.json()
expect(json.reactions_fired).toBe(1)
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const successLog = logsJson.reaction_logs.find((l: any) => l.status === 'success')
expect(successLog).toBeDefined()
expect(successLog.handler_output).toContain('old=')
expect(successLog.handler_output).toContain('new=')
expect(successLog.handler_output).toContain('params=')
expect(successLog.handler_output).toContain('event_id=')
})
it('handler emit() creates new event via reaction chain', async () => {
await app.fetch(
req('POST', '/event-defs', {
name: 'task_assignee_changed',
schema: { properties: { task: { type: 'ref' as const, object_type: 'task' } } },
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code: 'await emit("task_assignee_changed", { task: params.task_id }); log("emitted")',
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json = await res.json()
expect(json.reactions_fired).toBe(1)
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const successLog = logsJson.reaction_logs.find((l: any) => l.status === 'success' && l.action === 'handler')
expect(successLog).toBeDefined()
expect(successLog.handler_output).toContain('emitted')
})
it('handler log() output appears in reaction_logs.handler_output', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code: 'log("processed"); log("done")',
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const successLog = logsJson.reaction_logs.find((l: any) => l.status === 'success')
expect(successLog).toBeDefined()
expect(successLog.handler_output).toContain('processed')
expect(successLog.handler_output).toContain('done')
})
it('handler kv.set/get persists across multiple triggers', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code:
'const count = (await kv.get("count")) || 0; await kv.set("count", count + 1); log("count=" + (count + 1))',
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const agent2Res = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
const agent2Id = (await agent2Res.json()).id
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agent2Id, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const handlerLogs = logsJson.reaction_logs.filter((l: any) => l.status === 'success' && l.action === 'handler')
expect(handlerLogs.length).toBe(2)
const outputs = handlerLogs.map((l: any) => l.handler_output)
expect(outputs).toContain('count=1')
expect(outputs).toContain('count=2')
})
it('handler runtime error → status=failed, error in handler_output', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code: 'throw new Error("boom")',
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const failedLog = logsJson.reaction_logs.find((l: any) => l.status === 'failed')
expect(failedLog).toBeDefined()
expect(failedLog.handler_output).toContain('boom')
})
it('handler timeout → status=failed, error mentions timeout', async () => {
const body = {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'handler',
handler_code: 'await new Promise(resolve => setTimeout(resolve, 10000))',
handler_timeout_ms: 100,
}
await app.fetch(req('POST', '/reactions', body), { DB: db, API_TOKEN: API_TOKEN })
const payload = { participant: agentId, subject: taskId }
await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const logsRes = await app.fetch(req('GET', '/reaction-logs'), { DB: db, API_TOKEN: API_TOKEN })
const logsJson = await logsRes.json()
const failedLog = logsJson.reaction_logs.find((l: any) => l.status === 'failed')
expect(failedLog).toBeDefined()
expect(failedLog.handler_output).toContain('timeout')
}, 10000)
it('backward compat: webhook still works', async () => {
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json = await res.json()
expect(json.reactions_fired).toBe(1)
expect(json.reaction_results[0].old_value).toBe('')
expect(json.reaction_results[0].new_value).toBe(agentId)
})
it('backward compat: emit_event still works', async () => {
await app.fetch(
req('POST', '/event-defs', {
name: 'task_assignee_changed',
schema: { properties: { task: { type: 'ref' as const, object_type: 'task' } } },
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
await app.fetch(
req('POST', '/reactions', {
projection_def: 'current_assignee',
params: { task_id: taskId },
action: 'emit_event',
emit_event_type: 'task_assignee_changed',
emit_payload_template: '{ "task": params.task_id }',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
const payload = { participant: agentId, subject: taskId }
const res = await app.fetch(req('POST', '/events', { type: 'task_assigned', payload }), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json = await res.json()
expect(json.reactions_fired).toBe(1)
})
})
// ============================================
// API Key Management (#219)
// ============================================
describe('API Key Management', () => {
describe('CRUD', () => {
it('POST /api-keys creates key — returns id + name + key (plaintext)', async () => {
const res = await app.fetch(req('POST', '/api-keys', { name: 'test-key' }), { DB: db, API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.id).toBeDefined()
expect(json.name).toBe('test-key')
expect(json.key).toBeDefined()
expect(json.role).toBe('ingest')
expect(json.allowed_events).toEqual([])
expect(json.rate_limit).toBe(100)
})
it('key starts with ogk_', async () => {
const res = await app.fetch(req('POST', '/api-keys', { name: 'test-key' }), { DB: db, API_TOKEN })
const json = await res.json()
expect(json.key).toMatch(/^ogk_/)
})
it('GET /api-keys lists keys without key_hash or plaintext key', async () => {
await app.fetch(req('POST', '/api-keys', { name: 'key-1' }), { DB: db, API_TOKEN })
await app.fetch(req('POST', '/api-keys', { name: 'key-2' }), { DB: db, API_TOKEN })
const res = await app.fetch(req('GET', '/api-keys'), { DB: db, API_TOKEN })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.api_keys).toHaveLength(2)
expect(json.total).toBe(2)
for (const k of json.api_keys) {
expect(k).not.toHaveProperty('key')
expect(k).not.toHaveProperty('key_hash')
expect(k.name).toBeDefined()
expect(k.id).toBeDefined()
}
})
it('DELETE /api-keys/:id — key no longer in list', async () => {
const created = await app.fetch(req('POST', '/api-keys', { name: 'to-delete' }), { DB: db, API_TOKEN })
const { id } = await created.json()
const delRes = await app.fetch(req('DELETE', `/api-keys/${id}`), { DB: db, API_TOKEN })
expect(delRes.status).toBe(200)
const listRes = await app.fetch(req('GET', '/api-keys'), { DB: db, API_TOKEN })
const listJson = await listRes.json()
expect(listJson.api_keys).toHaveLength(0)
})
it('duplicate key names are allowed', async () => {
await app.fetch(req('POST', '/api-keys', { name: 'dup' }), { DB: db, API_TOKEN })
const res = await app.fetch(req('POST', '/api-keys', { name: 'dup' }), { DB: db, API_TOKEN })
expect(res.status).toBe(201)
const listRes = await app.fetch(req('GET', '/api-keys'), { DB: db, API_TOKEN })
const listJson = await listRes.json()
expect(listJson.api_keys).toHaveLength(2)
})
})
describe('Auth', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN })
})
it('POST /events with valid API key and correct event type → 201', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'ingest-key', allowed_events: ['task_assigned'] }),
{ DB: db, API_TOKEN },
)
const { key } = await keyRes.json()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(201)
})
it('POST /events with invalid Bearer token → 401', async () => {
const res = await app.fetch(
req(
'POST',
'/events',
{ type: 'task_assigned', payload: { participant: agentId, subject: taskId } },
'ogk_invalidtoken',
),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(401)
})
it('POST /events without Authorization header → 401', async () => {
const request = new Request('http://test/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
})
const res = await app.fetch(request, { DB: db, API_TOKEN })
expect(res.status).toBe(401)
})
it('POST /events with deleted key → 401', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'temp-key', allowed_events: ['task_assigned'] }),
{ DB: db, API_TOKEN },
)
const { id, key } = await keyRes.json()
await app.fetch(req('DELETE', `/api-keys/${id}`), { DB: db, API_TOKEN })
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(401)
})
it('POST /events with valid key but wrong event type → 403', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'limited-key', allowed_events: ['other_event'] }),
{ DB: db, API_TOKEN },
)
const { key } = await keyRes.json()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
it('empty allowed_events means no events allowed for ingest role', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'empty-key', allowed_events: [] }),
{ DB: db, API_TOKEN },
)
const { key } = await keyRes.json()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
it('existing API_TOKEN still works for POST /events', async () => {
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(201)
})
it('admin role API key is treated as ingest (admin role not implemented)', async () => {
// Even if someone passes role=admin, it's ignored — always ingest
const keyRes = await app.fetch(req('POST', '/api-keys', { name: 'admin-key', allowed_events: ['task_assigned'] }), {
DB: db,
API_TOKEN,
})
const { key } = await keyRes.json()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(201)
})
it('creating API key without role defaults to ingest', async () => {
const res = await app.fetch(req('POST', '/api-keys', { name: 'no-role-key' }), { DB: db, API_TOKEN })
expect(res.status).toBe(201)
const json = await res.json()
expect(json.role).toBe('ingest')
})
it('any explicitly passed role value is ignored, always ingest', async () => {
const res = await app.fetch(
req('POST', '/api-keys', { name: 'readonly-attempt', role: 'readonly' as any }),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(201)
const json = await res.json()
expect(json.role).toBe('ingest')
})
})
})
// ============================================
// API Key Read Access (#36)
// ============================================
describe('API Key Read Access', () => {
let apiKey: string
let agentId: number
beforeEach(async () => {
// Need object-def 'agent' first
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN })
agentId = (await agentRes.json()).id
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'read-test', allowed_events: ['task_created'] }),
{ DB: db, API_TOKEN },
)
apiKey = (await keyRes.json()).key
})
it('GET /events with API Key → 200', async () => {
const res = await app.fetch(
req('GET', '/events?ref=' + agentId, undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
const json = await res.json()
expect(json.events).toBeDefined()
})
it('GET /objects/:id with API Key → 200', async () => {
const res = await app.fetch(
req('GET', `/objects/${agentId}`, undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
})
it('GET /event-defs with API Key → 200', async () => {
const res = await app.fetch(
req('GET', '/event-defs', undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
})
it('POST /event-defs with API Key → 403', async () => {
const res = await app.fetch(
req('POST', '/event-defs', { name: 'blocked_type', schema: { properties: { x: { type: 'string' } } } }, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
it('GET /api-keys with API Key → 403', async () => {
const res = await app.fetch(
req('GET', '/api-keys', undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
})
// ============================================
// Request Logs (#221)
// ============================================
describe('Request Logs', () => {
let agentId: number
let taskId: number
function mockExecutionCtx() {
const pending: Promise<unknown>[] = []
return {
ctx: {
waitUntil(p: Promise<unknown>) {
pending.push(p)
},
passThroughOnException() {},
},
flush: () => Promise.all(pending),
}
}
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN })
})
it('request log created after POST /events', async () => {
const execCtx = mockExecutionCtx()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN },
execCtx.ctx as any,
)
expect(res.status).toBe(201)
await execCtx.flush()
const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const logsJson = await logsRes.json()
expect(logsJson.request_logs.length).toBeGreaterThanOrEqual(1)
const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST')
expect(postLog).toBeDefined()
expect(postLog.status_code).toBe(201)
})
it('request log includes api_key_name for API key auth', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'log-test-key', allowed_events: ['task_assigned'] }),
{ DB: db, API_TOKEN },
)
const { key } = await keyRes.json()
const execCtx = mockExecutionCtx()
const res = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
execCtx.ctx as any,
)
expect(res.status).toBe(201)
await execCtx.flush()
const logsRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const logsJson = await logsRes.json()
const postLog = logsJson.request_logs.find((l: any) => l.path === '/events' && l.method === 'POST')
expect(postLog).toBeDefined()
expect(postLog.api_key_name).toBe('log-test-key')
expect(postLog.api_key_id).toBeDefined()
})
it('GET /request-logs supports api_key_id filter', async () => {
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'filter-key', allowed_events: ['task_assigned'] }),
{ DB: db, API_TOKEN },
)
const { key, id: keyId } = await keyRes.json()
const execCtx1 = mockExecutionCtx()
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }, key),
{ DB: db, API_TOKEN },
execCtx1.ctx as any,
)
await execCtx1.flush()
const execCtx2 = mockExecutionCtx()
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN },
execCtx2.ctx as any,
)
await execCtx2.flush()
const filteredRes = await app.fetch(req('GET', `/request-logs?api_key_id=${keyId}`), { DB: db, API_TOKEN })
const filteredJson = await filteredRes.json()
expect(filteredJson.request_logs.length).toBe(1)
expect(filteredJson.request_logs[0].api_key_id).toBe(keyId)
const allRes = await app.fetch(req('GET', '/request-logs'), { DB: db, API_TOKEN })
const allJson = await allRes.json()
expect(allJson.request_logs.length).toBe(2)
})
})
// ============================================
// Projection Health (#20)
// ============================================
describe('Projection Health', () => {
let agentId: number
let taskId: number
beforeEach(async () => {
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
await app.fetch(req('POST', '/object-defs', { name: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN: API_TOKEN })
agentId = (await agentRes.json()).id
const taskRes = await app.fetch(req('POST', '/objects', { type: 'task' }), { DB: db, API_TOKEN: API_TOKEN })
taskId = (await taskRes.json()).id
const schema = {
properties: {
participant: { type: 'ref' as const, object_type: 'agent' },
subject: { type: 'ref' as const, object_type: 'task' },
},
}
await app.fetch(req('POST', '/event-defs', { name: 'task_assigned', schema }), { DB: db, API_TOKEN: API_TOKEN })
})
it('expression returning undefined → projection marked errored, returns initial_value', async () => {
// Create projection with expression that returns undefined (accessing non-existent field)
const projDef = {
name: 'bad_projection',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.nonexistent_field',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'string' },
initial_value: 'default_value',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// Emit an event
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
// Query the projection — should return initial_value + errored status
const res = await app.fetch(req('GET', `/projections/bad_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(200)
const json = await res.json()
expect(json.value).toBe('default_value')
expect(json._status).toBe('errored')
expect(json._error).toBeDefined()
})
it('expression that throws → projection marked errored, returns initial_value', async () => {
// Create projection with expression that will throw (invalid JSONata)
const projDef = {
name: 'throw_projection',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: '$unknown_function()',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'number' },
initial_value: 0,
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// Emit an event
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
// Query the projection — should return initial_value + errored status
const res = await app.fetch(req('GET', `/projections/throw_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res.status).toBe(200)
const json = await res.json()
expect(json.value).toBe(0)
expect(json._status).toBe('errored')
expect(json._error).toBeDefined()
})
it('subsequent query on errored projection still returns initial_value', async () => {
const projDef = {
name: 'bad_projection',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.nonexistent_field',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'string' },
initial_value: 'default_value',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// Emit event
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
// First query marks as errored
await app.fetch(req('GET', `/projections/bad_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
// Second query — should still return initial_value
const res2 = await app.fetch(req('GET', `/projections/bad_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res2.status).toBe(200)
const json2 = await res2.json()
expect(json2.value).toBe('default_value')
expect(json2._status).toBe('errored')
})
it('fix expression → projection recovers to healthy', async () => {
// Create broken projection
const projDef = {
name: 'recoverable_projection',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.nonexistent_field',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// Emit event
await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
// Query — marked errored
const res1 = await app.fetch(req('GET', `/projections/recoverable_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
const json1 = await res1.json()
expect(json1._status).toBe('errored')
// Fix projection def with correct expression
const fixedProjDef = {
name: 'recoverable_projection',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.participant',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'ref' },
initial_value: '',
}
await app.fetch(req('POST', '/projection-defs', fixedProjDef), { DB: db, API_TOKEN: API_TOKEN })
// Query again — should recover to healthy with correct value
const res2 = await app.fetch(req('GET', `/projections/recoverable_projection?task_id=${taskId}`), {
DB: db,
API_TOKEN: API_TOKEN,
})
expect(res2.status).toBe(200)
const json2 = await res2.json()
expect(json2.value).toBe(agentId)
expect(json2._status).toBeUndefined()
expect(json2._error).toBeUndefined()
})
it('errored projection does not trigger reactions', async () => {
// Create projection with bad expression
const projDef = {
name: 'bad_reaction_proj',
sources: [
{
event_def: 'task_assigned',
bindings: { subject: '$task_id' },
expression: 'event.nonexistent_field',
},
],
params: { task_id: { type: 'ref' } },
value_schema: { type: 'string' },
initial_value: 'default_value',
}
await app.fetch(req('POST', '/projection-defs', projDef), { DB: db, API_TOKEN: API_TOKEN })
// Create a reaction watching this projection
await app.fetch(
req('POST', '/reactions', {
projection_def: 'bad_reaction_proj',
params: { task_id: taskId },
webhook_url: 'https://hook.example.com',
}),
{ DB: db, API_TOKEN: API_TOKEN },
)
// First emit — this will make the projection errored during reaction chain
const res1 = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const json1 = await res1.json()
expect(json1.reactions_fired).toBe(0)
// Second emit — projection should still be errored, no reaction
const res2 = await app.fetch(
req('POST', '/events', { type: 'task_assigned', payload: { participant: agentId, subject: taskId } }),
{ DB: db, API_TOKEN: API_TOKEN },
)
const json2 = await res2.json()
expect(json2.reactions_fired).toBe(0)
})
})