527 lines
22 KiB
TypeScript
527 lines
22 KiB
TypeScript
// Test suite for OGraphClient v2.4
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { OGraphClient } from '../src/client.js'
|
|
|
|
// Mock the config module
|
|
vi.mock('../src/config.js', () => ({
|
|
loadConfig: vi.fn(),
|
|
}))
|
|
|
|
import { loadConfig } from '../src/config.js'
|
|
const mockLoadConfig = vi.mocked(loadConfig)
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = vi.fn()
|
|
global.fetch = mockFetch
|
|
|
|
describe('OGraphClient v2.4', () => {
|
|
let client: OGraphClient
|
|
|
|
beforeEach(() => {
|
|
client = new OGraphClient()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ─── init ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('init', () => {
|
|
it('should throw error if endpoint not configured', async () => {
|
|
mockLoadConfig.mockResolvedValue({})
|
|
await expect(client.init()).rejects.toThrow('API endpoint not configured. Run: ograph config set endpoint <url>')
|
|
})
|
|
|
|
it('should throw error if token not configured', async () => {
|
|
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com' })
|
|
await expect(client.init()).rejects.toThrow('Auth token not configured. Run: ograph config set token <token>')
|
|
})
|
|
|
|
it('should initialize successfully with both endpoint and token', async () => {
|
|
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com', token: 'test-token' })
|
|
await expect(client.init()).resolves.not.toThrow()
|
|
})
|
|
})
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
async function initClient() {
|
|
mockLoadConfig.mockResolvedValue({ endpoint: 'https://api.example.com', token: 'test-token' })
|
|
await client.init()
|
|
}
|
|
|
|
const jsonHeaders = { get: (name: string) => name.toLowerCase() === 'content-type' ? 'application/json' : null }
|
|
|
|
function mockOk(data: unknown) {
|
|
mockFetch.mockResolvedValue({ ok: true, headers: jsonHeaders, json: () => Promise.resolve(data) })
|
|
}
|
|
|
|
function mockFail(status: number, error: string) {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
status,
|
|
statusText: error,
|
|
headers: jsonHeaders,
|
|
json: () => Promise.resolve({ error }),
|
|
})
|
|
}
|
|
|
|
// ─── object-defs ─────────────────────────────────────────────────────────────
|
|
|
|
describe('listObjectDefs', () => {
|
|
it('returns object_defs array', async () => {
|
|
await initClient()
|
|
mockOk({ object_defs: [{ name: 'user' }, { name: 'task' }] })
|
|
const result = await client.listObjectDefs()
|
|
expect(result).toEqual([{ name: 'user' }, { name: 'task' }])
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/object-defs')
|
|
})
|
|
})
|
|
|
|
describe('createObjectDef', () => {
|
|
it('POSTs to /object-defs with name', async () => {
|
|
await initClient()
|
|
mockOk({ name: 'user', created_at: 1234 })
|
|
const result = await client.createObjectDef('user')
|
|
expect(result.name).toBe('user')
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/object-defs')
|
|
expect(opts.method).toBe('POST')
|
|
expect(JSON.parse(opts.body as string)).toEqual({ name: 'user' })
|
|
})
|
|
})
|
|
|
|
// ─── objects ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('createObject', () => {
|
|
it('POSTs to /objects with type only (no custom id)', async () => {
|
|
await initClient()
|
|
mockOk({ id: 1, type: 'user', created_at: 1234 })
|
|
const result = await client.createObject('user')
|
|
expect(result.id).toBe(1)
|
|
expect(typeof result.id).toBe('number')
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
expect(body).toEqual({ type: 'user' })
|
|
})
|
|
})
|
|
|
|
describe('getObject', () => {
|
|
it('GETs /objects/:id with numeric id', async () => {
|
|
await initClient()
|
|
mockOk({ id: 42, type: 'user', created_at: 1234 })
|
|
const result = await client.getObject(42)
|
|
expect(result.id).toBe(42)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects/42')
|
|
})
|
|
})
|
|
|
|
describe('listObjects', () => {
|
|
it('GETs /objects without filter', async () => {
|
|
await initClient()
|
|
mockOk({ objects: [{ id: 1, type: 'user', created_at: 1234 }] })
|
|
const result = await client.listObjects()
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe(1)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects')
|
|
})
|
|
|
|
it('GETs /objects?type=user with filter', async () => {
|
|
await initClient()
|
|
mockOk({ objects: [] })
|
|
await client.listObjects('user')
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/objects?type=user')
|
|
})
|
|
})
|
|
|
|
// ─── event-defs ──────────────────────────────────────────────────────────────
|
|
|
|
describe('listEventDefs', () => {
|
|
it('returns event_defs array', async () => {
|
|
await initClient()
|
|
mockOk({ event_defs: [{ name: 'UserCreated', schema: { properties: {} } }] })
|
|
const result = await client.listEventDefs()
|
|
expect(result[0].name).toBe('UserCreated')
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/event-defs')
|
|
})
|
|
})
|
|
|
|
describe('createEventDef', () => {
|
|
it('POSTs to /event-defs with name and schema', async () => {
|
|
await initClient()
|
|
const schema = { properties: { user: { type: 'ref' as const } } }
|
|
mockOk({ name: 'UserCreated', schema, hash: 'abc123' })
|
|
const result = await client.createEventDef('UserCreated', schema)
|
|
expect(result.name).toBe('UserCreated')
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
expect(body).toEqual({ name: 'UserCreated', schema })
|
|
})
|
|
})
|
|
|
|
// ─── events ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('emitEvent', () => {
|
|
it('POSTs to /events and returns {event, reactions_fired}', async () => {
|
|
await initClient()
|
|
mockOk({ event: { id: 1, type_hash: 'abc123', payload: { user: 1 }, created_at: 1234 }, reactions_fired: 0 })
|
|
const result = await client.emitEvent('UserCreated', { user: 1 })
|
|
expect(result.event.id).toBe(1)
|
|
expect(typeof result.event.id).toBe('number')
|
|
expect(result.event.type_hash).toBe('abc123')
|
|
expect(result.reactions_fired).toBe(0)
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/events')
|
|
expect(opts.method).toBe('POST')
|
|
const body = JSON.parse(opts.body as string)
|
|
expect(body).toEqual({ type: 'UserCreated', payload: { user: 1 } })
|
|
})
|
|
})
|
|
|
|
describe('getEvent', () => {
|
|
it('GETs /events/:id with numeric id', async () => {
|
|
await initClient()
|
|
mockOk({ id: 5, type_hash: 'abc123', payload: {}, created_at: 1234 })
|
|
const result = await client.getEvent(5)
|
|
expect(result.id).toBe(5)
|
|
expect(typeof result.id).toBe('number')
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/events/5')
|
|
})
|
|
})
|
|
|
|
describe('findEventsByRef', () => {
|
|
it('GETs /events?ref=<numeric-id>', async () => {
|
|
await initClient()
|
|
mockOk({ events: [{ id: 1, type_hash: 'abc123', payload: {}, created_at: 1234 }] })
|
|
const result = await client.findEventsByRef(7)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe(1)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/events?ref=7')
|
|
})
|
|
})
|
|
|
|
// ─── projection-defs ─────────────────────────────────────────────────────────
|
|
|
|
describe('listProjectionDefs', () => {
|
|
it('returns projection_defs array', async () => {
|
|
await initClient()
|
|
mockOk({
|
|
projection_defs: [
|
|
{
|
|
name: 'userCount',
|
|
sources: [{ event_def: 'UserCreated', bindings: {}, expression: '$count + 1' }],
|
|
value_schema: { type: 'number' },
|
|
initial_value: 0,
|
|
},
|
|
],
|
|
})
|
|
const result = await client.listProjectionDefs()
|
|
expect(result[0].name).toBe('userCount')
|
|
})
|
|
})
|
|
|
|
describe('createProjectionDef', () => {
|
|
it('POSTs to /projection-defs with name, sources, params, value_schema, initial_value', async () => {
|
|
await initClient()
|
|
const sources = [{ event_def: 'UserCreated', bindings: {}, expression: '$count + 1' }]
|
|
const params = {}
|
|
const value_schema = { type: 'number' }
|
|
const initial_value = 0
|
|
mockOk({ name: 'userCount', sources, params, value_schema, initial_value })
|
|
const result = await client.createProjectionDef('userCount', sources, params, value_schema, initial_value)
|
|
expect(result.name).toBe('userCount')
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/projection-defs')
|
|
expect(opts.method).toBe('POST')
|
|
const body = JSON.parse(opts.body as string)
|
|
expect(body.name).toBe('userCount')
|
|
expect(body.sources).toEqual(sources)
|
|
expect(body.value_schema).toEqual(value_schema)
|
|
expect(body.initial_value).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ─── projections ─────────────────────────────────────────────────────────────
|
|
|
|
describe('getProjection', () => {
|
|
it('GETs /projections/:name', async () => {
|
|
await initClient()
|
|
mockOk({ value: 42 })
|
|
const value = await client.getProjection('userCount')
|
|
expect(value).toBe(42)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/projections/userCount')
|
|
})
|
|
|
|
it('appends params as query string', async () => {
|
|
await initClient()
|
|
mockOk({ value: 5 })
|
|
await client.getProjection('tasksByUser', { userId: 'user_01ABC' })
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/projections/tasksByUser?userId=user_01ABC')
|
|
})
|
|
})
|
|
|
|
// ─── reactions ───────────────────────────────────────────────────────────────
|
|
|
|
describe('createReaction (webhook)', () => {
|
|
it('POSTs to /reactions with action=webhook', async () => {
|
|
await initClient()
|
|
mockOk({
|
|
id: 1,
|
|
projection_def_hash: 'hashABC',
|
|
params_hash: 'paramHash',
|
|
params: {},
|
|
action: 'webhook',
|
|
webhook_url: 'https://example.com/hook',
|
|
created_at: 1234,
|
|
})
|
|
const result = await client.createReaction(
|
|
'userCount',
|
|
{},
|
|
{ action: 'webhook', webhook_url: 'https://example.com/hook' },
|
|
)
|
|
expect(result.id).toBe(1)
|
|
expect(typeof result.id).toBe('number')
|
|
expect(result.action).toBe('webhook')
|
|
expect(result.webhook_url).toBe('https://example.com/hook')
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/reactions')
|
|
expect(opts.method).toBe('POST')
|
|
const body = JSON.parse(opts.body as string)
|
|
expect(body.projection_def).toBe('userCount')
|
|
expect(body.action).toBe('webhook')
|
|
expect(body.webhook_url).toBe('https://example.com/hook')
|
|
})
|
|
})
|
|
|
|
describe('createReaction (emit_event)', () => {
|
|
it('POSTs to /reactions with action=emit_event', async () => {
|
|
await initClient()
|
|
mockOk({
|
|
id: 2,
|
|
projection_def_hash: 'hashABC',
|
|
params_hash: 'paramHash',
|
|
params: {},
|
|
action: 'emit_event',
|
|
emit_event_type: 'TaskCompleted',
|
|
created_at: 1234,
|
|
})
|
|
const result = await client.createReaction(
|
|
'taskStatus',
|
|
{},
|
|
{
|
|
action: 'emit_event',
|
|
emit_event_type: 'TaskCompleted',
|
|
},
|
|
)
|
|
expect(result.id).toBe(2)
|
|
expect(result.action).toBe('emit_event')
|
|
expect(result.emit_event_type).toBe('TaskCompleted')
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
expect(body.action).toBe('emit_event')
|
|
expect(body.emit_event_type).toBe('TaskCompleted')
|
|
})
|
|
})
|
|
|
|
describe('createReaction (handler)', () => {
|
|
it('POSTs to /reactions with action=handler and handler_code', async () => {
|
|
await initClient()
|
|
mockOk({
|
|
id: 3,
|
|
projection_def_hash: 'hashABC',
|
|
params_hash: 'paramHash',
|
|
params: {},
|
|
action: 'handler',
|
|
handler_code: 'export default (event) => console.log(event)',
|
|
handler_timeout_ms: 10000,
|
|
created_at: 1234,
|
|
})
|
|
const result = await client.createReaction(
|
|
'userCount',
|
|
{},
|
|
{
|
|
action: 'handler',
|
|
handler_code: 'export default (event) => console.log(event)',
|
|
handler_timeout_ms: 10000,
|
|
},
|
|
)
|
|
expect(result.id).toBe(3)
|
|
expect(result.action).toBe('handler')
|
|
expect(result.handler_code).toBe('export default (event) => console.log(event)')
|
|
expect(result.handler_timeout_ms).toBe(10000)
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
expect(body.action).toBe('handler')
|
|
expect(body.handler_code).toBe('export default (event) => console.log(event)')
|
|
expect(body.handler_timeout_ms).toBe(10000)
|
|
})
|
|
})
|
|
|
|
describe('listReactions', () => {
|
|
it('GETs /reactions', async () => {
|
|
await initClient()
|
|
mockOk({ reactions: [] })
|
|
const result = await client.listReactions()
|
|
expect(Array.isArray(result)).toBe(true)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/reactions')
|
|
})
|
|
})
|
|
|
|
describe('deleteReaction', () => {
|
|
it('DELETEs /reactions/:id with numeric id', async () => {
|
|
await initClient()
|
|
mockOk({ ok: true })
|
|
const result = await client.deleteReaction(3)
|
|
expect(result.ok).toBe(true)
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/reactions/3')
|
|
expect(opts.method).toBe('DELETE')
|
|
})
|
|
})
|
|
|
|
// ─── health ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('health', () => {
|
|
it('GETs /health', async () => {
|
|
await initClient()
|
|
mockOk({ status: 'ok', version: '2.4.0' })
|
|
const result = await client.health()
|
|
expect(result.status).toBe('ok')
|
|
expect(result.version).toBe('2.4.0')
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/health')
|
|
})
|
|
})
|
|
|
|
// ─── reaction logs ────────────────────────────────────────────────────────
|
|
|
|
describe('listReactionLogs', () => {
|
|
it('GETs /reaction-logs without filters', async () => {
|
|
await initClient()
|
|
mockOk({ reaction_logs: [{ id: 1, reaction_id: 10, event_id: 20, status: 'success', created_at: 1234 }], total: 1 })
|
|
const result = await client.listReactionLogs()
|
|
expect(result.reaction_logs).toHaveLength(1)
|
|
expect(result.total).toBe(1)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/reaction-logs')
|
|
})
|
|
|
|
it('GETs /reaction-logs with reaction_id filter', async () => {
|
|
await initClient()
|
|
mockOk({ reaction_logs: [], total: 0 })
|
|
await client.listReactionLogs(10, 0, 5)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/reaction-logs?limit=10&offset=0&reaction_id=5')
|
|
})
|
|
})
|
|
|
|
// ─── request logs ────────────────────────────────────────────────────────────
|
|
|
|
describe('listRequestLogs', () => {
|
|
it('GETs /request-logs without filters', async () => {
|
|
await initClient()
|
|
mockOk({ request_logs: [{ id: 1, api_key_id: 3, method: 'POST', path: '/events', status: 200, created_at: 1234 }], total: 1 })
|
|
const result = await client.listRequestLogs()
|
|
expect(result.request_logs).toHaveLength(1)
|
|
expect(result.total).toBe(1)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/request-logs')
|
|
})
|
|
|
|
it('GETs /request-logs with api_key_id filter', async () => {
|
|
await initClient()
|
|
mockOk({ request_logs: [], total: 0 })
|
|
await client.listRequestLogs(20, 5, 7)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/request-logs?limit=20&offset=5&api_key_id=7')
|
|
})
|
|
})
|
|
|
|
// ─── api keys ────────────────────────────────────────────────────────────────
|
|
|
|
describe('createApiKey', () => {
|
|
it('POSTs to /api-keys with name', async () => {
|
|
await initClient()
|
|
mockOk({ api_key: { id: 1, name: 'test-key', prefix: 'og_abc', created_at: 1234 }, plaintext_key: 'og_abc_secret123' })
|
|
const result = await client.createApiKey('test-key')
|
|
expect(result.api_key.name).toBe('test-key')
|
|
expect(result.plaintext_key).toBe('og_abc_secret123')
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/api-keys')
|
|
expect(opts.method).toBe('POST')
|
|
const body = JSON.parse(opts.body as string)
|
|
expect(body.name).toBe('test-key')
|
|
})
|
|
|
|
it('POSTs to /api-keys with allowed_events and rate_limit', async () => {
|
|
await initClient()
|
|
mockOk({ api_key: { id: 2, name: 'limited', prefix: 'og_xyz', allowed_events: ['UserCreated'], rate_limit: 100, created_at: 1234 }, plaintext_key: 'og_xyz_key456' })
|
|
const result = await client.createApiKey('limited', ['UserCreated'], 100)
|
|
expect(result.api_key.allowed_events).toEqual(['UserCreated'])
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
expect(body.allowed_events).toEqual(['UserCreated'])
|
|
expect(body.rate_limit).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('listApiKeys', () => {
|
|
it('GETs /api-keys', async () => {
|
|
await initClient()
|
|
mockOk({ api_keys: [{ id: 1, name: 'test-key', prefix: 'og_abc', created_at: 1234 }], total: 1 })
|
|
const result = await client.listApiKeys()
|
|
expect(result.api_keys).toHaveLength(1)
|
|
expect(result.total).toBe(1)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/api-keys')
|
|
})
|
|
|
|
it('GETs /api-keys with pagination', async () => {
|
|
await initClient()
|
|
mockOk({ api_keys: [], total: 0 })
|
|
await client.listApiKeys(5, 10)
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/api-keys?limit=5&offset=10')
|
|
})
|
|
})
|
|
|
|
describe('deleteApiKey', () => {
|
|
it('DELETEs /api-keys/:id', async () => {
|
|
await initClient()
|
|
mockOk({ deleted: 1 })
|
|
const result = await client.deleteApiKey(3)
|
|
expect(result.deleted).toBe(1)
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.example.com/api-keys/3')
|
|
expect(opts.method).toBe('DELETE')
|
|
})
|
|
})
|
|
|
|
// ─── schema ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('getSchema', () => {
|
|
it('GETs /schema and returns all definitions', async () => {
|
|
await initClient()
|
|
mockOk({
|
|
object_defs: [{ name: 'user' }],
|
|
event_defs: [{ name: 'UserCreated', schema: { properties: {} } }],
|
|
projection_defs: [{ name: 'userCount', sources: [] }],
|
|
})
|
|
const result = await client.getSchema()
|
|
expect(result.object_defs).toHaveLength(1)
|
|
expect(result.event_defs).toHaveLength(1)
|
|
expect(result.projection_defs).toHaveLength(1)
|
|
expect(result.object_defs[0].name).toBe('user')
|
|
expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/schema')
|
|
})
|
|
})
|
|
|
|
// ─── error handling ──────────────────────────────────────────────────────────
|
|
|
|
describe('error handling', () => {
|
|
it('throws on 401', async () => {
|
|
await initClient()
|
|
mockFail(401, 'Unauthorized')
|
|
await expect(client.listObjectDefs()).rejects.toThrow('Authentication failed. Check your token.')
|
|
})
|
|
|
|
it('throws API error message on non-2xx', async () => {
|
|
await initClient()
|
|
mockFail(400, 'Invalid request')
|
|
await expect(client.createObjectDef('bad')).rejects.toThrow('Invalid request')
|
|
})
|
|
|
|
it('throws connection error on fetch failure', async () => {
|
|
await initClient()
|
|
mockFetch.mockRejectedValue(new Error('fetch failed: connection refused'))
|
|
await expect(client.health()).rejects.toThrow('Cannot reach OGraph API at https://api.example.com')
|
|
})
|
|
})
|
|
})
|