- 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
2666 lines
100 KiB
TypeScript
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)
|
|
})
|
|
})
|