diff --git a/client/src/app/providers/SseProvider.tsx b/client/src/app/providers/SseProvider.tsx index 0d16a73..5b24b46 100644 --- a/client/src/app/providers/SseProvider.tsx +++ b/client/src/app/providers/SseProvider.tsx @@ -23,6 +23,7 @@ export function SseProvider() { function invalidateOrderQueries(orderId: unknown) { if (!orderId) return + queryClient.invalidateQueries({ queryKey: ['me', 'orders'] }) queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] }) queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] }) diff --git a/client/src/app/providers/__tests__/SseProvider.test.tsx b/client/src/app/providers/__tests__/SseProvider.test.tsx index 3da6488..7bc3ba8 100644 --- a/client/src/app/providers/__tests__/SseProvider.test.tsx +++ b/client/src/app/providers/__tests__/SseProvider.test.tsx @@ -107,6 +107,7 @@ describe('SseProvider', () => { expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) @@ -118,6 +119,7 @@ describe('SseProvider', () => { renderSse() const handler = mockEventHandlers['order:statusChanged'] handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) @@ -129,6 +131,7 @@ describe('SseProvider', () => { renderSse() const handler = mockEventHandlers['order:updated'] handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) })) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d3ff4da..86fb7d2 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js index da79a2e..df2da65 100644 --- a/server/src/routes/__tests__/sse.test.js +++ b/server/src/routes/__tests__/sse.test.js @@ -39,6 +39,19 @@ describe('isAdminUser', () => { expect(isAdminUser(null)).toBe(false) expect(isAdminUser(undefined)).toBe(false) }) + + it('normalizes email before comparing with ADMIN_EMAIL', () => { + const previousAdminEmail = process.env.ADMIN_EMAIL + process.env.ADMIN_EMAIL = 'Admin@Test.com' + + expect(isAdminUser({ email: ' admin@test.com ' })).toBe(true) + + if (previousAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL + } else { + process.env.ADMIN_EMAIL = previousAdminEmail + } + }) }) describe('buildSseListeners', () => { @@ -96,6 +109,20 @@ describe('buildSseListeners', () => { cleanup() }) + it('forwards order:statusChanged to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:statusChanged', { + orderId: 'o1', + userId: 'user-1', + oldStatus: 'READY_FOR_PICKUP', + newStatus: 'DONE', + }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + 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' }) @@ -104,6 +131,15 @@ describe('buildSseListeners', () => { cleanup() }) + it('forwards payment:statusChanged to admin', () => { + const cleanup = buildSseListeners('admin-1', true, 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') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + 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 }) @@ -112,6 +148,15 @@ describe('buildSseListeners', () => { cleanup() }) + it('forwards order:deliveryFeeAdjusted to admin', () => { + const cleanup = buildSseListeners('admin-1', true, 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') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + 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 }) diff --git a/server/src/routes/__tests__/user-orders.test.js b/server/src/routes/__tests__/user-orders.test.js new file mode 100644 index 0000000..c03d62d --- /dev/null +++ b/server/src/routes/__tests__/user-orders.test.js @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto' +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { prisma } from '../../lib/prisma.js' +import { registerUserOrderRoutes } from '../user-orders.js' + +const JWT_SECRET = 'test-secret' + +let app +let testUser +let testUserEmail + +async function buildApp() { + const fastify = Fastify({ logger: false }) + await fastify.register(jwt, { secret: JWT_SECRET }) + fastify.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + fastify.decorate('eventBus', { emit: vi.fn() }) + await registerUserOrderRoutes(fastify) + await fastify.ready() + return fastify +} + +async function signToken(user) { + return app.jwt.sign({ sub: user.id, email: user.email }) +} + +async function createOrder(data = {}) { + return prisma.order.create({ + data: { + userId: testUser.id, + status: 'SHIPPED', + deliveryType: 'delivery', + deliveryFeeLocked: true, + paymentMethod: 'online', + itemsSubtotalCents: 10000, + deliveryFeeCents: 50000, + totalCents: 60000, + currency: 'RUB', + ...data, + }, + }) +} + +describe('user order routes', () => { + beforeEach(async () => { + testUserEmail = `user-orders-${randomUUID()}@example.com` + testUser = await prisma.user.create({ data: { email: testUserEmail } }) + app = await buildApp() + }) + + afterEach(async () => { + await app?.close() + if (testUser?.id) { + await prisma.order.deleteMany({ where: { userId: testUser.id } }) + await prisma.user.deleteMany({ where: { id: testUser.id } }) + } else if (testUserEmail) { + await prisma.user.deleteMany({ where: { email: testUserEmail } }) + } + vi.clearAllMocks() + }) + + it('emits order status event when user confirms receiving an order', async () => { + const order = await createOrder() + const token = await signToken(testUser) + + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${order.id}/confirm-received`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.statusCode).toBe(200) + expect(app.eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: order.id, + userId: testUser.id, + oldStatus: 'SHIPPED', + newStatus: 'DONE', + }) + }) +}) diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js index 33eb514..15980fe 100644 --- a/server/src/routes/sse.js +++ b/server/src/routes/sse.js @@ -10,7 +10,13 @@ const { } = NOTIFICATION_EVENTS export function isAdminUser(user) { - return !!(process.env.ADMIN_EMAIL && user?.email === process.env.ADMIN_EMAIL) + const adminEmail = String(process.env.ADMIN_EMAIL || '') + .trim() + .toLowerCase() + const userEmail = String(user?.email || '') + .trim() + .toLowerCase() + return !!(adminEmail && userEmail === adminEmail) } export function formatSSE(event, data) { @@ -53,21 +59,21 @@ export function buildSseListeners(userId, admin, eventBus, write) { on( ORDER_STATUS_CHANGED, - (p) => p.userId === userId, + (p) => admin || p.userId === userId, 'order:statusChanged', (p) => ({ orderId: p.orderId, newStatus: p.newStatus }), ) on( PAYMENT_STATUS_CHANGED, - (p) => p.userId === userId, + (p) => admin || p.userId === userId, 'order:statusChanged', (p) => ({ orderId: p.orderId }), ) on( DELIVERY_FEE_ADJUSTED, - (p) => p.userId === userId, + (p) => admin || p.userId === userId, 'order:updated', (p) => ({ orderId: p.orderId }), ) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 4d5cc45..d74ba91 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -267,6 +267,12 @@ export async function registerUserOrderRoutes(fastify) { } await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: order.id, + userId: order.userId, + oldStatus: order.status, + newStatus: 'DONE', + }) return { ok: true, status: 'DONE' } }), )