exp-node-server 2.0.0 → 2.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.
@@ -0,0 +1,162 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import pg from 'pg';
3
+ import type { PgBoss } from 'pg-boss';
4
+ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { getBoss, newBoss } from '../../src/app/queues/boss.js';
6
+ import {
7
+ createEmailQueue,
8
+ EMAIL_QUEUE,
9
+ enqueueEmail,
10
+ type EmailJob,
11
+ startEmailWorker,
12
+ } from '../../src/app/queues/email.queue.js';
13
+ import { startQueues, stopQueues } from '../../src/app/queues/index.js';
14
+ import sendMail from '../../src/app/utils/mail_sender.js';
15
+
16
+ // the real queue (tests/setup.ts mocks it for the route tests); only SMTP is faked
17
+ vi.unmock('../../src/app/queues/email.queue.js');
18
+ vi.mock('../../src/app/utils/mail_sender.js', () => ({ default: vi.fn() }));
19
+
20
+ /**
21
+ * pg-boss needs a real PostgreSQL, so these tests only run when one is given:
22
+ * docker compose up -d && TEST_DATABASE_URL=postgresql://... npm test
23
+ * Each run uses its own schema, dropped afterwards.
24
+ */
25
+ const url = process.env.TEST_DATABASE_URL;
26
+ const schema = `pgboss_test_${randomBytes(4).toString('hex')}`;
27
+ const FAST = {
28
+ connectionString: url,
29
+ schema,
30
+ retryDelaySeconds: 1,
31
+ retryLimit: 2,
32
+ pollingIntervalSeconds: 0.5,
33
+ maintenance: false,
34
+ };
35
+
36
+ const send = vi.mocked(sendMail);
37
+
38
+ const email = (to: string): EmailJob => ({
39
+ type: 'reset-password',
40
+ email: to,
41
+ resetLink: `https://app.test/reset?token=${to}`,
42
+ });
43
+
44
+ /** Polls until `check` passes or `timeoutMs` is over. */
45
+ const waitFor = async (check: () => unknown | Promise<unknown>, timeoutMs = 15_000) => {
46
+ const start = Date.now();
47
+ for (;;) {
48
+ try {
49
+ await check();
50
+ return;
51
+ } catch (err) {
52
+ if (Date.now() - start > timeoutMs) throw err;
53
+ await new Promise((r) => setTimeout(r, 50));
54
+ }
55
+ }
56
+ };
57
+
58
+ /** Lets workers run for a while, to show that nothing else happens. */
59
+ const settle = (ms = 1500) => new Promise((r) => setTimeout(r, ms));
60
+
61
+ const extraInstances: PgBoss[] = [];
62
+
63
+ describe.skipIf(!url)('email queue (pg-boss)', { timeout: 30_000 }, () => {
64
+ afterEach(async () => {
65
+ await Promise.all(extraInstances.splice(0).map((b) => b.stop({ graceful: false })));
66
+ await stopQueues().catch(() => {});
67
+ });
68
+
69
+ afterAll(async () => {
70
+ const client = new pg.Client({ connectionString: url });
71
+ await client.connect();
72
+ await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
73
+ await client.end();
74
+ });
75
+
76
+ it('sends a queued email once', async () => {
77
+ send.mockResolvedValue({} as never);
78
+ await startQueues(FAST);
79
+
80
+ const id = await enqueueEmail(email('a@test.com'));
81
+
82
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
83
+ expect(send).toHaveBeenCalledWith(
84
+ expect.objectContaining({ to: 'a@test.com', subject: 'Reset Your Password' }),
85
+ );
86
+ await waitFor(async () =>
87
+ expect(await getBoss().getJobById(EMAIL_QUEUE, id!)).toMatchObject({ state: 'completed' }),
88
+ );
89
+ });
90
+
91
+ it('retries a failed send', async () => {
92
+ send.mockRejectedValueOnce(new Error('SMTP down')).mockResolvedValue({} as never);
93
+ await startQueues(FAST);
94
+
95
+ await enqueueEmail(email('retry@test.com'));
96
+
97
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
98
+ });
99
+
100
+ it('gives up after the retry limit', async () => {
101
+ send.mockRejectedValue(new Error('SMTP down'));
102
+ await startQueues(FAST);
103
+
104
+ const id = await enqueueEmail(email('broken@test.com'));
105
+
106
+ // the first attempt + 2 retries, and then nothing more
107
+ await waitFor(async () =>
108
+ expect(await getBoss().getJobById(EMAIL_QUEUE, id!)).toMatchObject({ state: 'failed' }),
109
+ );
110
+ await settle();
111
+ expect(send).toHaveBeenCalledTimes(3);
112
+ });
113
+
114
+ it('never sends the same email twice, even with several workers', async () => {
115
+ send.mockResolvedValue({} as never);
116
+ await startQueues(FAST);
117
+ const second = newBoss(FAST);
118
+ extraInstances.push(second);
119
+ await second.start();
120
+ await startEmailWorker(second, FAST);
121
+
122
+ const recipients = ['1', '2', '3', '4', '5', '6'].map((n) => `${n}@test.com`);
123
+ await Promise.all(recipients.map((to) => enqueueEmail(email(to))));
124
+
125
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(recipients.length));
126
+ await settle();
127
+ const sentTo = send.mock.calls.map(([mail]) => mail.to).sort();
128
+ expect(sentTo).toEqual(recipients.sort());
129
+ });
130
+
131
+ it('sends the emails that were queued while no worker was running', async () => {
132
+ send.mockResolvedValue({} as never);
133
+ // e.g. the API keeps queueing while the worker process is being deployed
134
+ const producer = newBoss(FAST);
135
+ extraInstances.push(producer);
136
+ await producer.start();
137
+ await createEmailQueue(producer, FAST);
138
+ await producer.send(EMAIL_QUEUE, email('queued@test.com'));
139
+ await settle();
140
+ expect(send).not.toHaveBeenCalled();
141
+
142
+ await startQueues(FAST);
143
+
144
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
145
+ });
146
+
147
+ it('waits for a running send to finish before shutting down', async () => {
148
+ let finished = false;
149
+ send.mockImplementation(async () => {
150
+ await new Promise((r) => setTimeout(r, 500));
151
+ finished = true;
152
+ return {} as never;
153
+ });
154
+ await startQueues(FAST);
155
+ await enqueueEmail(email('slow@test.com'));
156
+ await waitFor(() => expect(send).toHaveBeenCalled());
157
+
158
+ await stopQueues();
159
+
160
+ expect(finished).toBe(true);
161
+ });
162
+ });