This commit is contained in:
Kirill
2026-05-28 22:08:49 +05:00
parent 21aa19b5d1
commit 5b9c2f4c46
7 changed files with 153 additions and 4 deletions
+1
View File
@@ -23,6 +23,7 @@ export function SseProvider() {
function invalidateOrderQueries(orderId: unknown) { function invalidateOrderQueries(orderId: unknown) {
if (!orderId) return if (!orderId) return
queryClient.invalidateQueries({ queryKey: ['me', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] }) queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] }) queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
@@ -107,6 +107,7 @@ describe('SseProvider', () => {
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
@@ -118,6 +119,7 @@ describe('SseProvider', () => {
renderSse() renderSse()
const handler = mockEventHandlers['order:statusChanged'] const handler = mockEventHandlers['order:statusChanged']
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) })) 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: ['me', 'orders', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
@@ -129,6 +131,7 @@ describe('SseProvider', () => {
renderSse() renderSse()
const handler = mockEventHandlers['order:updated'] const handler = mockEventHandlers['order:updated']
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) })) 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: ['me', 'orders', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
Binary file not shown.
+45
View File
@@ -39,6 +39,19 @@ describe('isAdminUser', () => {
expect(isAdminUser(null)).toBe(false) expect(isAdminUser(null)).toBe(false)
expect(isAdminUser(undefined)).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', () => { describe('buildSseListeners', () => {
@@ -96,6 +109,20 @@ describe('buildSseListeners', () => {
cleanup() 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', () => { it('forwards payment:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write) const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
@@ -104,6 +131,15 @@ describe('buildSseListeners', () => {
cleanup() 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', () => { it('forwards order:deliveryFeeAdjusted to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write) const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
@@ -112,6 +148,15 @@ describe('buildSseListeners', () => {
cleanup() 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', () => { it('forwards order:created to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write) const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
@@ -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',
})
})
})
+10 -4
View File
@@ -10,7 +10,13 @@ const {
} = NOTIFICATION_EVENTS } = NOTIFICATION_EVENTS
export function isAdminUser(user) { 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) { export function formatSSE(event, data) {
@@ -53,21 +59,21 @@ export function buildSseListeners(userId, admin, eventBus, write) {
on( on(
ORDER_STATUS_CHANGED, ORDER_STATUS_CHANGED,
(p) => p.userId === userId, (p) => admin || p.userId === userId,
'order:statusChanged', 'order:statusChanged',
(p) => ({ orderId: p.orderId, newStatus: p.newStatus }), (p) => ({ orderId: p.orderId, newStatus: p.newStatus }),
) )
on( on(
PAYMENT_STATUS_CHANGED, PAYMENT_STATUS_CHANGED,
(p) => p.userId === userId, (p) => admin || p.userId === userId,
'order:statusChanged', 'order:statusChanged',
(p) => ({ orderId: p.orderId }), (p) => ({ orderId: p.orderId }),
) )
on( on(
DELIVERY_FEE_ADJUSTED, DELIVERY_FEE_ADJUSTED,
(p) => p.userId === userId, (p) => admin || p.userId === userId,
'order:updated', 'order:updated',
(p) => ({ orderId: p.orderId }), (p) => ({ orderId: p.orderId }),
) )
+6
View File
@@ -267,6 +267,12 @@ export async function registerUserOrderRoutes(fastify) {
} }
await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) 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' } return { ok: true, status: 'DONE' }
}), }),
) )