@opentermsarchive/engine 10.3.3 → 10.5.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/config/default.json +2 -1
- package/package.json +1 -1
- package/src/archivist/fetcher/fullDomFetcher.js +149 -49
- package/src/archivist/fetcher/fullDomFetcher.test.js +32 -0
- package/src/collection-api/logger.js +6 -4
- package/src/logger/index.js +6 -5
- package/src/logger/mail-transport-with-retry.js +46 -0
- package/src/logger/mail-transport-with-retry.test.js +192 -0
package/config/default.json
CHANGED
package/package.json
CHANGED
|
@@ -8,37 +8,64 @@ let browser;
|
|
|
8
8
|
export default async function fetch(url, cssSelectors, config) {
|
|
9
9
|
puppeteer.use(stealthPlugin({ locale: config.language }));
|
|
10
10
|
|
|
11
|
-
let context;
|
|
12
|
-
let page;
|
|
13
|
-
let client;
|
|
14
|
-
let response;
|
|
15
|
-
const selectors = [].concat(cssSelectors);
|
|
16
|
-
|
|
17
11
|
if (!browser) {
|
|
18
12
|
throw new Error('The headless browser should be controlled manually with "launchHeadlessBrowser" and "stopHeadlessBrowser".');
|
|
19
13
|
}
|
|
20
14
|
|
|
15
|
+
let context;
|
|
16
|
+
let page;
|
|
17
|
+
let client;
|
|
18
|
+
|
|
21
19
|
try {
|
|
22
20
|
context = await browser.createBrowserContext(); // Create an isolated browser context to ensure complete isolation between fetches (cookies, localStorage, sessionStorage, IndexedDB, cache)
|
|
23
21
|
page = await context.newPage();
|
|
22
|
+
client = await page.createCDPSession();
|
|
24
23
|
|
|
25
|
-
await page
|
|
26
|
-
await page.setDefaultNavigationTimeout(config.navigationTimeout);
|
|
27
|
-
await page.setExtraHTTPHeaders({ 'Accept-Language': config.language });
|
|
24
|
+
await configurePage(page, client, config);
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
client = await page.createCDPSession();
|
|
26
|
+
const selectors = [].concat(cssSelectors).filter(Boolean);
|
|
31
27
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
let pdf = {};
|
|
29
|
+
let handled = null;
|
|
30
|
+
|
|
31
|
+
if (!selectors.length) { // CSS selectors are specified only for HTML content and omitted when fetching a PDF
|
|
32
|
+
({ pdf, handled } = setupPdfInterception(client));
|
|
33
|
+
}
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
let response;
|
|
36
|
+
let navigationAborted = false;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
response = await page.goto(url, { waitUntil: 'load' }); // Using `load` instead of `networkidle0` as it's more reliable and faster. The 'load' event fires when the page and all its resources (stylesheets, scripts, images) have finished loading. `networkidle0` can be problematic as it waits for 500ms of network inactivity, which may never occur on dynamic pages and then triggers a navigation timeout.
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.message.includes('net::ERR_ABORTED')) {
|
|
42
|
+
// Chrome may sometimes abort navigation for files such as PDFs.
|
|
43
|
+
// Do not throw for now; wait for the PDF interception handler to finish processing the response.
|
|
44
|
+
navigationAborted = true;
|
|
45
|
+
} else {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
// PDF interception handling
|
|
51
|
+
if (handled) {
|
|
52
|
+
await handled; // Wait for the interception callback to finish processing the response
|
|
53
|
+
|
|
54
|
+
if (pdf.content) {
|
|
55
|
+
return {
|
|
56
|
+
mimeType: 'application/pdf',
|
|
57
|
+
content: pdf.content,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (pdf.status) { // Status captured by CDP interception
|
|
62
|
+
throw new Error(`Received HTTP code ${pdf.status} when trying to fetch '${url}'`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (navigationAborted) {
|
|
67
|
+
throw new Error(`Navigation aborted when trying to fetch '${url}'`);
|
|
68
|
+
}
|
|
42
69
|
|
|
43
70
|
if (!response) {
|
|
44
71
|
throw new Error(`Response is empty when trying to fetch '${url}'`);
|
|
@@ -46,31 +73,11 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
46
73
|
|
|
47
74
|
const statusCode = response.status();
|
|
48
75
|
|
|
49
|
-
if (
|
|
76
|
+
if (!isValidHttpStatus(statusCode)) {
|
|
50
77
|
throw new Error(`Received HTTP code ${statusCode} when trying to fetch '${url}'`);
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
page.waitForFunction(
|
|
55
|
-
cssSelector => {
|
|
56
|
-
const element = document.querySelector(cssSelector); // eslint-disable-line no-undef
|
|
57
|
-
|
|
58
|
-
return element?.textContent.trim().length; // Ensures element exists and contains non-empty text, as an empty element may indicate content is still loading
|
|
59
|
-
},
|
|
60
|
-
{ timeout: config.waitForElementsTimeout },
|
|
61
|
-
selector,
|
|
62
|
-
));
|
|
63
|
-
|
|
64
|
-
// We expect all elements to be present on the page…
|
|
65
|
-
await Promise.all(waitForSelectorsPromises).catch(error => {
|
|
66
|
-
if (error.name == 'TimeoutError') {
|
|
67
|
-
// however, if they are not, this is not considered as an error since selectors may be out of date
|
|
68
|
-
// and the whole content of the page should still be returned.
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
throw error;
|
|
73
|
-
});
|
|
80
|
+
await waitForSelectors(page, selectors, config.waitForElementsTimeout);
|
|
74
81
|
|
|
75
82
|
return {
|
|
76
83
|
mimeType: 'text/html',
|
|
@@ -80,17 +87,10 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
80
87
|
if (error.name === 'TimeoutError') {
|
|
81
88
|
throw new Error(`Timed out after ${config.navigationTimeout / 1000} seconds when trying to fetch '${url}'`);
|
|
82
89
|
}
|
|
90
|
+
|
|
83
91
|
throw new Error(error.message);
|
|
84
92
|
} finally {
|
|
85
|
-
|
|
86
|
-
await client.detach();
|
|
87
|
-
}
|
|
88
|
-
if (page) {
|
|
89
|
-
await page.close();
|
|
90
|
-
}
|
|
91
|
-
if (context) {
|
|
92
|
-
await context.close(); // Close the isolated context to free resources and ensure complete cleanup
|
|
93
|
-
}
|
|
93
|
+
await cleanupPage(client, page, context);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -151,3 +151,103 @@ export async function stopHeadlessBrowser() {
|
|
|
151
151
|
await browser.close();
|
|
152
152
|
browser = null;
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
function isValidHttpStatus(status) {
|
|
156
|
+
return (status >= 200 && status < 300) || status === 304;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function configurePage(page, client, config) {
|
|
160
|
+
await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
|
|
161
|
+
await page.setDefaultNavigationTimeout(config.navigationTimeout);
|
|
162
|
+
await page.setExtraHTTPHeaders({ 'Accept-Language': config.language });
|
|
163
|
+
|
|
164
|
+
// Use CDP to ensure browser language is set correctly (see https://zirkelc.dev/posts/puppeteer-language-experiment)
|
|
165
|
+
await client.send('Network.setUserAgentOverride', {
|
|
166
|
+
userAgent: await browser.userAgent(),
|
|
167
|
+
acceptLanguage: config.language,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
|
|
171
|
+
await page.authenticate(browser.proxyCredentials);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setupPdfInterception(client) {
|
|
176
|
+
const pdf = { content: null, status: null };
|
|
177
|
+
let onHandled;
|
|
178
|
+
const handled = new Promise(resolve => { onHandled = resolve; });
|
|
179
|
+
|
|
180
|
+
client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it's handled by the PDF viewer
|
|
181
|
+
|
|
182
|
+
client.on('Fetch.requestPaused', async ({ requestId, resourceType, responseHeaders, responseStatusCode }) => {
|
|
183
|
+
try {
|
|
184
|
+
const contentType = responseHeaders?.find(header => header.name.toLowerCase() === 'content-type')?.value;
|
|
185
|
+
|
|
186
|
+
if (!contentType?.includes('application/pdf')) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pdf.status = responseStatusCode;
|
|
191
|
+
|
|
192
|
+
if (!isValidHttpStatus(responseStatusCode)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const { body, base64Encoded } = await client.send('Fetch.getResponseBody', { requestId });
|
|
198
|
+
|
|
199
|
+
pdf.content = Buffer.from(body, base64Encoded ? 'base64' : 'utf8');
|
|
200
|
+
} catch {
|
|
201
|
+
// Response body may be unavailable due to network error or connection interruption
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
try {
|
|
205
|
+
await client.send('Fetch.continueResponse', { requestId });
|
|
206
|
+
} catch {
|
|
207
|
+
// Client may have been closed by cleanupPage() in fetch() while this async callback was still running
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (resourceType === 'Document') { // Signal that the main navigation request has been processed
|
|
211
|
+
onHandled();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { pdf, handled };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function waitForSelectors(page, selectors, timeout) {
|
|
220
|
+
const waitForSelectorsPromises = selectors.filter(Boolean).map(selector =>
|
|
221
|
+
page.waitForFunction(
|
|
222
|
+
cssSelector => {
|
|
223
|
+
const element = document.querySelector(cssSelector); // eslint-disable-line no-undef
|
|
224
|
+
|
|
225
|
+
return element?.textContent.trim().length; // Ensures element exists and has non-empty text
|
|
226
|
+
},
|
|
227
|
+
{ timeout },
|
|
228
|
+
selector,
|
|
229
|
+
));
|
|
230
|
+
|
|
231
|
+
// We expect all elements to be present on the page…
|
|
232
|
+
await Promise.all(waitForSelectorsPromises).catch(error => {
|
|
233
|
+
if (error.name == 'TimeoutError') {
|
|
234
|
+
// however, if they are not, this is not considered as an error since selectors may be out of date
|
|
235
|
+
// and the whole content of the page should still be returned.
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw error;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function cleanupPage(client, page, context) {
|
|
244
|
+
if (client) {
|
|
245
|
+
await client.detach().catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
if (page) {
|
|
248
|
+
await page.close().catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
if (context) {
|
|
251
|
+
await context.close().catch(() => {}); // Close the isolated context to free resources and ensure complete cleanup
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import http from 'http';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
2
5
|
|
|
3
6
|
import { expect, use } from 'chai';
|
|
4
7
|
import chaiAsPromised from 'chai-as-promised';
|
|
5
8
|
|
|
6
9
|
import fetch, { launchHeadlessBrowser, stopHeadlessBrowser } from './fullDomFetcher.js';
|
|
7
10
|
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
8
13
|
const SERVER_PORT = 8977;
|
|
9
14
|
|
|
10
15
|
use(chaiAsPromised);
|
|
@@ -16,6 +21,7 @@ describe('Full DOM Fetcher', function () {
|
|
|
16
21
|
this.timeout(60000);
|
|
17
22
|
|
|
18
23
|
let temporaryServer;
|
|
24
|
+
let expectedPDFContent;
|
|
19
25
|
|
|
20
26
|
before(async () => {
|
|
21
27
|
await launchHeadlessBrowser();
|
|
@@ -27,6 +33,10 @@ describe('Full DOM Fetcher', function () {
|
|
|
27
33
|
if (request.url === '/delayed-content') {
|
|
28
34
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(delayedContentHTML);
|
|
29
35
|
}
|
|
36
|
+
if (request.url === '/terms.pdf') {
|
|
37
|
+
expectedPDFContent = fs.readFileSync(path.resolve(__dirname, '../../../test/fixtures/terms.pdf'));
|
|
38
|
+
response.writeHead(200, { 'Content-Type': 'application/pdf' }).write(expectedPDFContent);
|
|
39
|
+
}
|
|
30
40
|
|
|
31
41
|
return response.end();
|
|
32
42
|
}).listen(SERVER_PORT);
|
|
@@ -85,5 +95,27 @@ describe('Full DOM Fetcher', function () {
|
|
|
85
95
|
await expect(fetch(url, ['.content'], { ...config, navigationTimeout: timeout })).to.be.rejectedWith(`Timed out after ${timeout / 1000} seconds when trying to fetch '${url}'`);
|
|
86
96
|
});
|
|
87
97
|
});
|
|
98
|
+
|
|
99
|
+
context('when URL targets a PDF file', () => {
|
|
100
|
+
let content;
|
|
101
|
+
let mimeType;
|
|
102
|
+
const pdfUrl = `http://127.0.0.1:${SERVER_PORT}/terms.pdf`;
|
|
103
|
+
|
|
104
|
+
before(async () => {
|
|
105
|
+
({ content, mimeType } = await fetch(pdfUrl, [], config));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns a buffer for PDF content', () => {
|
|
109
|
+
expect(content).to.be.an.instanceOf(Buffer);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns the correct MIME type', () => {
|
|
113
|
+
expect(mimeType).to.equal('application/pdf');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns the PDF file content', () => {
|
|
117
|
+
expect(content.equals(expectedPDFContent)).to.be.true;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
88
120
|
});
|
|
89
121
|
});
|
|
@@ -3,7 +3,8 @@ import os from 'os';
|
|
|
3
3
|
import config from 'config';
|
|
4
4
|
import dotenv from 'dotenv';
|
|
5
5
|
import winston from 'winston';
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
import MailTransportWithRetry from '../logger/mail-transport-with-retry.js';
|
|
7
8
|
|
|
8
9
|
dotenv.config({ quiet: true });
|
|
9
10
|
|
|
@@ -12,14 +13,15 @@ const { combine, timestamp, printf, colorize } = winston.format;
|
|
|
12
13
|
const transports = [new winston.transports.Console()];
|
|
13
14
|
|
|
14
15
|
if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
15
|
-
transports.push(new
|
|
16
|
+
transports.push(new MailTransportWithRetry({
|
|
16
17
|
to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
|
|
17
18
|
from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
|
|
18
19
|
host: config.get('@opentermsarchive/engine.logger.smtp.host'),
|
|
20
|
+
port: config.get('@opentermsarchive/engine.logger.smtp.port'),
|
|
19
21
|
username: config.get('@opentermsarchive/engine.logger.smtp.username'),
|
|
20
22
|
password: process.env.OTA_ENGINE_SMTP_PASSWORD,
|
|
21
|
-
|
|
22
|
-
timeout:
|
|
23
|
+
tls: true,
|
|
24
|
+
timeout: 60 * 1000,
|
|
23
25
|
formatter: args => args[Object.getOwnPropertySymbols(args)[1]], // Returns the full error message, the same visible in the console. It is referenced in the argument object with a Symbol of which we do not have the reference but we know it is the second one.
|
|
24
26
|
exitOnError: true,
|
|
25
27
|
level: 'error',
|
package/src/logger/index.js
CHANGED
|
@@ -2,10 +2,10 @@ import os from 'os';
|
|
|
2
2
|
|
|
3
3
|
import config from 'config';
|
|
4
4
|
import winston from 'winston';
|
|
5
|
-
import 'winston-mail';
|
|
6
5
|
|
|
7
6
|
import { getCollection } from '../archivist/collection/index.js';
|
|
8
7
|
|
|
8
|
+
import MailTransportWithRetry from './mail-transport-with-retry.js';
|
|
9
9
|
import { formatDuration } from './utils.js';
|
|
10
10
|
|
|
11
11
|
const { combine, timestamp, printf, colorize } = winston.format;
|
|
@@ -57,10 +57,11 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
|
57
57
|
to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
|
|
58
58
|
from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
|
|
59
59
|
host: config.get('@opentermsarchive/engine.logger.smtp.host'),
|
|
60
|
+
port: config.get('@opentermsarchive/engine.logger.smtp.port'),
|
|
60
61
|
username: config.get('@opentermsarchive/engine.logger.smtp.username'),
|
|
61
62
|
password: process.env.OTA_ENGINE_SMTP_PASSWORD,
|
|
62
|
-
|
|
63
|
-
timeout:
|
|
63
|
+
tls: true,
|
|
64
|
+
timeout: 60 * 1000,
|
|
64
65
|
html: false,
|
|
65
66
|
formatter({ message, level }) {
|
|
66
67
|
const isError = level.includes('error');
|
|
@@ -141,14 +142,14 @@ if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
|
|
|
141
142
|
},
|
|
142
143
|
};
|
|
143
144
|
|
|
144
|
-
transports.push(new
|
|
145
|
+
transports.push(new MailTransportWithRetry({
|
|
145
146
|
...mailerOptions,
|
|
146
147
|
level: 'error',
|
|
147
148
|
subject: `Server error on ${collection.id} collection`,
|
|
148
149
|
}));
|
|
149
150
|
|
|
150
151
|
if (config.get('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings')) {
|
|
151
|
-
transports.push(new
|
|
152
|
+
transports.push(new MailTransportWithRetry({
|
|
152
153
|
...mailerOptions,
|
|
153
154
|
level: 'warn',
|
|
154
155
|
subject: `Inaccessible content on ${collection.id} collection`,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import async from 'async';
|
|
4
|
+
import winston from 'winston';
|
|
5
|
+
|
|
6
|
+
import 'winston-mail';
|
|
7
|
+
|
|
8
|
+
export const RETRY_DELAYS = [ 5000, 20000, 60000 ];
|
|
9
|
+
|
|
10
|
+
const RETRY_OPTIONS = {
|
|
11
|
+
times: RETRY_DELAYS.length + 1,
|
|
12
|
+
interval: retryCount => RETRY_DELAYS[retryCount - 1] || RETRY_DELAYS.at(-1),
|
|
13
|
+
errorFilter: error => {
|
|
14
|
+
console.warn(`SMTP mail sending failed: ${error.message}; retrying…`);
|
|
15
|
+
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class MailTransportWithRetry extends winston.Transport {
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super(options);
|
|
23
|
+
this.mailTransport = new winston.transports.Mail(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async log(info, callback) {
|
|
27
|
+
try {
|
|
28
|
+
await async.retry(RETRY_OPTIONS, async () => {
|
|
29
|
+
const result = Promise.race([
|
|
30
|
+
once(this.mailTransport, 'logged'),
|
|
31
|
+
once(this.mailTransport, 'error').then(([err]) => { throw err; }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
this.mailTransport.log(info, () => {});
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.warn(`SMTP mail sending failed after ${RETRY_OPTIONS.times} attempts: ${error.message}`);
|
|
40
|
+
this.emit('error', error);
|
|
41
|
+
}
|
|
42
|
+
callback();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default MailTransportWithRetry;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import sinon from 'sinon';
|
|
5
|
+
import winston from 'winston';
|
|
6
|
+
|
|
7
|
+
import MailTransportWithRetry, { RETRY_DELAYS } from './mail-transport-with-retry.js';
|
|
8
|
+
|
|
9
|
+
class MockMailTransport extends EventEmitter {
|
|
10
|
+
log() {} // eslint-disable-line
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('MailTransportWithRetry', () => {
|
|
14
|
+
let clock;
|
|
15
|
+
let mockMailTransport;
|
|
16
|
+
let transport;
|
|
17
|
+
let consoleWarnStub;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
clock = sinon.useFakeTimers();
|
|
21
|
+
mockMailTransport = new MockMailTransport();
|
|
22
|
+
sinon.stub(winston.transports, 'Mail').returns(mockMailTransport);
|
|
23
|
+
consoleWarnStub = sinon.stub(console, 'warn');
|
|
24
|
+
transport = new MailTransportWithRetry({ to: 'test@example.com' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
sinon.restore();
|
|
29
|
+
clock.restore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('#log', () => {
|
|
33
|
+
context('when email is sent successfully on first attempt', () => {
|
|
34
|
+
it('calls callback without error', async () => {
|
|
35
|
+
const callback = sinon.spy();
|
|
36
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
37
|
+
|
|
38
|
+
mockMailTransport.emit('logged');
|
|
39
|
+
await logPromise;
|
|
40
|
+
|
|
41
|
+
expect(callback).to.have.been.calledOnce;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does not emit error event', async () => {
|
|
45
|
+
const errorHandler = sinon.spy();
|
|
46
|
+
|
|
47
|
+
transport.on('error', errorHandler);
|
|
48
|
+
|
|
49
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
50
|
+
|
|
51
|
+
mockMailTransport.emit('logged');
|
|
52
|
+
await logPromise;
|
|
53
|
+
|
|
54
|
+
expect(errorHandler).not.to.have.been.called;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not log any warning', async () => {
|
|
58
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
59
|
+
|
|
60
|
+
mockMailTransport.emit('logged');
|
|
61
|
+
await logPromise;
|
|
62
|
+
|
|
63
|
+
expect(consoleWarnStub).not.to.have.been.called;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
context('when email fails then succeeds on retry', () => {
|
|
68
|
+
it('retries and eventually succeeds', async () => {
|
|
69
|
+
const callback = sinon.spy();
|
|
70
|
+
const logSpy = sinon.spy(mockMailTransport, 'log');
|
|
71
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
72
|
+
|
|
73
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
74
|
+
|
|
75
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
76
|
+
|
|
77
|
+
mockMailTransport.emit('logged');
|
|
78
|
+
await logPromise;
|
|
79
|
+
|
|
80
|
+
expect(logSpy).to.have.been.calledTwice;
|
|
81
|
+
expect(callback).to.have.been.calledOnce;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('logs warning for failed attempt', async () => {
|
|
85
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
86
|
+
|
|
87
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
88
|
+
|
|
89
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
90
|
+
|
|
91
|
+
mockMailTransport.emit('logged');
|
|
92
|
+
await logPromise;
|
|
93
|
+
|
|
94
|
+
expect(consoleWarnStub).to.have.been.calledOnce;
|
|
95
|
+
expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP mail sending failed');
|
|
96
|
+
expect(consoleWarnStub.firstCall.args[0]).to.include('SMTP timeout');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
context('when email fails after all retry attempts', () => {
|
|
101
|
+
it('emits error event after all retries are exhausted', async () => {
|
|
102
|
+
const errorHandler = sinon.spy();
|
|
103
|
+
|
|
104
|
+
transport.on('error', errorHandler);
|
|
105
|
+
|
|
106
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
107
|
+
|
|
108
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
109
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
110
|
+
|
|
111
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
112
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
113
|
+
|
|
114
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
115
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
116
|
+
|
|
117
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
118
|
+
await logPromise;
|
|
119
|
+
|
|
120
|
+
expect(errorHandler).to.have.been.calledOnce;
|
|
121
|
+
expect(errorHandler.firstCall.args[0].message).to.equal('SMTP timeout');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('calls callback even after failure', async () => {
|
|
125
|
+
const callback = sinon.spy();
|
|
126
|
+
|
|
127
|
+
transport.on('error', () => {}); // Prevent unhandled error
|
|
128
|
+
|
|
129
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
130
|
+
|
|
131
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
132
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
133
|
+
|
|
134
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
135
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
136
|
+
|
|
137
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
138
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
139
|
+
|
|
140
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
141
|
+
await logPromise;
|
|
142
|
+
|
|
143
|
+
expect(callback).to.have.been.calledOnce;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('logs final failure warning', async () => {
|
|
147
|
+
transport.on('error', () => {}); // Prevent unhandled error
|
|
148
|
+
|
|
149
|
+
const logPromise = transport.log({ message: 'test' }, () => {});
|
|
150
|
+
|
|
151
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
152
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
153
|
+
|
|
154
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
155
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
156
|
+
|
|
157
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
158
|
+
await clock.tickAsync(RETRY_DELAYS[2]);
|
|
159
|
+
|
|
160
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
161
|
+
await logPromise;
|
|
162
|
+
|
|
163
|
+
expect(consoleWarnStub.lastCall.args[0]).to.include(`failed after ${RETRY_DELAYS.length + 1} attempts`);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
context('when email succeeds after multiple failures', () => {
|
|
168
|
+
it('succeeds on third attempt', async () => {
|
|
169
|
+
const callback = sinon.spy();
|
|
170
|
+
const errorHandler = sinon.spy();
|
|
171
|
+
const logSpy = sinon.spy(mockMailTransport, 'log');
|
|
172
|
+
|
|
173
|
+
transport.on('error', errorHandler);
|
|
174
|
+
|
|
175
|
+
const logPromise = transport.log({ message: 'test' }, callback);
|
|
176
|
+
|
|
177
|
+
mockMailTransport.emit('error', new Error('SMTP timeout'));
|
|
178
|
+
await clock.tickAsync(RETRY_DELAYS[0]);
|
|
179
|
+
|
|
180
|
+
mockMailTransport.emit('error', new Error('Connection refused'));
|
|
181
|
+
await clock.tickAsync(RETRY_DELAYS[1]);
|
|
182
|
+
|
|
183
|
+
mockMailTransport.emit('logged');
|
|
184
|
+
await logPromise;
|
|
185
|
+
|
|
186
|
+
expect(logSpy).to.have.been.calledThrice;
|
|
187
|
+
expect(callback).to.have.been.calledOnce;
|
|
188
|
+
expect(errorHandler).not.to.have.been.called;
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|