@5minds/node-red-contrib-processcube-tools 1.2.0-develop-d19f89-mg68thdf → 1.2.0-develop-59ef22-mg9d9ja5
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/.mocharc.json +5 -0
- package/package.json +26 -10
- package/src/custom-node-template/custom-node-template.html.template +45 -0
- package/src/custom-node-template/custom-node-template.ts.template +69 -0
- package/src/email-receiver/email-receiver.ts +439 -0
- package/src/email-sender/email-sender.ts +210 -0
- package/{processcube-html-to-text/processcube-html-to-text.html → src/html-to-text/html-to-text.html} +3 -3
- package/src/html-to-text/html-to-text.ts +53 -0
- package/src/index.ts +12 -0
- package/src/interfaces/EmailReceiverMessage.ts +22 -0
- package/src/interfaces/EmailSenderNodeProperties.ts +37 -0
- package/src/interfaces/FetchState.ts +9 -0
- package/src/interfaces/ImapConnectionConfig.ts +14 -0
- package/src/test/framework/advanced-test-patterns.ts +224 -0
- package/src/test/framework/generic-node-test-suite.ts +58 -0
- package/src/test/framework/index.ts +17 -0
- package/src/test/framework/integration-assertions.ts +67 -0
- package/src/test/framework/integration-scenario-builder.ts +77 -0
- package/src/test/framework/integration-test-runner.ts +101 -0
- package/src/test/framework/node-assertions.ts +63 -0
- package/src/test/framework/node-test-runner.ts +260 -0
- package/src/test/framework/test-scenario-builder.ts +74 -0
- package/src/test/framework/types.ts +61 -0
- package/src/test/helpers/email-receiver-test-configs.ts +67 -0
- package/src/test/helpers/email-receiver-test-flows.ts +16 -0
- package/src/test/helpers/email-sender-test-configs.ts +123 -0
- package/src/test/helpers/email-sender-test-flows.ts +16 -0
- package/src/test/integration/email-receiver.integration.test.ts +41 -0
- package/src/test/integration/email-sender.integration.test.ts +129 -0
- package/src/test/interfaces/email-data.ts +10 -0
- package/src/test/interfaces/email-receiver-config.ts +12 -0
- package/src/test/interfaces/email-sender-config.ts +26 -0
- package/src/test/interfaces/imap-config.ts +9 -0
- package/src/test/interfaces/imap-mailbox.ts +5 -0
- package/src/test/interfaces/mail-options.ts +20 -0
- package/src/test/interfaces/parsed-email.ts +11 -0
- package/src/test/interfaces/send-mail-result.ts +7 -0
- package/src/test/mocks/imap-mock.ts +147 -0
- package/src/test/mocks/mailparser-mock.ts +82 -0
- package/src/test/mocks/nodemailer-mock.ts +118 -0
- package/src/test/unit/email-receiver.unit.test.ts +471 -0
- package/src/test/unit/email-sender.unit.test.ts +550 -0
- package/tsconfig.json +23 -0
- package/email-receiver/email-receiver.js +0 -304
- package/email-sender/email-sender.js +0 -178
- package/examples/.gitkeep +0 -0
- package/processcube-html-to-text/processcube-html-to-text.js +0 -22
- package/test/helpers/email-receiver.mocks.js +0 -447
- package/test/helpers/email-sender.mocks.js +0 -368
- package/test/integration/email-receiver.integration.test.js +0 -515
- package/test/integration/email-sender.integration.test.js +0 -239
- package/test/unit/email-receiver.unit.test.js +0 -304
- package/test/unit/email-sender.unit.test.js +0 -570
- /package/{email-receiver → src/email-receiver}/email-receiver.html +0 -0
- /package/{email-sender → src/email-sender}/email-sender.html +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { NodeInitializer, Node, NodeMessage } from 'node-red';
|
|
2
|
+
import nodemailer from 'nodemailer';
|
|
3
|
+
import { EmailSenderNodeProperties } from '../interfaces/EmailSenderNodeProperties';
|
|
4
|
+
|
|
5
|
+
interface EmailSenderNodeMessage extends NodeMessage {}
|
|
6
|
+
|
|
7
|
+
// Dependency injection interface
|
|
8
|
+
interface Dependencies {
|
|
9
|
+
nodemailer: typeof nodemailer;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Default dependencies - production values
|
|
13
|
+
const defaultDependencies: Dependencies = {
|
|
14
|
+
nodemailer: nodemailer,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const EmailSenderNode: NodeInitializer = (RED, dependencies: Dependencies = defaultDependencies) => {
|
|
18
|
+
function EmailSender(this: Node, config: EmailSenderNodeProperties) {
|
|
19
|
+
RED.nodes.createNode(this, config);
|
|
20
|
+
const node = this;
|
|
21
|
+
|
|
22
|
+
const validateRequiredProperties = (cfg: EmailSenderNodeProperties): string | null => {
|
|
23
|
+
const requiredFields = [
|
|
24
|
+
{ name: 'sender', value: cfg.sender },
|
|
25
|
+
{ name: 'address', value: cfg.address },
|
|
26
|
+
{ name: 'to', value: cfg.to },
|
|
27
|
+
{ name: 'subject', value: cfg.subject },
|
|
28
|
+
{ name: 'htmlContent', value: cfg.htmlContent },
|
|
29
|
+
{ name: 'host', value: cfg.host },
|
|
30
|
+
{ name: 'port', value: cfg.port },
|
|
31
|
+
{ name: 'user', value: cfg.user },
|
|
32
|
+
{ name: 'password', value: cfg.password },
|
|
33
|
+
{ name: 'secure', value: cfg.secure },
|
|
34
|
+
{ name: 'rejectUnauthorized', value: cfg.rejectUnauthorized },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const field of requiredFields) {
|
|
38
|
+
if (field.value === undefined || field.value === null || field.value === '') {
|
|
39
|
+
return `Required property '${field.name}' is missing`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const validationError = validateRequiredProperties(config);
|
|
47
|
+
if (validationError) {
|
|
48
|
+
node.status({ fill: 'red', shape: 'dot', text: 'configuration error' });
|
|
49
|
+
setImmediate(() => {
|
|
50
|
+
node.error(validationError);
|
|
51
|
+
});
|
|
52
|
+
return; // Stop initialization if config is invalid
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const safeEvaluatePropertyAttachment = (cfg: EmailSenderNodeProperties, n: Node, m: NodeMessage) => {
|
|
56
|
+
if (cfg.attachments && cfg.attachments.trim() !== '') {
|
|
57
|
+
try {
|
|
58
|
+
return RED.util.evaluateNodeProperty(cfg.attachments, cfg.attachmentsType, n, m);
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
n.error('Failed to evaluate attachments property: ' + e.message, m);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
(node as any).on('input', async (msg: EmailSenderNodeMessage, send: Function, done: Function) => {
|
|
68
|
+
send =
|
|
69
|
+
send ||
|
|
70
|
+
function () {
|
|
71
|
+
node.send.apply(node, arguments as any);
|
|
72
|
+
};
|
|
73
|
+
done =
|
|
74
|
+
done ||
|
|
75
|
+
function (err?: Error) {
|
|
76
|
+
if (err) node.error(err, msg);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Retrieve and evaluate all configuration values
|
|
81
|
+
const sender = String(RED.util.evaluateNodeProperty(config.sender, config.senderType, node, msg));
|
|
82
|
+
const address = String(RED.util.evaluateNodeProperty(config.address, config.addressType, node, msg));
|
|
83
|
+
const to = String(RED.util.evaluateNodeProperty(config.to, config.toType, node, msg) || '');
|
|
84
|
+
const cc = String(RED.util.evaluateNodeProperty(config.cc, config.ccType, node, msg) || '');
|
|
85
|
+
const bcc = String(RED.util.evaluateNodeProperty(config.bcc, config.bccType, node, msg) || '');
|
|
86
|
+
const replyTo = String(
|
|
87
|
+
RED.util.evaluateNodeProperty(config.replyTo, config.replyToType, node, msg) || '',
|
|
88
|
+
);
|
|
89
|
+
const subject = String(
|
|
90
|
+
RED.util.evaluateNodeProperty(config.subject, config.subjectType, node, msg) ||
|
|
91
|
+
msg.topic ||
|
|
92
|
+
'Message from Node-RED',
|
|
93
|
+
);
|
|
94
|
+
const htmlContent = String(
|
|
95
|
+
RED.util.evaluateNodeProperty(config.htmlContent, config.htmlContentType, node, msg),
|
|
96
|
+
);
|
|
97
|
+
const attachments = safeEvaluatePropertyAttachment(config, node, msg);
|
|
98
|
+
|
|
99
|
+
// SMTP Configuration
|
|
100
|
+
const host = String(RED.util.evaluateNodeProperty(config.host, config.hostType, node, msg));
|
|
101
|
+
const port = Number(RED.util.evaluateNodeProperty(config.port, config.portType, node, msg));
|
|
102
|
+
const user = String(RED.util.evaluateNodeProperty(config.user, config.userType, node, msg));
|
|
103
|
+
const password = String(RED.util.evaluateNodeProperty(config.password, config.passwordType, node, msg));
|
|
104
|
+
const secure = Boolean(RED.util.evaluateNodeProperty(config.secure, config.secureType, node, msg));
|
|
105
|
+
const rejectUnauthorized = Boolean(
|
|
106
|
+
RED.util.evaluateNodeProperty(config.rejectUnauthorized, config.rejectUnauthorizedType, node, msg),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Process attachments
|
|
110
|
+
let processedAttachments: any[] = [];
|
|
111
|
+
let parsedAttachments = attachments;
|
|
112
|
+
if (typeof parsedAttachments === 'string' && parsedAttachments.trim().startsWith('[')) {
|
|
113
|
+
try {
|
|
114
|
+
parsedAttachments = JSON.parse(parsedAttachments);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new Error('Failed to parse attachments JSON: ' + (e as Error).message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (parsedAttachments) {
|
|
121
|
+
const attachmentArray = Array.isArray(parsedAttachments) ? parsedAttachments : [parsedAttachments];
|
|
122
|
+
for (const attachment of attachmentArray) {
|
|
123
|
+
if (typeof attachment === 'object' && attachment !== null) {
|
|
124
|
+
if (attachment.filename && attachment.content !== undefined) {
|
|
125
|
+
processedAttachments.push({
|
|
126
|
+
filename: attachment.filename,
|
|
127
|
+
content: attachment.content,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Attachment object is missing 'filename' or 'content' property. Got: ${JSON.stringify(attachment)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error(`Invalid attachment format. Expected object, got: ${typeof attachment}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Create and send email
|
|
141
|
+
const transporter = dependencies.nodemailer.createTransport({
|
|
142
|
+
host,
|
|
143
|
+
port,
|
|
144
|
+
secure,
|
|
145
|
+
auth: { user, pass: password },
|
|
146
|
+
tls: { rejectUnauthorized },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const mailOptions = {
|
|
150
|
+
from: { name: sender, address: address },
|
|
151
|
+
to,
|
|
152
|
+
cc,
|
|
153
|
+
bcc,
|
|
154
|
+
replyTo,
|
|
155
|
+
subject,
|
|
156
|
+
html: Buffer.from(htmlContent, 'utf-8'),
|
|
157
|
+
attachments: processedAttachments,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
transporter.sendMail(mailOptions, (error, info) => {
|
|
161
|
+
if (error) {
|
|
162
|
+
node.status({ fill: 'red', shape: 'dot', text: 'error sending' });
|
|
163
|
+
if (
|
|
164
|
+
error.message &&
|
|
165
|
+
error.message.includes('SSL routines') &&
|
|
166
|
+
error.message.includes('wrong version number')
|
|
167
|
+
) {
|
|
168
|
+
done(
|
|
169
|
+
new Error(
|
|
170
|
+
'SSL/TLS connection failed: Wrong version number. This usually means the wrong port or security settings are used. For SMTP: use port 587 with secure=false (STARTTLS) or port 465 with secure=true (SSL/TLS).',
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
done(error);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
node.log('Email sent: ' + info.response);
|
|
178
|
+
(msg as any).payload = info;
|
|
179
|
+
|
|
180
|
+
if (info.accepted && info.accepted.length > 0) {
|
|
181
|
+
node.status({ fill: 'green', shape: 'dot', text: 'sent' });
|
|
182
|
+
send(msg);
|
|
183
|
+
done();
|
|
184
|
+
} else if (info.rejected && info.rejected.length > 0) {
|
|
185
|
+
done(new Error('Email rejected: ' + info.rejected.join(', ')));
|
|
186
|
+
node.status({ fill: 'red', shape: 'dot', text: 'rejected' });
|
|
187
|
+
} else if (info.pending && info.pending.length > 0) {
|
|
188
|
+
done(new Error('Email pending: ' + info.pending.join(', ')));
|
|
189
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'pending' });
|
|
190
|
+
} else {
|
|
191
|
+
done(new Error('Unknown error while sending email.'));
|
|
192
|
+
node.status({ fill: 'red', shape: 'dot', text: 'unknown error' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
done(error instanceof Error ? error : new Error(String(error)));
|
|
198
|
+
node.status({ fill: 'red', shape: 'dot', text: 'error' });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
RED.nodes.registerType('email-sender', EmailSender, {
|
|
204
|
+
credentials: {
|
|
205
|
+
password: { type: 'password' },
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export = EmailSenderNode;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script type="text/javascript">
|
|
2
|
-
RED.nodes.registerType('
|
|
2
|
+
RED.nodes.registerType('html-to-text', {
|
|
3
3
|
category: 'ProcessCube Tools',
|
|
4
4
|
color: '#02AFD6',
|
|
5
5
|
defaults: {
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
outputs: 1,
|
|
10
10
|
icon: 'font-awesome/fa-sign-in',
|
|
11
11
|
label: function () {
|
|
12
|
-
return this.name || '
|
|
12
|
+
return this.name || 'html-to-text';
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
|
-
<script type="text/html" data-template-name="
|
|
17
|
+
<script type="text/html" data-template-name="html-to-text">
|
|
18
18
|
<div class="form-row">
|
|
19
19
|
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
20
20
|
<input type="text" id="node-input-name" placeholder="Name" />
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NodeInitializer, Node, NodeDef, NodeMessage } from 'node-red';
|
|
2
|
+
const { compile } = require('html-to-text');
|
|
3
|
+
|
|
4
|
+
interface HtmlToTextNodeProperties extends NodeDef {
|
|
5
|
+
// Add your custom properties here if needed
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface HtmlToTextNodeMessage extends NodeMessage {
|
|
9
|
+
payload: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HtmlToTextNode: NodeInitializer = function (RED) {
|
|
13
|
+
function HtmlToText(this: Node, config: HtmlToTextNodeProperties) {
|
|
14
|
+
RED.nodes.createNode(this, config);
|
|
15
|
+
const node = this;
|
|
16
|
+
|
|
17
|
+
const options = {
|
|
18
|
+
wordwrap: 130,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const compiledConvert = compile(options);
|
|
22
|
+
|
|
23
|
+
(node as any).on('input', (msg: HtmlToTextNodeMessage, send?: Function, done?: Function) => {
|
|
24
|
+
// Provide default functions if not available (for older Node-RED versions)
|
|
25
|
+
send =
|
|
26
|
+
send ||
|
|
27
|
+
function (m: NodeMessage | NodeMessage[]) {
|
|
28
|
+
node.send(m);
|
|
29
|
+
};
|
|
30
|
+
done =
|
|
31
|
+
done ||
|
|
32
|
+
function (err?: Error) {
|
|
33
|
+
if (err) node.error(err, msg);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (typeof msg.payload !== 'string') {
|
|
38
|
+
throw new Error('Payload is not a string!');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
msg.payload = compiledConvert(msg.payload);
|
|
42
|
+
send(msg);
|
|
43
|
+
done();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
done(error instanceof Error ? error : new Error(String(error)));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
RED.nodes.registerType('html-to-text', HtmlToText);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export = HtmlToTextNode;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Importiere die Registrierungsfunktion für deine Nodes.
|
|
2
|
+
import registerEmailReceiverNode from './email-receiver/email-receiver';
|
|
3
|
+
import registerEmailSenderNode from './email-sender/email-sender';
|
|
4
|
+
import registerHtmlToTextNode from './html-to-text/html-to-text';
|
|
5
|
+
|
|
6
|
+
// Exportiere eine Funktion, die alle Nodes registriert.
|
|
7
|
+
export = function (RED: any) {
|
|
8
|
+
// Rufe die Registrierungsfunktionen für jede Node auf.
|
|
9
|
+
registerEmailReceiverNode(RED);
|
|
10
|
+
registerEmailSenderNode(RED);
|
|
11
|
+
registerHtmlToTextNode(RED);
|
|
12
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ParsedMail } from 'mailparser';
|
|
2
|
+
|
|
3
|
+
// Custom type for the output message
|
|
4
|
+
export interface EmailReceiverMessage {
|
|
5
|
+
topic: string | undefined;
|
|
6
|
+
payload: string | undefined;
|
|
7
|
+
html: string | boolean | undefined;
|
|
8
|
+
from: string | undefined;
|
|
9
|
+
date: Date | undefined;
|
|
10
|
+
folder: string;
|
|
11
|
+
header: ParsedMail['headers'];
|
|
12
|
+
attachments: Array<{
|
|
13
|
+
contentType: string;
|
|
14
|
+
fileName: string | undefined;
|
|
15
|
+
contentDisposition: string;
|
|
16
|
+
generatedFileName: string | undefined;
|
|
17
|
+
contentId: string | undefined;
|
|
18
|
+
checksum: string;
|
|
19
|
+
length: number;
|
|
20
|
+
content: Buffer;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NodeDef } from 'node-red';
|
|
2
|
+
|
|
3
|
+
export interface EmailSenderNodeProperties extends NodeDef {
|
|
4
|
+
// Mail configuration properties
|
|
5
|
+
sender: string;
|
|
6
|
+
senderType: string;
|
|
7
|
+
address: string;
|
|
8
|
+
addressType: string;
|
|
9
|
+
to: string;
|
|
10
|
+
toType: string;
|
|
11
|
+
cc: string;
|
|
12
|
+
ccType: string;
|
|
13
|
+
bcc: string;
|
|
14
|
+
bccType: string;
|
|
15
|
+
replyTo: string;
|
|
16
|
+
replyToType: string;
|
|
17
|
+
subject: string;
|
|
18
|
+
subjectType: string;
|
|
19
|
+
htmlContent: string;
|
|
20
|
+
htmlContentType: string;
|
|
21
|
+
attachments: string;
|
|
22
|
+
attachmentsType: string;
|
|
23
|
+
|
|
24
|
+
// SMTP configuration properties
|
|
25
|
+
host: string;
|
|
26
|
+
hostType: string;
|
|
27
|
+
port: string;
|
|
28
|
+
portType: string;
|
|
29
|
+
user: string;
|
|
30
|
+
userType: string;
|
|
31
|
+
password: string;
|
|
32
|
+
passwordType: string;
|
|
33
|
+
secure: string;
|
|
34
|
+
secureType: string;
|
|
35
|
+
rejectUnauthorized: string;
|
|
36
|
+
rejectUnauthorizedType: string;
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ImapConnectionConfig {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
tls: boolean;
|
|
5
|
+
user: string;
|
|
6
|
+
password: string;
|
|
7
|
+
folders: string[];
|
|
8
|
+
markSeen: boolean;
|
|
9
|
+
connTimeout: number;
|
|
10
|
+
authTimeout: number;
|
|
11
|
+
keepalive: boolean;
|
|
12
|
+
autotls: string;
|
|
13
|
+
tlsOptions: { rejectUnauthorized: boolean };
|
|
14
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ADVANCED TEST PATTERNS FOR NODE-RED CUSTOM NODES
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type { TestScenario } from './types';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// ERROR RESILIENCE PATTERNS
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export class ErrorResilienceTestBuilder {
|
|
12
|
+
private scenarios: TestScenario[] = [];
|
|
13
|
+
|
|
14
|
+
addNetworkErrorScenario(name: string, config: any): this {
|
|
15
|
+
return this.addScenario({
|
|
16
|
+
name: `${name} - network error`,
|
|
17
|
+
config: { ...config, host: 'unreachable.invalid.test' },
|
|
18
|
+
input: { payload: 'test' },
|
|
19
|
+
expectedError: /network|connection|timeout/i,
|
|
20
|
+
timeout: 3000,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
addMalformedInputScenario(name: string, config: any): this {
|
|
25
|
+
const malformedInputs = [
|
|
26
|
+
null,
|
|
27
|
+
undefined,
|
|
28
|
+
{ payload: null },
|
|
29
|
+
{ payload: '' },
|
|
30
|
+
{ payload: { malformed: true, circular: null } },
|
|
31
|
+
'not-an-object',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
malformedInputs.forEach((input, index) => {
|
|
35
|
+
this.addScenario({
|
|
36
|
+
name: `${name} - malformed input ${index + 1}`,
|
|
37
|
+
config,
|
|
38
|
+
input,
|
|
39
|
+
timeout: 2000,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
addResourceExhaustionScenario(name: string, config: any): this {
|
|
47
|
+
return this.addScenario({
|
|
48
|
+
name: `${name} - resource exhaustion`,
|
|
49
|
+
config,
|
|
50
|
+
input: {
|
|
51
|
+
payload: 'x'.repeat(10 * 1024 * 1024), // 10MB payload
|
|
52
|
+
largeArray: Array.from({ length: 100000 }, (_, i) => ({ id: i, data: 'test' })),
|
|
53
|
+
},
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
addRapidFireScenario(name: string, config: any, messageCount: number = 1000): this {
|
|
59
|
+
return this.addScenario({
|
|
60
|
+
name: `${name} - rapid fire messages`,
|
|
61
|
+
config,
|
|
62
|
+
input: Array.from({ length: messageCount }, (_, i) => ({
|
|
63
|
+
payload: `rapid-message-${i}`,
|
|
64
|
+
sequence: i,
|
|
65
|
+
})),
|
|
66
|
+
timeout: Math.max(5000, messageCount * 5),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private addScenario(scenario: TestScenario): this {
|
|
71
|
+
this.scenarios.push(scenario);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getScenarios(): TestScenario[] {
|
|
76
|
+
return [...this.scenarios];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// EDGE CASE PATTERNS
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export class EdgeCaseTestBuilder {
|
|
84
|
+
private scenarios: TestScenario[] = [];
|
|
85
|
+
|
|
86
|
+
addEmptyDataScenarios(name: string, config: any): this {
|
|
87
|
+
const emptyDataCases = [
|
|
88
|
+
{ name: 'empty object', data: {} },
|
|
89
|
+
{ name: 'empty array', data: [] },
|
|
90
|
+
{ name: 'empty string', data: '' },
|
|
91
|
+
{ name: 'null payload', data: null },
|
|
92
|
+
{ name: 'undefined payload', data: undefined },
|
|
93
|
+
{ name: 'zero value', data: 0 },
|
|
94
|
+
{ name: 'false value', data: false },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
emptyDataCases.forEach((testCase) => {
|
|
98
|
+
this.scenarios.push({
|
|
99
|
+
name: `${name} - ${testCase.name}`,
|
|
100
|
+
config,
|
|
101
|
+
input: { payload: testCase.data },
|
|
102
|
+
timeout: 2000,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
addSpecialCharacterScenarios(name: string, config: any): this {
|
|
110
|
+
const specialCases = [
|
|
111
|
+
{ name: 'unicode characters', data: '🚀💡🌟' },
|
|
112
|
+
{ name: 'newlines and tabs', data: 'line1\nline2\tindented' },
|
|
113
|
+
{ name: 'special symbols', data: '!@#$%^&*()_+-=[]{}|;:,.<>?' },
|
|
114
|
+
{ name: 'very long string', data: 'a'.repeat(10000) },
|
|
115
|
+
{ name: 'mixed encoding', data: 'Ñiño café résumé 北京' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
specialCases.forEach((testCase) => {
|
|
119
|
+
this.scenarios.push({
|
|
120
|
+
name: `${name} - ${testCase.name}`,
|
|
121
|
+
config,
|
|
122
|
+
input: { payload: testCase.data },
|
|
123
|
+
timeout: 3000,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addLargeDataScenarios(name: string, config: any): this {
|
|
131
|
+
const largeCases = [
|
|
132
|
+
{
|
|
133
|
+
name: 'large object',
|
|
134
|
+
data: Object.fromEntries(Array.from({ length: 1000 }, (_, i) => [`key${i}`, `value${i}`])),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'deeply nested object',
|
|
138
|
+
data: Array.from({ length: 100 }, () => ({})).reduce((acc, _, i) => ({ [`level${i}`]: acc }), {
|
|
139
|
+
deepest: true,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'large array',
|
|
144
|
+
data: Array.from({ length: 10000 }, (_, i) => ({ id: i, data: `item${i}` })),
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
largeCases.forEach((testCase) => {
|
|
149
|
+
this.scenarios.push({
|
|
150
|
+
name: `${name} - ${testCase.name}`,
|
|
151
|
+
config,
|
|
152
|
+
input: { payload: testCase.data },
|
|
153
|
+
timeout: 5000,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getScenarios(): TestScenario[] {
|
|
161
|
+
return [...this.scenarios];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// SECURITY TESTING PATTERNS
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
export class SecurityTestBuilder {
|
|
170
|
+
private scenarios: TestScenario[] = [];
|
|
171
|
+
|
|
172
|
+
addInjectionTestScenarios(name: string, config: any): this {
|
|
173
|
+
const injectionPayloads = [
|
|
174
|
+
{ name: 'SQL injection', payload: "'; DROP TABLE users; --" },
|
|
175
|
+
{ name: 'XSS attempt', payload: '<script>alert("xss")</script>' },
|
|
176
|
+
{ name: 'Command injection', payload: '; rm -rf / ;' },
|
|
177
|
+
{ name: 'Path traversal', payload: '../../../etc/passwd' },
|
|
178
|
+
{ name: 'JSON injection', payload: '{"__proto__":{"isAdmin":true}}' },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
injectionPayloads.forEach((attack) => {
|
|
182
|
+
this.scenarios.push({
|
|
183
|
+
name: `${name} - ${attack.name}`,
|
|
184
|
+
config,
|
|
185
|
+
input: { payload: attack.payload },
|
|
186
|
+
timeout: 2000,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
addOversizedPayloadScenarios(name: string, config: any): this {
|
|
194
|
+
const oversizedCases = [
|
|
195
|
+
{ name: '1MB payload', size: 1024 * 1024 },
|
|
196
|
+
{ name: '10MB payload', size: 10 * 1024 * 1024 },
|
|
197
|
+
{ name: 'deeply nested payload', depth: 1000 },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
oversizedCases.forEach((testCase) => {
|
|
201
|
+
let payload;
|
|
202
|
+
if (testCase.size) {
|
|
203
|
+
payload = 'x'.repeat(testCase.size);
|
|
204
|
+
} else if (testCase.depth) {
|
|
205
|
+
payload = Array.from({ length: testCase.depth }, () => ({})).reduce((acc) => ({ nested: acc }), {
|
|
206
|
+
bottom: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.scenarios.push({
|
|
211
|
+
name: `${name} - ${testCase.name}`,
|
|
212
|
+
config,
|
|
213
|
+
input: { payload },
|
|
214
|
+
timeout: 10000,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getScenarios(): TestScenario[] {
|
|
222
|
+
return [...this.scenarios];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { TestScenarioBuilder } from './test-scenario-builder';
|
|
3
|
+
import { NodeTestRunner } from './node-test-runner';
|
|
4
|
+
import { NodeAssertions } from './node-assertions';
|
|
5
|
+
import type { TestScenario } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic test suite generator for Node-RED custom nodes
|
|
9
|
+
*/
|
|
10
|
+
export function createNodeTestSuite(nodeName: string, nodeConstructor: Function, testConfigs: Record<string, any>) {
|
|
11
|
+
describe(`${nodeName} - Generic Test Suite`, function () {
|
|
12
|
+
this.timeout(10000);
|
|
13
|
+
|
|
14
|
+
describe('Node Registration', function () {
|
|
15
|
+
it('should register without errors', async function () {
|
|
16
|
+
const scenario: TestScenario = {
|
|
17
|
+
name: 'registration',
|
|
18
|
+
config: testConfigs.valid || testConfigs.minimal || {},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const context = await NodeTestRunner.runScenario(nodeConstructor, scenario);
|
|
22
|
+
expect(context.nodeInstance).to.exist;
|
|
23
|
+
expect(context.mockRED.nodes.lastRegisteredType).to.exist;
|
|
24
|
+
expect(context.mockRED.nodes.lastRegisteredConstructor).to.be.a('function');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('Configuration Validation', function () {
|
|
29
|
+
const validationTests = new TestScenarioBuilder();
|
|
30
|
+
|
|
31
|
+
if (testConfigs.valid) {
|
|
32
|
+
validationTests.addValidScenario('valid config', testConfigs.valid);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (testConfigs.minimal) {
|
|
36
|
+
validationTests.addValidScenario('minimal config', testConfigs.minimal);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (testConfigs.invalid) {
|
|
40
|
+
validationTests.addErrorScenario('invalid config', testConfigs.invalid, /error|invalid|missing/i);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
validationTests.getScenarios().forEach((scenario) => {
|
|
44
|
+
it(`should handle ${scenario.name}`, async function () {
|
|
45
|
+
const context = await NodeTestRunner.runScenario(nodeConstructor, scenario);
|
|
46
|
+
|
|
47
|
+
expect(context.nodeInstance).to.exist;
|
|
48
|
+
|
|
49
|
+
if (scenario.expectedError) {
|
|
50
|
+
NodeAssertions.expectError(context, scenario.expectedError);
|
|
51
|
+
} else {
|
|
52
|
+
NodeAssertions.expectNoErrors(context);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Main exports for the Node-RED test framework
|
|
2
|
+
|
|
3
|
+
// Unit Testing Framework
|
|
4
|
+
export { TestScenarioBuilder } from './test-scenario-builder';
|
|
5
|
+
export { NodeTestRunner } from './node-test-runner';
|
|
6
|
+
export { NodeAssertions } from './node-assertions';
|
|
7
|
+
export { createNodeTestSuite } from './generic-node-test-suite';
|
|
8
|
+
|
|
9
|
+
// Integration Testing Framework
|
|
10
|
+
export { IntegrationTestRunner } from './integration-test-runner';
|
|
11
|
+
export { IntegrationAssertions } from './integration-assertions';
|
|
12
|
+
export { IntegrationScenarioBuilder } from './integration-scenario-builder';
|
|
13
|
+
|
|
14
|
+
export { ErrorResilienceTestBuilder, EdgeCaseTestBuilder, SecurityTestBuilder } from './advanced-test-patterns';
|
|
15
|
+
|
|
16
|
+
// Types
|
|
17
|
+
export * from './types';
|