Extracted from uncaged monorepo (oc-xiaoju/uncaged). Resolves oc-xiaoju/uncaged#224. - @uncaged/ograph: CF Worker engine (events, projections, reactions) - @uncaged/ograph-cli: CLI for managing OGraph instances - Removed @uncaged/oid dependency (unused) - 116 tests, all passing - CI: GitHub Actions 小橘 🍊(NEKO Team)
375 lines
15 KiB
TypeScript
375 lines
15 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()
|
|
}
|
|
|
|
function mockOk(data: unknown) {
|
|
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(data) })
|
|
}
|
|
|
|
function mockFail(status: number, error: string) {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
status,
|
|
statusText: error,
|
|
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('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')
|
|
})
|
|
})
|
|
|
|
// ─── 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')
|
|
})
|
|
})
|
|
})
|