// 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[]>) { function findRows(sql: string, binds: unknown[]): Record[] { 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[] = [] 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 = {} 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 = {} columns.forEach((col, i) => { if (placeholders[i] === '?') { row[col] = binds[i] } }) if (sqlLower.includes('or replace')) { const compositePKs: Record = { 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() { const rows = findRows(sql, binds) return { results: rows as T[] } }, first() { const rows = findRows(sql, binds) return rows[0] as T | null }, } }, } as unknown as D1Database } // ============================================ // Test Setup // ============================================ let app: ReturnType let db: D1Database let tables: Record[]> 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[] = [] return { ctx: { waitUntil(p: Promise) { 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) }) })