fix: DI statt Singleton, Queue-Name email-send (BullMQ v5 kein Doppelpunkt), err bei fehlender Job-ID

This commit is contained in:
2026-04-17 10:36:04 +00:00
parent 7baeb004c6
commit 5b24c9f129
2 changed files with 24 additions and 36 deletions

View File

@@ -3,19 +3,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockAdd = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'job-1' })) const mockAdd = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'job-1' }))
vi.mock('bullmq', () => ({ vi.mock('bullmq', () => ({
Queue: vi.fn().mockImplementation(() => ({ Queue: vi.fn().mockImplementation(() => ({ add: mockAdd })),
add: mockAdd,
})),
})) }))
import { enqueueEmailSend, type EmailSendJobData, resetQueueForTesting } from './email-send.queue' import { enqueueEmailSend, emailSendQueue, type EmailSendJobData } from './email-send.queue'
import { Queue } from 'bullmq'
describe('enqueueEmailSend', () => { describe('enqueueEmailSend', () => {
beforeEach(() => { beforeEach(() => vi.clearAllMocks())
vi.clearAllMocks()
resetQueueForTesting()
})
it('enqueued Job mit korrekten Daten', async () => { it('enqueued Job mit korrekten Daten', async () => {
const data: EmailSendJobData = { const data: EmailSendJobData = {
@@ -24,7 +18,7 @@ describe('enqueueEmailSend', () => {
recipientEmail: 'empfaenger@example.com', recipientEmail: 'empfaenger@example.com',
recipientHash: 'abc123hash', recipientHash: 'abc123hash',
} }
const result = await enqueueEmailSend(data) const result = await enqueueEmailSend(data, emailSendQueue)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(mockAdd).toHaveBeenCalledWith('send', data, expect.objectContaining({ attempts: 3 })) expect(mockAdd).toHaveBeenCalledWith('send', data, expect.objectContaining({ attempts: 3 }))
}) })
@@ -36,7 +30,8 @@ describe('enqueueEmailSend', () => {
recipientEmail: 'x@example.com', recipientEmail: 'x@example.com',
recipientHash: 'hash1', recipientHash: 'hash1',
} }
const result = await enqueueEmailSend(data) const result = await enqueueEmailSend(data, emailSendQueue)
expect(result.ok).toBe(true)
if (result.ok) expect(result.data).toBe('job-1') if (result.ok) expect(result.data).toBe('job-1')
}) })
@@ -44,7 +39,7 @@ describe('enqueueEmailSend', () => {
mockAdd.mockRejectedValueOnce(new Error('Redis down')) mockAdd.mockRejectedValueOnce(new Error('Redis down'))
const result = await enqueueEmailSend({ const result = await enqueueEmailSend({
tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1', tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1',
}) }, emailSendQueue)
expect(result.ok).toBe(false) expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toBe('Redis down') if (!result.ok) expect(result.error.message).toBe('Redis down')
}) })
@@ -52,19 +47,20 @@ describe('enqueueEmailSend', () => {
it('nutzt exponentielles Backoff', async () => { it('nutzt exponentielles Backoff', async () => {
await enqueueEmailSend({ await enqueueEmailSend({
tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1', tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1',
}) }, emailSendQueue)
expect(mockAdd).toHaveBeenCalledWith( expect(mockAdd).toHaveBeenCalledWith(
'send', 'send',
expect.anything(), expect.anything(),
expect.objectContaining({ expect.objectContaining({ backoff: expect.objectContaining({ type: 'exponential' }) })
backoff: expect.objectContaining({ type: 'exponential' }),
})
) )
}) })
it('Queue wird mit Queue-Name "email:send" initialisiert', async () => { it('gibt err zurück wenn Job ohne ID erstellt wird', async () => {
await enqueueEmailSend({ tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1' }) mockAdd.mockResolvedValueOnce({ id: undefined })
const { Queue: MockQueue } = await import('bullmq') const result = await enqueueEmailSend({
expect(MockQueue).toHaveBeenCalledWith('email:send', expect.anything()) tenantId: 't1', campaignId: 'c1', recipientEmail: 'x@x.com', recipientHash: 'h1',
}, emailSendQueue)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.message).toContain('ohne ID')
}) })
}) })

View File

@@ -13,29 +13,21 @@ const connection = {
port: Number(process.env.REDIS_PORT ?? 6379), port: Number(process.env.REDIS_PORT ?? 6379),
} }
let _queue: Queue<EmailSendJobData> | null = null export const emailSendQueue = new Queue<EmailSendJobData>('email-send', { connection })
function getQueue(): Queue<EmailSendJobData> { export async function enqueueEmailSend(
if (!_queue) { data: EmailSendJobData,
_queue = new Queue<EmailSendJobData>('email:send', { connection }) queue: Queue<EmailSendJobData> = emailSendQueue
} ): Promise<Result<string>> {
return _queue
}
/** Nur für Tests — setzt den Queue-Singleton zurück */
export function resetQueueForTesting(): void {
_queue = null
}
export async function enqueueEmailSend(data: EmailSendJobData): Promise<Result<string>> {
try { try {
const job = await getQueue().add('send', data, { const job = await queue.add('send', data, {
attempts: 3, attempts: 3,
backoff: { type: 'exponential', delay: 2000 }, backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100, removeOnComplete: 100,
removeOnFail: { count: 500 }, removeOnFail: { count: 500 },
}) })
return ok(job.id ?? 'unknown') if (!job.id) return err(new Error('Job wurde ohne ID erstellt'))
return ok(job.id)
} catch (e) { } catch (e) {
return err(e instanceof Error ? e : new Error(String(e))) return err(e instanceof Error ? e : new Error(String(e)))
} }