@5minds/node-red-contrib-processcube-tools 1.2.0-feature-37541f-mg92jkdw → 1.2.0-feature-608421-mg9cjskq
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/.env.template +7 -1
- package/email-receiver/email-receiver.js +304 -0
- package/email-sender/email-sender.js +178 -0
- package/examples/.gitkeep +0 -0
- package/file-storage/file-storage.html +203 -0
- package/file-storage/file-storage.js +148 -0
- package/package.json +17 -26
- package/{src/html-to-text/html-to-text.html → processcube-html-to-text/processcube-html-to-text.html} +3 -3
- package/processcube-html-to-text/processcube-html-to-text.js +22 -0
- package/storage/providers/fs.js +117 -0
- package/storage/providers/postgres.js +160 -0
- package/storage/storage-core.js +77 -0
- package/test/helpers/email-receiver.mocks.js +447 -0
- package/test/helpers/email-sender.mocks.js +368 -0
- package/test/integration/email-receiver.integration.test.js +515 -0
- package/test/integration/email-sender.integration.test.js +239 -0
- package/test/unit/email-receiver.unit.test.js +304 -0
- package/test/unit/email-sender.unit.test.js +570 -0
- package/.mocharc.json +0 -5
- package/src/custom-node-template/custom-node-template.html.template +0 -45
- package/src/custom-node-template/custom-node-template.ts.template +0 -69
- package/src/email-receiver/email-receiver.ts +0 -439
- package/src/email-sender/email-sender.ts +0 -210
- package/src/html-to-text/html-to-text.ts +0 -53
- package/src/index.ts +0 -12
- package/src/interfaces/EmailReceiverMessage.ts +0 -22
- package/src/interfaces/EmailSenderNodeProperties.ts +0 -37
- package/src/interfaces/FetchState.ts +0 -9
- package/src/interfaces/ImapConnectionConfig.ts +0 -14
- package/src/test/framework/advanced-test-patterns.ts +0 -224
- package/src/test/framework/generic-node-test-suite.ts +0 -58
- package/src/test/framework/index.ts +0 -17
- package/src/test/framework/integration-assertions.ts +0 -67
- package/src/test/framework/integration-scenario-builder.ts +0 -77
- package/src/test/framework/integration-test-runner.ts +0 -101
- package/src/test/framework/node-assertions.ts +0 -63
- package/src/test/framework/node-test-runner.ts +0 -260
- package/src/test/framework/test-scenario-builder.ts +0 -74
- package/src/test/framework/types.ts +0 -61
- package/src/test/helpers/email-receiver-test-configs.ts +0 -67
- package/src/test/helpers/email-receiver-test-flows.ts +0 -16
- package/src/test/helpers/email-sender-test-configs.ts +0 -123
- package/src/test/helpers/email-sender-test-flows.ts +0 -16
- package/src/test/integration/email-receiver.integration.test.ts +0 -41
- package/src/test/integration/email-sender.integration.test.ts +0 -129
- package/src/test/interfaces/email-data.ts +0 -10
- package/src/test/interfaces/email-receiver-config.ts +0 -12
- package/src/test/interfaces/email-sender-config.ts +0 -26
- package/src/test/interfaces/imap-config.ts +0 -9
- package/src/test/interfaces/imap-mailbox.ts +0 -5
- package/src/test/interfaces/mail-options.ts +0 -20
- package/src/test/interfaces/parsed-email.ts +0 -11
- package/src/test/interfaces/send-mail-result.ts +0 -7
- package/src/test/mocks/imap-mock.ts +0 -147
- package/src/test/mocks/mailparser-mock.ts +0 -82
- package/src/test/mocks/nodemailer-mock.ts +0 -118
- package/src/test/unit/email-receiver.unit.test.ts +0 -471
- package/src/test/unit/email-sender.unit.test.ts +0 -550
- package/tsconfig.json +0 -23
- /package/{src/email-receiver → email-receiver}/email-receiver.html +0 -0
- /package/{src/email-sender → email-sender}/email-sender.html +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { Pool } = require('pg');
|
|
2
|
+
const { LargeObjectManager } = require('pg-large-object');
|
|
3
|
+
const { pipeline, PassThrough } = require('stream');
|
|
4
|
+
const { createHash } = require('crypto');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
const pump = promisify(pipeline);
|
|
7
|
+
|
|
8
|
+
class PgProvider {
|
|
9
|
+
constructor(opts = {}) {
|
|
10
|
+
const conString = "postgres://" + opts.username + ":" + opts.password + "@" + opts.host + ":" + opts.port + "/" + opts.database;
|
|
11
|
+
this.connectionString = conString
|
|
12
|
+
this.schema = opts.schema || 'public';
|
|
13
|
+
this.table = opts.table || 'files';
|
|
14
|
+
this.pool = new Pool({ connectionString: this.connectionString });
|
|
15
|
+
this.pool = new Pool();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init() {
|
|
19
|
+
const client = await this.pool.connect();
|
|
20
|
+
try {
|
|
21
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${this.schema}.${this.table} (
|
|
22
|
+
id UUID PRIMARY KEY,
|
|
23
|
+
loid OID NOT NULL,
|
|
24
|
+
filename TEXT,
|
|
25
|
+
content_type TEXT,
|
|
26
|
+
size BIGINT,
|
|
27
|
+
sha256 TEXT,
|
|
28
|
+
metadata JSONB,
|
|
29
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
30
|
+
)`);
|
|
31
|
+
await client.query(
|
|
32
|
+
`CREATE INDEX IF NOT EXISTS idx_${this.table}_created_at ON ${this.schema}.${this.table}(created_at)`,
|
|
33
|
+
);
|
|
34
|
+
} finally {
|
|
35
|
+
client.release();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async store(readable, info) {
|
|
40
|
+
const { id, filename, contentType, metadata, createdAt } = info;
|
|
41
|
+
const client = await this.pool.connect();
|
|
42
|
+
try {
|
|
43
|
+
const lom = new LargeObjectManager({pg: client});
|
|
44
|
+
await client.query('BEGIN');
|
|
45
|
+
const bufSize = 16384;
|
|
46
|
+
|
|
47
|
+
const result = await lom.createAndWritableStreamAsync(bufSize);
|
|
48
|
+
const oid =result[0];
|
|
49
|
+
const stream = result[1];
|
|
50
|
+
if (!oid || !stream) {
|
|
51
|
+
throw new Error('Failed to create large object');
|
|
52
|
+
}
|
|
53
|
+
const hash = createHash('sha256');
|
|
54
|
+
let size = 0;
|
|
55
|
+
|
|
56
|
+
// Wir berechnen Hash und Größe "on the fly", während wir in das Large Object schreiben
|
|
57
|
+
const hashAndSize = new PassThrough();
|
|
58
|
+
hashAndSize.on('data', (chunk) => {
|
|
59
|
+
hash.update(chunk);
|
|
60
|
+
size += chunk.length;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// readable -> hashAndSize -> stream
|
|
64
|
+
const pipelinePromise = pump(readable, hashAndSize, stream);
|
|
65
|
+
|
|
66
|
+
await pipelinePromise;
|
|
67
|
+
|
|
68
|
+
const sha256 = hash.digest('hex');
|
|
69
|
+
|
|
70
|
+
await client.query(
|
|
71
|
+
`INSERT INTO ${this.schema}.${this.table} (id, loid, filename, content_type, size, sha256, metadata, created_at)
|
|
72
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,
|
|
73
|
+
[id, oid, filename, contentType, size, sha256, JSON.stringify(metadata || {}), createdAt],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await client.query('COMMIT');
|
|
77
|
+
return { size, sha256, oid };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
80
|
+
throw err;
|
|
81
|
+
} finally {
|
|
82
|
+
client.release();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async get(id, options = { as: 'stream' }) {
|
|
87
|
+
const client = await this.pool.connect();
|
|
88
|
+
try {
|
|
89
|
+
const { rows } = await client.query(`SELECT * FROM ${this.schema}.${this.table} WHERE id=$1`, [id]);
|
|
90
|
+
if (rows.length === 0) throw new Error(`File not found: ${id}`);
|
|
91
|
+
const meta = rows[0];
|
|
92
|
+
|
|
93
|
+
const bufSize = 16384;
|
|
94
|
+
if (options.as === 'buffer') {
|
|
95
|
+
// Stream LO into memory
|
|
96
|
+
await client.query('BEGIN');
|
|
97
|
+
const lom = new LargeObjectManager({ pg: client });
|
|
98
|
+
|
|
99
|
+
const ro = await lom.openAndReadableStreamAsync(meta.loid, bufSize);
|
|
100
|
+
const totalSize = ro[0];
|
|
101
|
+
const stream = ro[1];
|
|
102
|
+
if (!stream) {
|
|
103
|
+
throw new Error('Failed to open large object for reading');
|
|
104
|
+
}
|
|
105
|
+
const chunks = [];
|
|
106
|
+
stream.on('data', (c) => chunks.push(c));
|
|
107
|
+
await new Promise((res, rej) => stream.on('end', res).on('error', rej));
|
|
108
|
+
await client.query('COMMIT');
|
|
109
|
+
client.release();
|
|
110
|
+
return { meta, payload: Buffer.concat(chunks) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (options.as === 'path') {
|
|
114
|
+
throw new Error('options.as="path" is not supported by Postgres provider');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// default: stream – wrap LO stream so we can close txn when done
|
|
118
|
+
await client.query('BEGIN');
|
|
119
|
+
const lom = new LargeObjectManager({ pg: client });
|
|
120
|
+
const ro = await lom.openAndReadableStreamAsync(meta.loid, bufSize);
|
|
121
|
+
const totalSize = ro[0];
|
|
122
|
+
const stream = ro[1];
|
|
123
|
+
const pass = new PassThrough();
|
|
124
|
+
stream.pipe(pass);
|
|
125
|
+
const done = new Promise((res, rej) => pass.on('end', res).on('error', rej));
|
|
126
|
+
done.finally(async () => {
|
|
127
|
+
await client.query('COMMIT').catch(() => {});
|
|
128
|
+
client.release();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Do not release here; we release in finally of wrapper. We return early, so prevent double release.
|
|
132
|
+
return { meta, payload: pass };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async delete(id) {
|
|
140
|
+
const client = await this.pool.connect();
|
|
141
|
+
try {
|
|
142
|
+
await client.query('BEGIN');
|
|
143
|
+
const { rows } = await client.query(`DELETE FROM ${this.schema}.${this.table} WHERE id=$1 RETURNING loid`, [
|
|
144
|
+
id,
|
|
145
|
+
]);
|
|
146
|
+
if (rows.length) {
|
|
147
|
+
const lom = new LargeObjectManager({ pg: client });
|
|
148
|
+
await lom.unlinkAsync(rows[0].loid);
|
|
149
|
+
}
|
|
150
|
+
await client.query('COMMIT');
|
|
151
|
+
} catch (err) {
|
|
152
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
153
|
+
throw err;
|
|
154
|
+
} finally {
|
|
155
|
+
client.release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = PgProvider;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { v4: uuidv4 } = require('uuid');
|
|
2
|
+
const { Readable } = require('stream');
|
|
3
|
+
const FsProvider = require('./providers/fs');
|
|
4
|
+
const PgProvider = require('./providers/postgres');
|
|
5
|
+
|
|
6
|
+
function ensureReadable(payload) {
|
|
7
|
+
if (!payload && payload !== 0) {
|
|
8
|
+
throw new Error('No payload provided for storage');
|
|
9
|
+
}
|
|
10
|
+
if (Buffer.isBuffer(payload) || typeof payload === 'string' || typeof payload === 'number') {
|
|
11
|
+
return Readable.from(Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload)));
|
|
12
|
+
}
|
|
13
|
+
if (payload && typeof payload.pipe === 'function') {
|
|
14
|
+
return payload; // Readable stream
|
|
15
|
+
}
|
|
16
|
+
throw new Error('Unsupported payload type. Use Buffer, string, number, or Readable stream.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class StorageCore {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} config
|
|
22
|
+
* @param {('fs'|'pg')} config.provider
|
|
23
|
+
* @param {Object} [config.fs]
|
|
24
|
+
* @param {string} [config.fs.baseDir]
|
|
25
|
+
* @param {Object} [config.pg]
|
|
26
|
+
* @param {string} [config.pg.username]
|
|
27
|
+
* @param {string} [config.pg.password]
|
|
28
|
+
* @param {string} [config.pg.host]
|
|
29
|
+
* @param {number} [config.pg.port]
|
|
30
|
+
* @param {string} [config.pg.database]
|
|
31
|
+
* @param {string} [config.pg.schema]
|
|
32
|
+
* @param {string} [config.pg.table]
|
|
33
|
+
*/
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
const p = config.provider || 'fs';
|
|
37
|
+
if (p === 'fs') this.provider = new FsProvider(config.fs || {});
|
|
38
|
+
else if (p === 'pg') this.provider = new PgProvider(config.pg || {});
|
|
39
|
+
else throw new Error(`Unknown provider: ${p}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async init() {
|
|
43
|
+
if (typeof this.provider.init === 'function') {
|
|
44
|
+
await this.provider.init();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Store a file */
|
|
49
|
+
async store(payload, file = {}) {
|
|
50
|
+
const stream = ensureReadable(payload);
|
|
51
|
+
const id = uuidv4();
|
|
52
|
+
const info = {
|
|
53
|
+
id,
|
|
54
|
+
filename: file.filename || id,
|
|
55
|
+
contentType: file.contentType || 'application/octet-stream',
|
|
56
|
+
metadata: file.metadata || {},
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
const result = await this.provider.store(stream, info);
|
|
60
|
+
return { ...info, ...result, storage: this.config.provider || 'fs' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get a file by id */
|
|
64
|
+
async get(id, options = { as: 'stream' }) {
|
|
65
|
+
if (!id) throw new Error('id is required');
|
|
66
|
+
return this.provider.get(id, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delete a file by id */
|
|
70
|
+
async delete(id) {
|
|
71
|
+
if (!id) throw new Error('id is required');
|
|
72
|
+
await this.provider.delete(id);
|
|
73
|
+
return { id, deleted: true };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = StorageCore;
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared mock objects and utilities for Email Receiver Node tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create mock Node-RED object for unit testing
|
|
7
|
+
*/
|
|
8
|
+
function createMockNodeRED(options = {}) {
|
|
9
|
+
// Store input callback in the mock RED context
|
|
10
|
+
let storedInputCallback = null;
|
|
11
|
+
let nodeInstance = null;
|
|
12
|
+
|
|
13
|
+
const mockRED = {
|
|
14
|
+
nodes: {
|
|
15
|
+
createNode: function (node, config) {
|
|
16
|
+
nodeInstance = node; // Capture the node instance
|
|
17
|
+
|
|
18
|
+
// Apply config properties to node
|
|
19
|
+
Object.assign(node, {
|
|
20
|
+
id: config.id || 'mock-node-id',
|
|
21
|
+
type: config.type || 'email-receiver',
|
|
22
|
+
name: config.name || 'Mock Node',
|
|
23
|
+
on: function (event, callback) {
|
|
24
|
+
if (event === 'input') {
|
|
25
|
+
storedInputCallback = callback;
|
|
26
|
+
// Store the callback on the node instance for easy access
|
|
27
|
+
node.inputCallback = callback;
|
|
28
|
+
}
|
|
29
|
+
// Call the original onHandler if provided
|
|
30
|
+
if (options.onHandler) {
|
|
31
|
+
options.onHandler.call(node, event, callback);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
status: options.statusHandler || function () {},
|
|
35
|
+
error: options.errorHandler || function () {},
|
|
36
|
+
send: options.sendHandler || function () {},
|
|
37
|
+
log: options.logHandler || function () {},
|
|
38
|
+
warn: options.warnHandler || function () {},
|
|
39
|
+
debug: options.debugHandler || function () {},
|
|
40
|
+
});
|
|
41
|
+
return node;
|
|
42
|
+
},
|
|
43
|
+
registerType: function (type, constructor) {
|
|
44
|
+
// Store registration for verification in tests
|
|
45
|
+
this.lastRegisteredType = type;
|
|
46
|
+
this.lastRegisteredConstructor = constructor;
|
|
47
|
+
},
|
|
48
|
+
// Helper method to get the stored input callback
|
|
49
|
+
getInputCallback: function () {
|
|
50
|
+
return storedInputCallback;
|
|
51
|
+
},
|
|
52
|
+
// Helper method to get the node instance
|
|
53
|
+
getNodeInstance: function () {
|
|
54
|
+
return nodeInstance;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
util: {
|
|
58
|
+
evaluateNodeProperty: function (value, type, node, msg, callback) {
|
|
59
|
+
if (type === 'json') {
|
|
60
|
+
try {
|
|
61
|
+
// Simulate parsing a JSON string into an object
|
|
62
|
+
return JSON.parse(JSON.stringify(value));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (callback) {
|
|
65
|
+
callback(e, null);
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Simple mock implementation
|
|
72
|
+
if (callback) {
|
|
73
|
+
callback(null, value);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
},
|
|
77
|
+
encrypt: function (value) {
|
|
78
|
+
return 'encrypted:' + value;
|
|
79
|
+
},
|
|
80
|
+
decrypt: function (value) {
|
|
81
|
+
return value.replace('encrypted:', '');
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
log: {
|
|
85
|
+
info: options.logInfo || function () {},
|
|
86
|
+
warn: options.logWarn || function () {},
|
|
87
|
+
error: options.logError || function () {},
|
|
88
|
+
debug: options.logDebug || function () {},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return mockRED;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mock IMAP implementation for testing
|
|
97
|
+
*/
|
|
98
|
+
function createMockImap() {
|
|
99
|
+
return function MockImap(config) {
|
|
100
|
+
this.config = config;
|
|
101
|
+
this.events = {};
|
|
102
|
+
|
|
103
|
+
// Simulate connection behavior
|
|
104
|
+
this.connect = () => {
|
|
105
|
+
// Check if we should simulate a connection error
|
|
106
|
+
if (this.config.host && this.config.host.includes('invalid')) {
|
|
107
|
+
// Simulate connection error
|
|
108
|
+
if (this.events && this.events.error) {
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
const error = new Error('Connection failed');
|
|
111
|
+
error.code = 'ENOTFOUND';
|
|
112
|
+
this.events.error(error);
|
|
113
|
+
}, 10);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// Simulate successful connection by emitting 'ready' event
|
|
117
|
+
if (this.events && this.events.ready) {
|
|
118
|
+
setTimeout(() => this.events.ready(), 10);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Simulate opening a mailbox
|
|
124
|
+
this.openBox = (folder, readOnly, callback) => {
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
callback(null, {
|
|
127
|
+
messages: { total: 1 },
|
|
128
|
+
name: folder,
|
|
129
|
+
readOnly: readOnly,
|
|
130
|
+
});
|
|
131
|
+
}, 10);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Simulate searching for emails
|
|
135
|
+
this.search = (criteria, callback) => {
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
// Return mock message IDs
|
|
138
|
+
callback(null, [123, 456, 789]);
|
|
139
|
+
}, 10);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Simulate fetching email messages
|
|
143
|
+
this.fetch = (results, options) => {
|
|
144
|
+
const fetchEmitter = {
|
|
145
|
+
on: (event, callback) => {
|
|
146
|
+
if (event === 'message') {
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
const mockMessage = {
|
|
149
|
+
on: (messageEvent, messageCallback) => {
|
|
150
|
+
if (messageEvent === 'body') {
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
// Return a mock email body that will be parsed
|
|
153
|
+
const mockEmailContent = `From: sender@test.com\r\nTo: recipient@test.com\r\nSubject: Mock Email Subject\r\n\r\nThis is a mock email body for testing purposes.`;
|
|
154
|
+
messageCallback(Buffer.from(mockEmailContent));
|
|
155
|
+
}, 5);
|
|
156
|
+
} else if (messageEvent === 'attributes') {
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
messageCallback({
|
|
159
|
+
uid: 123,
|
|
160
|
+
flags: ['\\Seen'],
|
|
161
|
+
date: new Date(),
|
|
162
|
+
size: 1024,
|
|
163
|
+
});
|
|
164
|
+
}, 5);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
once: (messageEvent, messageCallback) => {
|
|
168
|
+
if (messageEvent === 'end') {
|
|
169
|
+
setTimeout(() => messageCallback(), 15);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
callback(mockMessage);
|
|
174
|
+
}, 10);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
once: (event, callback) => {
|
|
178
|
+
if (event === 'end') {
|
|
179
|
+
setTimeout(() => callback(), 20);
|
|
180
|
+
} else if (event === 'error') {
|
|
181
|
+
// Store error callback for potential use
|
|
182
|
+
this.errorCallback = callback;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Store the fetch emitter so we can trigger events manually in tests
|
|
188
|
+
this.lastFetchEmitter = fetchEmitter;
|
|
189
|
+
return fetchEmitter;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Simulate closing connection
|
|
193
|
+
this.end = () => {
|
|
194
|
+
if (this.events && this.events.end) {
|
|
195
|
+
setTimeout(() => this.events.end(), 5);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Event listener setup
|
|
200
|
+
this.once = (event, callback) => {
|
|
201
|
+
if (!this.events) this.events = {};
|
|
202
|
+
this.events[event] = callback;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
this.on = (event, callback) => {
|
|
206
|
+
if (!this.events) this.events = {};
|
|
207
|
+
this.events[event] = callback;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Additional IMAP methods that might be used
|
|
211
|
+
this.addFlags = (source, flags, callback) => {
|
|
212
|
+
setTimeout(() => callback(null), 5);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
this.removeFlags = (source, flags, callback) => {
|
|
216
|
+
setTimeout(() => callback(null), 5);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Helper method to trigger the email processing flow
|
|
220
|
+
this.simulateNewEmail = (emailData = {}) => {
|
|
221
|
+
if (this.events && this.events.mail) {
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
this.events.mail(1); // Simulate 1 new email
|
|
224
|
+
}, 10);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
return this;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mock Mailparser implementation for testing
|
|
234
|
+
*/
|
|
235
|
+
function createMockMailparser() {
|
|
236
|
+
return {
|
|
237
|
+
simpleParser: function (source, options = {}) {
|
|
238
|
+
return Promise.resolve({
|
|
239
|
+
subject: options.subject || 'Mock Email Subject',
|
|
240
|
+
text: options.text || 'This is a mock email body for testing purposes.',
|
|
241
|
+
html: options.html || '<p>This is a mock email body for testing purposes.</p>',
|
|
242
|
+
from: {
|
|
243
|
+
text: options.from || 'sender@test.com',
|
|
244
|
+
value: [{ address: options.from || 'sender@test.com', name: 'Test Sender' }],
|
|
245
|
+
},
|
|
246
|
+
to: {
|
|
247
|
+
text: options.to || 'recipient@test.com',
|
|
248
|
+
value: [{ address: options.to || 'recipient@test.com', name: 'Test Recipient' }],
|
|
249
|
+
},
|
|
250
|
+
date: options.date || new Date(),
|
|
251
|
+
messageId: options.messageId || '<mock-message-id@test.com>',
|
|
252
|
+
headers: new Map([
|
|
253
|
+
['message-id', '<mock-message-id@test.com>'],
|
|
254
|
+
['subject', options.subject || 'Mock Email Subject'],
|
|
255
|
+
['from', options.from || 'sender@test.com'],
|
|
256
|
+
['to', options.to || 'recipient@test.com'],
|
|
257
|
+
]),
|
|
258
|
+
attachments: options.attachments || [],
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Enhanced module mocks setup with better email simulation
|
|
266
|
+
*/
|
|
267
|
+
function setupModuleMocks() {
|
|
268
|
+
const mockModules = {
|
|
269
|
+
'node-imap': createMockImap(),
|
|
270
|
+
mailparser: createMockMailparser(),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const Module = require('module');
|
|
274
|
+
const originalLoad = Module._load;
|
|
275
|
+
|
|
276
|
+
Module._load = function (request, parent) {
|
|
277
|
+
if (mockModules[request]) {
|
|
278
|
+
return mockModules[request];
|
|
279
|
+
}
|
|
280
|
+
return originalLoad.apply(this, arguments);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Return cleanup function
|
|
284
|
+
return function cleanup() {
|
|
285
|
+
Module._load = originalLoad;
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Create test configurations for different scenarios
|
|
291
|
+
*/
|
|
292
|
+
const testConfigs = {
|
|
293
|
+
valid: {
|
|
294
|
+
id: 'test-node-1',
|
|
295
|
+
type: 'email-receiver',
|
|
296
|
+
name: 'Test Email Receiver',
|
|
297
|
+
host: 'imap.test.com',
|
|
298
|
+
hostType: 'str',
|
|
299
|
+
port: 993,
|
|
300
|
+
portType: 'num',
|
|
301
|
+
tls: true,
|
|
302
|
+
tlsType: 'bool',
|
|
303
|
+
user: 'test@test.com',
|
|
304
|
+
userType: 'str',
|
|
305
|
+
password: 'testpass',
|
|
306
|
+
passwordType: 'str',
|
|
307
|
+
folder: ['INBOX'],
|
|
308
|
+
folderType: 'str',
|
|
309
|
+
markseen: true,
|
|
310
|
+
markseenType: 'bool',
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
arrayFolders: {
|
|
314
|
+
id: 'test-node-3',
|
|
315
|
+
type: 'email-receiver',
|
|
316
|
+
name: 'Array Folders Test',
|
|
317
|
+
host: 'imap.test.com',
|
|
318
|
+
hostType: 'str',
|
|
319
|
+
port: 993,
|
|
320
|
+
portType: 'num',
|
|
321
|
+
user: 'test@test.com',
|
|
322
|
+
userType: 'str',
|
|
323
|
+
password: 'testpass',
|
|
324
|
+
passwordType: 'str',
|
|
325
|
+
folder: ['INBOX', 'Junk', 'Drafts'],
|
|
326
|
+
folderType: 'json',
|
|
327
|
+
markseen: false,
|
|
328
|
+
markseenType: 'bool',
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
invalidFolderType: {
|
|
332
|
+
id: 'test-node-4',
|
|
333
|
+
type: 'email-receiver',
|
|
334
|
+
name: 'Invalid Config Test',
|
|
335
|
+
host: '', // Missing host
|
|
336
|
+
hostType: 'str',
|
|
337
|
+
port: 993,
|
|
338
|
+
portType: 'num',
|
|
339
|
+
user: 'test@test.com',
|
|
340
|
+
userType: 'str',
|
|
341
|
+
password: '', // Missing password
|
|
342
|
+
passwordType: 'str',
|
|
343
|
+
folder: 123,
|
|
344
|
+
folderType: 'num',
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
invalidConfig: {
|
|
348
|
+
id: 'test-node-4',
|
|
349
|
+
type: 'email-receiver',
|
|
350
|
+
name: 'Invalid Config Test',
|
|
351
|
+
host: '', // Missing host
|
|
352
|
+
hostType: 'str',
|
|
353
|
+
port: 993,
|
|
354
|
+
portType: 'num',
|
|
355
|
+
user: 'test@test.com',
|
|
356
|
+
userType: 'str',
|
|
357
|
+
password: '', // Missing password
|
|
358
|
+
passwordType: 'str',
|
|
359
|
+
folder: ['Inbox'],
|
|
360
|
+
folderType: 'num',
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
minimal: {
|
|
364
|
+
id: 'test-node-5',
|
|
365
|
+
type: 'email-receiver',
|
|
366
|
+
host: 'imap.minimal.com',
|
|
367
|
+
hostType: 'str',
|
|
368
|
+
port: 993,
|
|
369
|
+
portType: 'num',
|
|
370
|
+
user: 'minimal@test.com',
|
|
371
|
+
userType: 'str',
|
|
372
|
+
password: 'minimalpass',
|
|
373
|
+
passwordType: 'str',
|
|
374
|
+
folder: 'INBOX',
|
|
375
|
+
folderType: 'str',
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Create test flows for Node-RED integration tests
|
|
381
|
+
*/
|
|
382
|
+
const testFlows = {
|
|
383
|
+
single: [testConfigs.valid],
|
|
384
|
+
|
|
385
|
+
withHelper: [testConfigs.valid, { id: 'h1', type: 'helper' }],
|
|
386
|
+
|
|
387
|
+
connected: [
|
|
388
|
+
{ ...testConfigs.valid, wires: [['h1']] },
|
|
389
|
+
{ id: 'h1', type: 'helper' },
|
|
390
|
+
],
|
|
391
|
+
|
|
392
|
+
multiOutput: [
|
|
393
|
+
{ ...testConfigs.valid, wires: [['h1', 'h2']] },
|
|
394
|
+
{ id: 'h1', type: 'helper' },
|
|
395
|
+
{ id: 'h2', type: 'helper' },
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Utility functions for test assertions and email simulation
|
|
401
|
+
*/
|
|
402
|
+
const testUtils = {
|
|
403
|
+
/**
|
|
404
|
+
* Wait for a specified amount of time
|
|
405
|
+
*/
|
|
406
|
+
wait: (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Create a promise that resolves when a node receives a message
|
|
410
|
+
*/
|
|
411
|
+
waitForMessage: (node, timeout = 1000) => {
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const timer = setTimeout(() => {
|
|
414
|
+
reject(new Error('Timeout waiting for message'));
|
|
415
|
+
}, timeout);
|
|
416
|
+
|
|
417
|
+
node.on('input', (msg) => {
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
resolve(msg);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Verify that a message has expected properties
|
|
426
|
+
*/
|
|
427
|
+
verifyMessage: (msg, expectedProps = {}) => {
|
|
428
|
+
const should = require('should');
|
|
429
|
+
should.exist(msg);
|
|
430
|
+
|
|
431
|
+
Object.keys(expectedProps).forEach((prop) => {
|
|
432
|
+
if (expectedProps[prop] !== undefined) {
|
|
433
|
+
msg.should.have.property(prop, expectedProps[prop]);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
module.exports = {
|
|
440
|
+
createMockNodeRED,
|
|
441
|
+
createMockImap,
|
|
442
|
+
createMockMailparser,
|
|
443
|
+
setupModuleMocks,
|
|
444
|
+
testConfigs,
|
|
445
|
+
testFlows,
|
|
446
|
+
testUtils,
|
|
447
|
+
};
|