honojs-middleware/packages/event-emitter/src/index.test.ts

437 lines
15 KiB
TypeScript
Raw Normal View History

import { Hono } from 'hono'
import type { Context } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createEmitter, defineHandler, defineHandlers, emitter } from './index'
import type { Emitter } from './index' // Adjust the import path as needed
describe('Event Emitter Middleware', () => {
describe('createEmitter', () => {
it('should create an emitter with initial handlers', () => {
type EventPayloadMap = {
test: { id: string; text: string }
}
const handlers = {
test: [vi.fn()],
}
const ee = createEmitter<EventPayloadMap>(handlers)
expect(ee).toBeDefined()
expect(ee.emit).toBeDefined()
expect(ee.on).toBeDefined()
expect(ee.off).toBeDefined()
expect(ee.emitAsync).toBeDefined()
})
it('should create an emitter without initial handlers', () => {
const ee = createEmitter()
expect(ee).toBeDefined()
})
it('should allow adding and removing handlers', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
const handler = vi.fn()
ee.on('test', handler)
ee.emit({} as Context, 'test', 'payload')
expect(handler).toHaveBeenCalledWith({}, 'payload')
ee.off('test', handler)
ee.emit({} as Context, 'test', 'payload')
expect(handler).toHaveBeenCalledTimes(1)
})
it('should remove all handlers for an event when no handler is specified', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
const handler1 = vi.fn()
const handler2 = vi.fn()
ee.on('test', handler1)
ee.on('test', handler2)
ee.off('test')
ee.emit({} as Context, 'test', 'payload')
expect(handler1).not.toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
})
it('should emit events to all registered handlers', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
const handler1 = vi.fn()
const handler2 = vi.fn()
ee.on('test', handler1)
ee.on('test', handler2)
ee.emit({} as Context, 'test', 'payload')
expect(handler1).toHaveBeenCalledWith({}, 'payload')
expect(handler2).toHaveBeenCalledWith({}, 'payload')
})
it('should not add the same named function handler multiple times', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
const handler = vi.fn()
ee.on('test', handler)
ee.on('test', handler)
ee.emit({} as Context, 'test', 'payload')
expect(handler).toHaveBeenCalledTimes(1)
})
it('should emit async events concurrently', async () => {
type EventPayloadMap = {
test: { id: string }
}
const ee = createEmitter<EventPayloadMap>()
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const handler1 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async (_c, _payload) => {
await delay(100)
})
)
const handler2 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async (_c, _payload) => {
await delay(100)
})
)
ee.on('test', handler1)
ee.on('test', handler2)
const start = Date.now()
await ee.emitAsync({} as Context, 'test', { id: '123' }, { mode: 'concurrent' })
const end = Date.now()
// The total time should be close to 100ms (since handlers run concurrently)
// We'll allow a small margin for execution time
expect(end - start).toBeLessThan(150)
expect(handler1).toHaveBeenCalledWith(expect.anything(), { id: '123' })
expect(handler2).toHaveBeenCalledWith(expect.anything(), { id: '123' })
expect(handler1).toHaveBeenCalledTimes(1)
expect(handler2).toHaveBeenCalledTimes(1)
})
it('should emit async events sequentially', async () => {
type EventPayloadMap = {
test: { id: string }
}
const ee = createEmitter<EventPayloadMap>()
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const handler1 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async (_c, _payload) => {
await delay(101)
})
)
const handler2 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async (_c, _payload) => {
await delay(101)
})
)
ee.on('test', handler1)
ee.on('test', handler2)
const start = Date.now()
await ee.emitAsync({} as Context, 'test', { id: '123' }, { mode: 'sequencial' })
const end = Date.now()
// The total time should be close to 200ms (since handlers run sequentially)
// We'll allow a small margin for execution time
expect(end - start).toBeGreaterThanOrEqual(200)
expect(end - start).toBeLessThan(250)
expect(handler1).toHaveBeenCalledWith(expect.anything(), { id: '123' })
expect(handler2).toHaveBeenCalledWith(expect.anything(), { id: '123' })
expect(handler1).toHaveBeenCalledTimes(1)
expect(handler2).toHaveBeenCalledTimes(1)
})
it('should throw AggregateError when async handlers fail using emitAsync with concurent mode', async () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
const handler1 = vi.fn().mockRejectedValue(new Error('Error 1'))
const handler2 = vi.fn().mockRejectedValue(new Error('Error 2'))
ee.on('test', handler1)
ee.on('test', handler2)
await expect(ee.emitAsync({} as Context, 'test', 'payload')).rejects.toThrow(AggregateError)
try {
await ee.emitAsync({} as Context, 'test', 'payload', { mode: 'concurrent' })
// Should not reach here
expect(true).toBe(false)
} catch (error) {
expect((error as AggregateError).errors).toHaveLength(2)
expect((error as AggregateError).errors[0].message).toBe('Error 1')
expect((error as AggregateError).errors[1].message).toBe('Error 2')
}
})
it('should stop execution on first error in async handlers fail using emitAsync with sequential mode', async () => {
type EventPayloadMap = {
test: { id: string }
}
const ee = createEmitter<EventPayloadMap>()
const handler1 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async () => {
throw new Error('Error 1')
})
)
const handler2 = vi.fn(
defineHandler<EventPayloadMap, 'test'>(async () => {
// This should not be called
})
)
ee.on('test', handler1)
ee.on('test', handler2)
try {
await ee.emitAsync({} as Context, 'test', { id: '789' }, { mode: 'sequencial' })
// Should not reach here
expect(true).toBe(false)
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Error 1')
}
expect(handler1).toHaveBeenCalledWith(expect.anything(), { id: '789' })
expect(handler1).toHaveBeenCalledTimes(1)
expect(handler2).not.toHaveBeenCalled()
})
it('should throw TypeError when adding a non-function handler', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
expect(() => ee.on('test', 'not a function' as any)).toThrow(TypeError)
})
it('should throw RangeError when max handlers limit is reached', () => {
type EventPayloadMap = { test: string }
const emitter = createEmitter<EventPayloadMap>({}, { maxHandlers: 3 })
emitter.on('test', vi.fn())
emitter.on('test', vi.fn())
emitter.on('test', vi.fn())
expect(() => emitter.on('test', vi.fn())).toThrow(RangeError)
})
it('should use default max handlers limit of 10 when not specified', () => {
type EventPayloadMap = { testEvent: string }
const emitter = createEmitter<EventPayloadMap>()
for (let i = 0; i < 10; i++) {
emitter.on('testEvent', vi.fn())
}
expect(() => emitter.on('testEvent', vi.fn())).toThrow(RangeError)
})
it('should allow different events to have their own handler counts', () => {
type EventPayloadMap = { test1: string; test2: string }
const emitter = createEmitter<EventPayloadMap>({}, { maxHandlers: 2 })
emitter.on('test1', vi.fn())
emitter.on('test1', vi.fn())
emitter.on('test2', vi.fn())
expect(() => emitter.on('test1', vi.fn())).toThrow(RangeError)
expect(() => emitter.on('test2', vi.fn())).not.toThrow()
})
it('should include event key in error message when limit is reached', () => {
type EventPayloadMap = { specificEvent: string }
const emitter = createEmitter<EventPayloadMap>({}, { maxHandlers: 1 })
emitter.on('specificEvent', vi.fn())
expect(() => emitter.on('specificEvent', vi.fn())).toThrow(/specificEvent/)
})
it('should allow setting custom max handlers limit', () => {
type EventPayloadMap = { test: string }
const emitter = createEmitter<EventPayloadMap>({}, { maxHandlers: 5 })
for (let i = 0; i < 5; i++) {
emitter.on('test', vi.fn())
}
expect(() => emitter.on('test', vi.fn())).toThrow(RangeError)
})
it('should do nothing when emitting an event with no handlers', () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
expect(() => ee.emit({} as Context, 'test', 'payload')).not.toThrow()
})
it('should do nothing when emitting an async event with no handlers', async () => {
type EventPayloadMap = {
test: string
}
const ee = createEmitter<EventPayloadMap>()
await expect(ee.emitAsync({} as Context, 'test', 'payload')).resolves.toBeUndefined()
})
})
describe('emitter middleware', () => {
it('should add emitter to context', async () => {
type EventPayloadMap = {
test: string
}
const middleware = emitter<EventPayloadMap>()
const context = {
set: vi.fn(),
} as unknown as Context
const next = vi.fn()
await middleware(context, next)
expect(context.set).toHaveBeenCalledWith('emitter', expect.any(Object))
expect(next).toHaveBeenCalled()
})
it('should create emitter with provided handlers', async () => {
const handler = vi.fn()
type EventPayloadMap = {
test: string
}
const middleware = emitter<EventPayloadMap>({ test: [handler] })
let capturedEmitter: Emitter<EventPayloadMap> | undefined
const context = {
set: vi.fn().mockImplementation((key, value) => {
if (key === 'emitter') {
capturedEmitter = value
}
}),
} as unknown as Context
const next = vi.fn()
await middleware(context, next)
expect(context.set).toHaveBeenCalledWith('emitter', expect.any(Object))
expect(capturedEmitter).toBeDefined()
capturedEmitter?.emit({} as Context, 'test', 'payload')
expect(handler).toHaveBeenCalledWith({}, 'payload')
})
})
describe('defineHandler', () => {
it('should return the provided handler', () => {
type EventPayloadMap = {
test: number
}
const handler = (_c: Context, _payload: number) => {}
const definedHandler = defineHandler<EventPayloadMap, 'test'>(handler)
expect(definedHandler).toBe(handler)
})
})
describe('defineHandlers', () => {
it('should return the provided handlers object', () => {
const handlers = {
test: [(_c: Context, _payload: number) => {}],
}
const definedHandlers = defineHandlers(handlers)
expect(definedHandlers).toBe(handlers)
})
})
describe('type safety', () => {
it('should enforce correct types for event payloads', () => {
type EventPayloadMap = {
numberEvent: number
objectEvent: { id: string }
}
const ee = createEmitter<EventPayloadMap>()
// These should compile without errors
ee.on('numberEvent', (_c, payload) => {
const _num: number = payload
})
ee.on('objectEvent', (_c, payload) => {
const _id: string = payload.id
})
// @ts-expect-error - payload should be a number
ee.emit('numberEvent', {} as Context, 'not a number')
// @ts-expect-error - payload should be an object with an id property
ee.emit('objectEvent', {} as Context, { wrongKey: 'value' })
// These should compile without errors
ee.emit({} as Context, 'numberEvent', 42)
ee.emit({} as Context, 'objectEvent', { id: 'test' })
})
})
describe('Hono request flow', () => {
it('should work when assigning event handlers via middleware', async () => {
type EventPayloadMap = {
'todo:created': { id: string; text: string }
}
type Env = { Variables: { emitter: Emitter<EventPayloadMap> } }
const handlers = defineHandlers<EventPayloadMap>({
'todo:created': [vi.fn((_c, _payload) => {})],
})
const app = new Hono<Env>()
app.use(emitter(handlers))
const ee = createEmitter<{ sdf: string; adsf: number }>()
ee.on('adsf', vi.fn())
let currentContext = null
app.post('/todo', (c) => {
currentContext = c
c.get('emitter').emit(c, 'todo:created', { id: '2', text: 'Buy milk' })
return c.json({ message: 'Todo created' })
})
const res = await app.request('http://localhost/todo', { method: 'POST' })
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(handlers['todo:created']?.[0]).toHaveBeenCalledWith(currentContext, {
id: '2',
text: 'Buy milk',
})
})
it('should work when assigning async event handlers via middleware', async () => {
type EventPayloadMap = {
'todo:created': { id: string; text: string }
}
type Env = { Variables: { emitter: Emitter<EventPayloadMap> } }
const handlers = defineHandlers<EventPayloadMap>({
'todo:created': [vi.fn(async (_c, _payload) => {})],
})
const app = new Hono<Env>()
app.use(emitter(handlers))
let currentContext = null
app.post('/todo', async (c) => {
currentContext = c
await c.get('emitter').emitAsync(c, 'todo:created', { id: '2', text: 'Buy milk' })
return c.json({ message: 'Todo created' })
})
const res = await app.request('http://localhost/todo', { method: 'POST' })
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(handlers['todo:created']?.[0]).toHaveBeenCalledWith(currentContext, {
id: '2',
text: 'Buy milk',
})
})
})
})