211 lines
5.3 KiB
TypeScript
211 lines
5.3 KiB
TypeScript
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-shared/types'
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { heartbeatFrameFrom, resolveEventDelivery, selectConsumerPeerId } from './index'
|
|
|
|
function createInputTextEvent(
|
|
overrides: Partial<WebSocketBaseEvent<'input:text', WebSocketEvents['input:text']>> = {},
|
|
): WebSocketBaseEvent<'input:text', WebSocketEvents['input:text']> {
|
|
return {
|
|
type: 'input:text',
|
|
data: {
|
|
text: 'hello',
|
|
...overrides.data,
|
|
},
|
|
metadata: overrides.metadata ?? {
|
|
source: {
|
|
kind: 'plugin',
|
|
plugin: { id: 'discord' },
|
|
id: 'discord-instance',
|
|
},
|
|
event: {
|
|
id: 'event-1',
|
|
},
|
|
},
|
|
route: overrides.route,
|
|
}
|
|
}
|
|
|
|
describe('resolveEventDelivery', () => {
|
|
it('uses protocol event metadata defaults for input:text', () => {
|
|
const delivery = resolveEventDelivery(createInputTextEvent())
|
|
|
|
expect(delivery).toEqual({
|
|
mode: 'consumer-group',
|
|
group: 'chat-ingestion',
|
|
selection: 'first',
|
|
})
|
|
})
|
|
|
|
it('allows route delivery to override protocol defaults', () => {
|
|
const delivery = resolveEventDelivery(createInputTextEvent({
|
|
route: {
|
|
delivery: {
|
|
required: true,
|
|
selection: 'sticky',
|
|
stickyKey: 'discord-dm-user-1',
|
|
},
|
|
},
|
|
}))
|
|
|
|
expect(delivery).toEqual({
|
|
mode: 'consumer-group',
|
|
group: 'chat-ingestion',
|
|
required: true,
|
|
selection: 'sticky',
|
|
stickyKey: 'discord-dm-user-1',
|
|
})
|
|
})
|
|
|
|
it('returns explicit route delivery for events without protocol defaults', () => {
|
|
const delivery = resolveEventDelivery({
|
|
type: 'spark:notify',
|
|
data: {
|
|
id: 'spark-1',
|
|
eventId: 'spark-notify-1',
|
|
kind: 'ping',
|
|
urgency: 'soon',
|
|
headline: 'hello',
|
|
destinations: ['module:character'],
|
|
},
|
|
metadata: {
|
|
source: {
|
|
kind: 'plugin',
|
|
plugin: { id: 'stage-web' },
|
|
id: 'stage-web-instance',
|
|
},
|
|
event: {
|
|
id: 'event-2',
|
|
},
|
|
},
|
|
route: {
|
|
delivery: {
|
|
mode: 'consumer',
|
|
required: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(delivery).toEqual({
|
|
mode: 'consumer',
|
|
required: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('selectConsumerPeerId', () => {
|
|
it('selects the highest-priority healthy consumer in the delivery group', () => {
|
|
const selectedPeerId = selectConsumerPeerId({
|
|
eventType: 'input:text',
|
|
fromPeerId: 'discord-instance',
|
|
delivery: {
|
|
mode: 'consumer-group',
|
|
group: 'chat-ingestion',
|
|
selection: 'priority',
|
|
},
|
|
candidates: [
|
|
{
|
|
peerId: 'stage-window-a',
|
|
priority: 10,
|
|
registeredAt: 2,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
{
|
|
peerId: 'stage-window-b',
|
|
priority: 20,
|
|
registeredAt: 3,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
{
|
|
peerId: 'stage-window-c',
|
|
priority: 30,
|
|
registeredAt: 1,
|
|
authenticated: true,
|
|
healthy: false,
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(selectedPeerId).toBe('stage-window-b')
|
|
})
|
|
|
|
it('keeps sticky delivery on the same consumer when available', () => {
|
|
const stickyAssignments = new Map<string, string>()
|
|
|
|
const firstSelectedPeerId = selectConsumerPeerId({
|
|
eventType: 'input:text',
|
|
fromPeerId: 'discord-instance',
|
|
delivery: {
|
|
mode: 'consumer-group',
|
|
group: 'chat-ingestion',
|
|
selection: 'sticky',
|
|
stickyKey: 'discord-dm-user-1',
|
|
},
|
|
candidates: [
|
|
{
|
|
peerId: 'stage-window-a',
|
|
priority: 10,
|
|
registeredAt: 1,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
{
|
|
peerId: 'stage-window-b',
|
|
priority: 10,
|
|
registeredAt: 2,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
],
|
|
stickyAssignments,
|
|
})
|
|
|
|
const secondSelectedPeerId = selectConsumerPeerId({
|
|
eventType: 'input:text',
|
|
fromPeerId: 'discord-instance',
|
|
delivery: {
|
|
mode: 'consumer-group',
|
|
group: 'chat-ingestion',
|
|
selection: 'sticky',
|
|
stickyKey: 'discord-dm-user-1',
|
|
},
|
|
candidates: [
|
|
{
|
|
peerId: 'stage-window-a',
|
|
priority: 10,
|
|
registeredAt: 1,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
{
|
|
peerId: 'stage-window-b',
|
|
priority: 10,
|
|
registeredAt: 2,
|
|
authenticated: true,
|
|
healthy: true,
|
|
},
|
|
],
|
|
stickyAssignments,
|
|
})
|
|
|
|
expect(firstSelectedPeerId).toBe('stage-window-a')
|
|
expect(secondSelectedPeerId).toBe('stage-window-a')
|
|
})
|
|
})
|
|
|
|
describe('heartbeatFrameFrom', () => {
|
|
it('recognizes raw websocket control frame text without treating it as protocol JSON', () => {
|
|
expect(heartbeatFrameFrom('ping')).toBe('ping')
|
|
expect(heartbeatFrameFrom('pong')).toBe('pong')
|
|
})
|
|
|
|
it('ignores non-control payloads', () => {
|
|
expect(heartbeatFrameFrom('')).toBeUndefined()
|
|
expect(heartbeatFrameFrom('🩵')).toBeUndefined()
|
|
expect(heartbeatFrameFrom('{"type":"transport:connection:heartbeat"}')).toBeUndefined()
|
|
})
|
|
})
|