test: add SSE route tests (TDD red)

This commit is contained in:
Kirill
2026-05-22 18:23:11 +05:00
parent 3212d6c185
commit 6b89f42269
+185
View File
@@ -0,0 +1,185 @@
import Fastify from 'fastify'
import { EventEmitter } from 'node:events'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
describe('formatSSE', () => {
it('formats event with data', () => {
const result = formatSSE('message:new', { orderId: 'o1' })
expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n')
})
it('formats event without data', () => {
const result = formatSSE('heartbit')
expect(result).toBe('event: heartbit\n\n')
})
})
describe('formatHeartbit', () => {
it('returns SSE comment', () => {
expect(formatHeartbit()).toBe(':heartbit\n\n')
})
})
describe('isAdminUser', () => {
it('returns false for non-matching email', () => {
expect(isAdminUser({ email: 'user@test.com' })).toBe(false)
})
it('returns true when email matches ADMIN_EMAIL', () => {
const adminEmail = process.env.ADMIN_EMAIL
if (adminEmail) {
expect(isAdminUser({ email: adminEmail })).toBe(true)
}
})
it('returns false for null/undefined user', () => {
expect(isAdminUser(null)).toBe(false)
expect(isAdminUser(undefined)).toBe(false)
})
})
describe('buildSseListeners', () => {
let eventBus
let write
beforeEach(() => {
eventBus = new EventEmitter()
eventBus.setMaxListeners(50)
write = vi.fn()
})
it('forwards orderMessage:adminReply to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: message:new')
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
cleanup()
})
it('ignores orderMessage:adminReply for non-matching userId', () => {
const cleanup = buildSseListeners('user-2', false, eventBus, write)
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('forwards orderMessage:sent to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: message:new')
cleanup()
})
it('ignores orderMessage:sent for non-admin', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('forwards order:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
cleanup()
})
it('forwards payment:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
cleanup()
})
it('forwards order:deliveryFeeAdjusted to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:updated')
cleanup()
})
it('forwards order:created to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:new')
cleanup()
})
it('forwards order:created:admin to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:new')
cleanup()
})
it('ignores order:created for non-admin', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('cleanup removes all listeners', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
cleanup()
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).not.toHaveBeenCalled()
})
})
describe('GET /api/sse/stream (integration)', () => {
let app
beforeAll(async () => {
app = Fastify({ logger: false })
app.decorate('authenticate', async function (request, reply) {
try {
const token = request.query?.token
if (!token) throw new Error('no token')
if (token === 'user-token') {
request.user = { sub: 'user-1', email: 'user@test.com' }
} else if (token === 'admin-token') {
request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' }
} else {
throw new Error('bad token')
}
} catch {
return reply.code(401).send({ error: 'Unauthorized' })
}
})
app.decorate('eventBus', new EventEmitter())
await registerSseRoutes(app)
await app.ready()
})
afterAll(async () => {
await app.close()
})
it('returns 401 without token', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream' })
expect(res.statusCode).toBe(401)
})
it('returns 200 and event-stream headers for authenticated user', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toBe('text/event-stream')
expect(res.headers['cache-control']).toBe('no-cache')
expect(res.headers['connection']).toBe('keep-alive')
})
it('sends initial heartbit', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
expect(res.body).toContain(':heartbit')
})
})