test: add SSE route tests (TDD red)
This commit is contained in:
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user