diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js new file mode 100644 index 0000000..472b7a1 --- /dev/null +++ b/server/src/routes/__tests__/sse.test.js @@ -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') + }) +})