fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s

This commit is contained in:
root
2026-07-22 22:31:39 -04:00
parent bcabd17220
commit f6fcd7ce54
26 changed files with 1171 additions and 361 deletions
+107 -94
View File
@@ -1,26 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('resend', () => ({ Resend: vi.fn() }))
vi.mock('twilio', () => ({ default: vi.fn(() => ({ messages: { create: vi.fn() } })) }))
vi.mock('firebase-admin', () => ({
default: {
apps: [],
initializeApp: vi.fn(),
credential: { cert: vi.fn() },
messaging: vi.fn(() => ({ send: vi.fn() })),
},
}))
vi.mock('../lib/prisma', () => ({
prisma: {
notification: {
create: vi.fn(),
update: vi.fn(),
const prismaMock = vi.hoisted(() => {
const tx = {
notificationEvent: {
create: vi.fn().mockResolvedValue({ id: 'event_1' }),
},
},
}))
vi.mock('../lib/redis', () => ({
redis: { publish: vi.fn(), on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
}))
notificationRecipient: {
create: vi.fn().mockResolvedValue({ id: 'recipient_1' }),
},
notificationDelivery: {
create: vi.fn().mockImplementation(({ data }) => Promise.resolve({ id: `delivery_${data.channel}`, ...data })),
},
notificationOutbox: {
create: vi.fn().mockResolvedValue({ id: 'outbox_1' }),
},
}
return {
tx,
prisma: {
employee: { findFirst: vi.fn(), findMany: vi.fn() },
renter: { findUnique: vi.fn() },
notificationPreference: { findFirst: vi.fn() },
companyNotificationPreference: { findUnique: vi.fn() },
notificationEvent: { findUniqueOrThrow: vi.fn() },
$transaction: vi.fn((callback) => callback(tx)),
},
}
})
vi.mock('resend', () => ({ Resend: vi.fn() }))
vi.mock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
vi.mock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
@@ -31,104 +41,107 @@ vi.mock('./notificationLocalizationService', async () => {
})
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { sendNotification } from './notificationService'
import { createNotification, sendNotification } from './notificationService'
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
describe('notificationService delivery boundaries', () => {
describe('notificationService command boundaries', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.notification.create).mockResolvedValue({ id: 'notification_1', title: 'Hello', body: 'Body' } as never)
vi.mocked(prisma.notification.update).mockResolvedValue({ id: 'notification_1' } as never)
vi.mocked(prisma.employee.findFirst).mockResolvedValue({
id: 'employee_1',
email: 'agent@example.test',
preferredLanguage: 'en',
} as never)
vi.mocked(prisma.notificationEvent.findUniqueOrThrow).mockResolvedValue({ recipients: [] } as never)
vi.mocked(prisma.notificationPreference.findFirst).mockResolvedValue(null as never)
vi.mocked(prisma.companyNotificationPreference.findUnique).mockResolvedValue(null as never)
vi.mocked(resolveNotificationLocale).mockResolvedValue('en')
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
})
it('persists and publishes in-app notifications for employee recipients', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
it('creates an event, explicit recipient, delivery rows, and an outbox row without inline provider calls', async () => {
const result = await createNotification({
companyId: 'company_1',
employeeId: 'employee_1',
channels: ['IN_APP' as never],
data: { severity: 'low' },
type: 'NEW_BOOKING',
audience: { type: 'EMPLOYEE', employeeId: 'employee_1' },
channels: ['IN_APP', 'EMAIL'],
source: { type: 'reservation', id: 'reservation_1' },
idempotencyKey: 'reservation_1:new_booking:employee_1',
title: 'New booking',
body: 'A renter booked a vehicle.',
data: { reservationId: 'reservation_1' },
})
expect(result).toEqual([{ channel: 'IN_APP', success: true }])
expect(resolveNotificationLocale).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
employeeId: 'employee_1',
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
expect(result.duplicate).toBe(false)
expect(prisma.notificationEvent.findUniqueOrThrow).not.toHaveBeenCalled()
expect(prismaMock.tx.notificationEvent.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'SYSTEM_ALERT',
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
channel: 'IN_APP',
status: 'PENDING',
companyId: 'company_1',
type: 'NEW_BOOKING',
sourceType: 'reservation',
sourceId: 'reservation_1',
}),
})
expect(prismaMock.tx.notificationRecipient.create).toHaveBeenCalledWith({
data: {
notificationEventId: 'event_1',
recipientType: 'EMPLOYEE',
employeeId: 'employee_1',
renterId: null,
}),
},
})
expect(redis.publish).toHaveBeenCalledWith(
'notifications:employee_1',
expect.stringContaining('"status":"DELIVERED"'),
)
expect(prisma.notification.update).toHaveBeenCalledWith({
where: { id: 'notification_1' },
data: expect.objectContaining({ status: 'SENT', providerMessageId: null }),
})
})
it('uses resolved templates and records failed channels without throwing', async () => {
vi.mocked(resolveNotificationTemplate).mockResolvedValue({
templateKey: 'reservation.confirmed',
locale: 'fr',
subject: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
usedFallback: false,
version: 2,
} as never)
const result = await sendNotification({
type: 'RESERVATION_UPDATE' as never,
templateKey: 'reservation.confirmed',
templateVariables: { firstName: 'Aya' },
companyId: 'company_1',
channels: ['EMAIL' as never],
email: 'aya@example.test',
})
expect(result).toEqual([{ channel: 'EMAIL', success: false, error: 'No email provider is configured' }])
expect(resolveNotificationTemplate).toHaveBeenCalledWith(expect.objectContaining({
templateKey: 'reservation.confirmed',
channel: 'EMAIL',
locale: 'en',
variables: { firstName: 'Aya' },
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
templateKey: 'reservation.confirmed',
locale: 'fr',
notificationRecipientId: 'recipient_1',
channel: 'IN_APP',
status: 'QUEUED',
preferenceDecision: 'ENABLED_BY_PRODUCT_DEFAULT',
}),
})
expect(prismaMock.tx.notificationOutbox.create).toHaveBeenCalledWith({
data: expect.objectContaining({
notificationEventId: 'event_1',
payload: expect.objectContaining({
notificationEventId: 'event_1',
type: 'NEW_BOOKING',
}),
}),
})
expect(prisma.notification.update).not.toHaveBeenCalled()
})
it('fails incomplete notification content before persistence', async () => {
it('records skipped delivery decisions for unsupported optional channels', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Missing body',
channels: ['IN_APP' as never],
type: 'BOOKING_CONFIRMED',
title: 'Booking confirmed',
body: 'Your booking is confirmed.',
companyId: 'company_1',
employeeId: 'employee_1',
channels: ['PUSH'],
data: { reservationId: 'reservation_1' },
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'Notification content is incomplete for channel IN_APP' }])
expect(prisma.notification.create).not.toHaveBeenCalled()
expect(redis.publish).not.toHaveBeenCalled()
expect(result).toEqual([
{ channel: 'PUSH', success: false, error: 'Delivery channel disabled by notification policy or preference.' },
])
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
channel: 'PUSH',
status: 'SKIPPED',
preferenceDecision: 'SKIPPED_UNSUPPORTED_CHANNEL',
}),
})
})
it('fails before persistence when an explicit recipient is missing', async () => {
const result = await sendNotification({
type: 'NEW_BOOKING',
title: 'Missing recipient',
body: 'No target.',
companyId: 'company_1',
channels: ['IN_APP'],
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
})
})