imapflow
Version:
IMAP Client for Node
269 lines (236 loc) • 8.05 kB
JavaScript
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
;
const { ImapStream } = require('../lib/handler/imap-stream');
/**
* Helper that wires up the standard readable/next consumer pattern used across tests.
*
* The reader calls onCommand synchronously for each command object, then calls cmd.next()
* to allow the stream to continue. test.done() is called on the 'end' event.
*
* The readable handler uses a pendingRead flag so that if a 'readable' event fires
* while the reader is active, a follow-up read is triggered after the reader finishes.
* This prevents the 'end' event from being missed when the stream ends between reader
* iterations.
*
* @param {Object} test - nodeunit test object
* @param {Function} onCommand - synchronous function(cmd) called for each parsed command
* @param {Function} writer - async function(stream) that writes data to the stream
* @param {number} [expectedCount] - if set, assert that exactly this many commands were emitted
*/
function runStreamTest(test, onCommand, writer, expectedCount) {
const stream = new ImapStream({ cid: 'test' });
let commandCount = 0;
let reading = false;
let pendingRead = false;
const reader = async () => {
let cmd;
while ((cmd = stream.read()) !== null) {
commandCount++;
onCommand(cmd);
cmd.next();
}
};
const drainStream = () => {
if (reading) {
pendingRead = true;
return;
}
reading = true;
reader()
.catch(err => test.ifError(err))
.finally(() => {
reading = false;
if (pendingRead) {
pendingRead = false;
drainStream();
}
});
};
stream.on('readable', drainStream);
stream.on('error', err => {
test.ifError(err);
});
stream.on('end', () => {
if (expectedCount !== undefined) {
test.equal(commandCount, expectedCount, `expected ${expectedCount} command(s), got ${commandCount}`);
}
test.done();
});
writer(stream).catch(err => test.ifError(err));
}
module.exports['Literal split across chunks'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A APPEND {5}\r\n', 'payload should include literal marker line');
test.equal(cmd.literals.length, 1, 'should have one literal');
test.ok(Buffer.isBuffer(cmd.literals[0]), 'literal should be a Buffer');
test.equal(cmd.literals[0].toString(), '12345', 'literal content should be 12345');
},
async stream => {
stream.write(Buffer.from('A APPEND {5}\r\n'));
stream.end(Buffer.from('12345\r\n'));
},
1
);
};
module.exports['Literal with zero size'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A APPEND {0}\r\n', 'payload should include zero-size literal marker');
test.equal(cmd.literals.length, 1, 'should have one literal');
test.ok(Buffer.isBuffer(cmd.literals[0]), 'literal should be a Buffer');
test.equal(cmd.literals[0].length, 0, 'literal should be empty (length 0)');
},
async stream => {
stream.end(Buffer.from('A APPEND {0}\r\n\r\n'));
},
1
);
};
module.exports['Multiple commands in single chunk'] = test => {
const expected = ['A CMD1', 'B CMD2'];
let index = 0;
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), expected[index], `command ${index} payload`);
index++;
},
async stream => {
stream.end(Buffer.from('A CMD1\r\nB CMD2\r\n'));
},
2
);
};
module.exports['LiteralTooLarge error'] = test => {
const stream = new ImapStream({ cid: 'test' });
stream.on('error', err => {
test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
stream.destroy();
test.done();
});
stream.write(Buffer.from('A APPEND {1073741825}\r\n'));
};
module.exports['Incomplete line continued in next chunk'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CAPABILITY', 'payload should be A CAPABILITY');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.write(Buffer.from('A CA'));
stream.end(Buffer.from('PABILITY\r\n'));
},
1
);
};
module.exports['Empty chunk then valid command'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.write(Buffer.alloc(0));
stream.end(Buffer.from('A CMD\r\n'));
},
1
);
};
module.exports['String input converted to Buffer'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD');
test.ok(Buffer.isBuffer(cmd.payload), 'payload should be a Buffer');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.end('A CMD\r\n');
},
1
);
};
module.exports['LF-only line terminator'] = test => {
runStreamTest(
test,
cmd => {
test.equal(cmd.payload.toString(), 'A CMD', 'payload should be A CMD without CR or LF');
test.equal(cmd.literals.length, 0, 'should have no literals');
},
async stream => {
stream.end(Buffer.from('A CMD\n'));
},
1
);
};
module.exports['Many chunks trigger event loop yield'] = test => {
runStreamTest(
test,
cmd => {
// Just verify each command is valid
test.ok(cmd.payload.toString().startsWith('A'), 'command should start with tag');
},
async stream => {
for (let i = 0; i < 15; i++) {
stream.write(Buffer.from(`A CMD${i}\r\n`));
}
stream.end();
},
15
);
};
module.exports['Destroy with queued items does not hang'] = test => {
const stream = new ImapStream({ cid: 'test' });
let errorEmitted = false;
stream.on('error', () => {
errorEmitted = true;
});
// Write multiple chunks rapidly then destroy
stream.write(Buffer.from('A CMD1\r\n'));
stream.write(Buffer.from('B CMD2\r\n'));
stream.destroy();
// Errors from destroy are emitted synchronously or on next tick
setImmediate(() => {
test.ok(!errorEmitted, 'should not emit error on destroy');
test.done();
});
};
module.exports['logRaw option triggers trace logging'] = test => {
let traceCalled = false;
let traceData = null;
const stream = new ImapStream({
cid: 'test',
logRaw: true
});
// Override the log object to capture trace calls
stream.log = {
trace: data => {
traceCalled = true;
traceData = data;
},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {}
};
// Also need to handle readable events
stream.on('readable', () => {
let cmd;
while ((cmd = stream.read()) !== null) {
cmd.next();
}
});
stream.on('end', () => {
test.ok(traceCalled, 'trace should have been called');
test.ok(traceData, 'trace data should exist');
test.equal(traceData.src, 's', 'source should be s');
test.ok(traceData.data, 'should have base64 data');
test.done();
});
stream.end(Buffer.from('A CMD\r\n'));
};