@feathers-baas/plugin-notifications 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/index.cjs +381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +252 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.js +334 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/drivers/brevo.driver.ts +47 -0
- package/src/drivers/resend.driver.ts +51 -0
- package/src/drivers/sendgrid.driver.ts +43 -0
- package/src/drivers/smtp.driver.ts +58 -0
- package/src/index.ts +28 -0
- package/src/notifier.ts +204 -0
- package/src/queue/producer.ts +44 -0
- package/src/queue/worker.ts +66 -0
- package/src/registry.ts +51 -0
- package/src/types.ts +57 -0
- package/test/drivers/brevo.driver.test.ts +115 -0
- package/test/drivers/resend.driver.test.ts +116 -0
- package/test/drivers/sendgrid.driver.test.ts +109 -0
- package/test/drivers/smtp.driver.test.ts +121 -0
- package/test/queue.test.ts +131 -0
- package/test/setup.ts +2 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// ─── Mock @sendgrid/mail ──────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const { mockSend, mockSetApiKey } = vi.hoisted(() => ({
|
|
6
|
+
mockSend: vi.fn(),
|
|
7
|
+
mockSetApiKey: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
vi.mock('@sendgrid/mail', () => ({
|
|
11
|
+
default: {
|
|
12
|
+
setApiKey: mockSetApiKey,
|
|
13
|
+
send: mockSend,
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
// ─── Imports after mocking ────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
import { SendGridDriver } from '../../src/drivers/sendgrid.driver.js'
|
|
20
|
+
import type { Notification } from '../../src/types.js'
|
|
21
|
+
|
|
22
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('SendGridDriver', () => {
|
|
25
|
+
let driver: SendGridDriver
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockSend.mockReset()
|
|
29
|
+
mockSetApiKey.mockReset()
|
|
30
|
+
mockSend.mockResolvedValue([{ statusCode: 202 }, {}])
|
|
31
|
+
driver = new SendGridDriver({
|
|
32
|
+
apiKey: 'SG.test_key',
|
|
33
|
+
fromName: 'Feathers BaaS',
|
|
34
|
+
fromAddress: 'no-reply@example.com',
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('has the correct driver name', () => {
|
|
39
|
+
expect(driver.name).toBe('sendgrid')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('sets the API key on construction', () => {
|
|
43
|
+
expect(mockSetApiKey).toHaveBeenCalledWith('SG.test_key')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('send', () => {
|
|
47
|
+
it('calls sgMail.send with rendered HTML and correct fields', async () => {
|
|
48
|
+
const notification: Notification = {
|
|
49
|
+
to: 'recipient@example.com',
|
|
50
|
+
channel: 'email',
|
|
51
|
+
subject: 'Welcome!',
|
|
52
|
+
bodyTemplate: '<h1>Hello, <%= name %></h1>',
|
|
53
|
+
data: { name: 'Alice' },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await driver.send(notification)
|
|
57
|
+
|
|
58
|
+
expect(mockSend).toHaveBeenCalledOnce()
|
|
59
|
+
const arg = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
60
|
+
expect(arg['to']).toBe('recipient@example.com')
|
|
61
|
+
expect(arg['from']).toEqual({ name: 'Feathers BaaS', email: 'no-reply@example.com' })
|
|
62
|
+
expect(arg['subject']).toBe('Welcome!')
|
|
63
|
+
expect(arg['html']).toBe('<h1>Hello, Alice</h1>')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('includes text when textTemplate is provided', async () => {
|
|
67
|
+
const notification: Notification = {
|
|
68
|
+
to: 'user@example.com',
|
|
69
|
+
channel: 'email',
|
|
70
|
+
bodyTemplate: '<p><%= msg %></p>',
|
|
71
|
+
textTemplate: 'Plain: <%= msg %>',
|
|
72
|
+
data: { msg: 'test' },
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await driver.send(notification)
|
|
76
|
+
|
|
77
|
+
const arg = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
78
|
+
expect(arg['text']).toBe('Plain: test')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('omits text when no textTemplate is given', async () => {
|
|
82
|
+
const notification: Notification = {
|
|
83
|
+
to: 'user@example.com',
|
|
84
|
+
channel: 'email',
|
|
85
|
+
bodyTemplate: '<p>HTML only</p>',
|
|
86
|
+
data: {},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await driver.send(notification)
|
|
90
|
+
|
|
91
|
+
const arg = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
92
|
+
expect(arg['text']).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('falls back to "(no subject)" when subject is not provided', async () => {
|
|
96
|
+
const notification: Notification = {
|
|
97
|
+
to: 'user@example.com',
|
|
98
|
+
channel: 'email',
|
|
99
|
+
bodyTemplate: 'body',
|
|
100
|
+
data: {},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await driver.send(notification)
|
|
104
|
+
|
|
105
|
+
const arg = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
106
|
+
expect(arg['subject']).toBe('(no subject)')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// ─── Hoist mock variables so they're available inside vi.mock factories ───────
|
|
4
|
+
const { mockSendMail, mockVerify, mockClose } = vi.hoisted(() => ({
|
|
5
|
+
mockSendMail: vi.fn(),
|
|
6
|
+
mockVerify: vi.fn(),
|
|
7
|
+
mockClose: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
vi.mock('nodemailer', () => ({
|
|
11
|
+
default: {
|
|
12
|
+
createTransport: vi.fn().mockReturnValue({
|
|
13
|
+
sendMail: mockSendMail,
|
|
14
|
+
verify: mockVerify,
|
|
15
|
+
close: mockClose,
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
// ─── Imports after mocking ────────────────────────────────────────────────────
|
|
21
|
+
import { SMTPDriver } from '../../src/drivers/smtp.driver.js'
|
|
22
|
+
import type { Notification } from '../../src/types.js'
|
|
23
|
+
|
|
24
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('SMTPDriver', () => {
|
|
27
|
+
let driver: SMTPDriver
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
mockSendMail.mockReset()
|
|
31
|
+
mockSendMail.mockResolvedValue({ messageId: 'msg-123' })
|
|
32
|
+
driver = new SMTPDriver({
|
|
33
|
+
url: 'smtp://user:pass@localhost:1025',
|
|
34
|
+
fromName: 'Feathers BaaS',
|
|
35
|
+
fromAddress: 'no-reply@example.com',
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('has the correct driver name', () => {
|
|
40
|
+
expect(driver.name).toBe('smtp')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('send', () => {
|
|
44
|
+
it('calls sendMail with rendered HTML', async () => {
|
|
45
|
+
const notification: Notification = {
|
|
46
|
+
to: 'recipient@example.com',
|
|
47
|
+
channel: 'email',
|
|
48
|
+
subject: 'Welcome, <%= name %>!',
|
|
49
|
+
bodyTemplate: '<h1>Hello, <%= name %></h1>',
|
|
50
|
+
data: { name: 'Alice' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await driver.send(notification)
|
|
54
|
+
|
|
55
|
+
expect(mockSendMail).toHaveBeenCalledOnce()
|
|
56
|
+
const mailArg = mockSendMail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
57
|
+
expect(mailArg['to']).toBe('recipient@example.com')
|
|
58
|
+
// subject is passed as-is (not EJS-rendered)
|
|
59
|
+
expect(mailArg['subject']).toBe('Welcome, <%= name %>!')
|
|
60
|
+
// HTML body should have EJS rendered
|
|
61
|
+
expect(mailArg['html']).toBe('<h1>Hello, Alice</h1>')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders the text template when provided', async () => {
|
|
65
|
+
const notification: Notification = {
|
|
66
|
+
to: 'user@example.com',
|
|
67
|
+
channel: 'email',
|
|
68
|
+
bodyTemplate: '<p><%= msg %></p>',
|
|
69
|
+
textTemplate: 'Plain: <%= msg %>',
|
|
70
|
+
data: { msg: 'test message' },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await driver.send(notification)
|
|
74
|
+
|
|
75
|
+
const mailArg = mockSendMail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
76
|
+
expect(mailArg['text']).toBe('Plain: test message')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('sets text to undefined when no textTemplate is given', async () => {
|
|
80
|
+
const notification: Notification = {
|
|
81
|
+
to: 'user@example.com',
|
|
82
|
+
channel: 'email',
|
|
83
|
+
bodyTemplate: '<p>HTML only</p>',
|
|
84
|
+
data: {},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await driver.send(notification)
|
|
88
|
+
|
|
89
|
+
const mailArg = mockSendMail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
90
|
+
expect(mailArg['text']).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('uses the configured from address', async () => {
|
|
94
|
+
const notification: Notification = {
|
|
95
|
+
to: 'user@example.com',
|
|
96
|
+
channel: 'email',
|
|
97
|
+
bodyTemplate: 'hi',
|
|
98
|
+
data: {},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await driver.send(notification)
|
|
102
|
+
|
|
103
|
+
const mailArg = mockSendMail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
104
|
+
expect(mailArg['from']).toBe('"Feathers BaaS" <no-reply@example.com>')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to "(no subject)" when subject is not provided', async () => {
|
|
108
|
+
const notification: Notification = {
|
|
109
|
+
to: 'user@example.com',
|
|
110
|
+
channel: 'email',
|
|
111
|
+
bodyTemplate: 'body',
|
|
112
|
+
data: {},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await driver.send(notification)
|
|
116
|
+
|
|
117
|
+
const mailArg = mockSendMail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
118
|
+
expect(mailArg['subject']).toBe('(no subject)')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import type { NotificationJob, Notification } from '../src/types.js'
|
|
3
|
+
|
|
4
|
+
// ─── Mock ioredis with ioredis-mock ───────────────────────────────────────────
|
|
5
|
+
vi.mock('ioredis', async () => {
|
|
6
|
+
const IORedisMock = (await import('ioredis-mock')).default
|
|
7
|
+
return { default: IORedisMock, Redis: IORedisMock }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
// ─── Mock BullMQ ─────────────────────────────────────────────────────────────
|
|
11
|
+
const mockQueueAdd = vi.fn().mockResolvedValue({ id: 'job-1' })
|
|
12
|
+
const mockQueueClose = vi.fn().mockResolvedValue(undefined)
|
|
13
|
+
const mockWorkerClose = vi.fn().mockResolvedValue(undefined)
|
|
14
|
+
let capturedProcessor: ((job: { data: NotificationJob }) => Promise<void>) | undefined
|
|
15
|
+
|
|
16
|
+
vi.mock('bullmq', () => ({
|
|
17
|
+
Queue: vi.fn().mockImplementation(() => ({
|
|
18
|
+
add: mockQueueAdd,
|
|
19
|
+
close: mockQueueClose,
|
|
20
|
+
})),
|
|
21
|
+
Worker: vi.fn().mockImplementation((_name: string, processor: (job: { data: NotificationJob }) => Promise<void>) => {
|
|
22
|
+
capturedProcessor = processor
|
|
23
|
+
return {
|
|
24
|
+
on: vi.fn(),
|
|
25
|
+
close: mockWorkerClose,
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
// ─── Imports after mocking ────────────────────────────────────────────────────
|
|
31
|
+
import { createNotificationQueue, enqueueNotification, NOTIFICATION_QUEUE_NAME } from '../src/queue/producer.js'
|
|
32
|
+
import { createNotificationWorker } from '../src/queue/worker.js'
|
|
33
|
+
import { DriverRegistry } from '../src/registry.js'
|
|
34
|
+
import type { NotifierDriver } from '../src/types.js'
|
|
35
|
+
|
|
36
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const fakeNotification: Notification = {
|
|
39
|
+
to: 'user@example.com',
|
|
40
|
+
channel: 'email',
|
|
41
|
+
subject: 'Test',
|
|
42
|
+
bodyTemplate: '<p>Hello <%= name %></p>',
|
|
43
|
+
data: { name: 'Alice' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('createNotificationQueue', () => {
|
|
47
|
+
it('creates a BullMQ Queue with the correct name', async () => {
|
|
48
|
+
const { Queue } = await import('bullmq')
|
|
49
|
+
const Redis = (await import('ioredis')).default
|
|
50
|
+
const redis = new Redis() as unknown as import('ioredis').Redis
|
|
51
|
+
createNotificationQueue(redis)
|
|
52
|
+
expect(vi.mocked(Queue)).toHaveBeenCalledWith(
|
|
53
|
+
NOTIFICATION_QUEUE_NAME,
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
defaultJobOptions: expect.objectContaining({
|
|
56
|
+
attempts: 5,
|
|
57
|
+
backoff: expect.objectContaining({ type: 'exponential' }),
|
|
58
|
+
}),
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('enqueueNotification', () => {
|
|
65
|
+
beforeEach(() => mockQueueAdd.mockClear())
|
|
66
|
+
|
|
67
|
+
it('adds a job with the correct payload', async () => {
|
|
68
|
+
const { Queue } = await import('bullmq')
|
|
69
|
+
const Redis = (await import('ioredis')).default
|
|
70
|
+
const redis = new Redis() as unknown as import('ioredis').Redis
|
|
71
|
+
const queue = createNotificationQueue(redis) as unknown as Parameters<typeof enqueueNotification>[0]
|
|
72
|
+
|
|
73
|
+
const job: NotificationJob = { driverName: 'smtp', notification: fakeNotification }
|
|
74
|
+
await enqueueNotification(queue, job)
|
|
75
|
+
|
|
76
|
+
expect(mockQueueAdd).toHaveBeenCalledWith('send', job)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('createNotificationWorker', () => {
|
|
81
|
+
let registry: DriverRegistry
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
capturedProcessor = undefined
|
|
85
|
+
registry = new DriverRegistry()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.clearAllMocks()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('creates a BullMQ Worker with the correct queue name', async () => {
|
|
93
|
+
const { Worker } = await import('bullmq')
|
|
94
|
+
const Redis = (await import('ioredis')).default
|
|
95
|
+
const redis = new Redis() as unknown as import('ioredis').Redis
|
|
96
|
+
createNotificationWorker(registry, redis, { concurrency: 3 })
|
|
97
|
+
expect(vi.mocked(Worker)).toHaveBeenCalledWith(
|
|
98
|
+
NOTIFICATION_QUEUE_NAME,
|
|
99
|
+
expect.any(Function),
|
|
100
|
+
expect.objectContaining({ concurrency: 3 }),
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('calls the driver.send() when a job is processed', async () => {
|
|
105
|
+
const Redis = (await import('ioredis')).default
|
|
106
|
+
const redis = new Redis() as unknown as import('ioredis').Redis
|
|
107
|
+
|
|
108
|
+
const mockSend = vi.fn().mockResolvedValue(undefined)
|
|
109
|
+
const fakeDriver: NotifierDriver = { name: 'smtp', send: mockSend }
|
|
110
|
+
registry.registerDriver(fakeDriver)
|
|
111
|
+
|
|
112
|
+
createNotificationWorker(registry, redis)
|
|
113
|
+
|
|
114
|
+
expect(capturedProcessor).toBeDefined()
|
|
115
|
+
|
|
116
|
+
const job: NotificationJob = { driverName: 'smtp', notification: fakeNotification }
|
|
117
|
+
await capturedProcessor!({ data: job })
|
|
118
|
+
|
|
119
|
+
expect(mockSend).toHaveBeenCalledOnce()
|
|
120
|
+
expect(mockSend).toHaveBeenCalledWith(fakeNotification)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('throws when the driver is not registered', async () => {
|
|
124
|
+
const Redis = (await import('ioredis')).default
|
|
125
|
+
const redis = new Redis() as unknown as import('ioredis').Redis
|
|
126
|
+
createNotificationWorker(registry, redis)
|
|
127
|
+
|
|
128
|
+
const job: NotificationJob = { driverName: 'unknown-driver', notification: fakeNotification }
|
|
129
|
+
await expect(capturedProcessor!({ data: job })).rejects.toThrow(/not registered/)
|
|
130
|
+
})
|
|
131
|
+
})
|
package/test/setup.ts
ADDED
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
package/vitest.config.ts
ADDED