ograph/packages/cli/test/client.test.ts
小橘 d84a860d15 feat: initial ograph repo — engine (85 tests) + cli (31 tests)
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)
2026-04-12 23:43:56 +00:00

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')
})
})
})