UNPKG

imapflow

Version:

IMAP Client for Node

1,069 lines (942 loc) 43.1 kB
'use strict'; /* eslint-disable new-cap */ // BigInt() is a standard JS function but triggers new-cap rule // ============================================================================ // Additional branch-coverage tests for lib/commands/*.js // // Each test targets one or more specific uncovered branches identified via c8. // The mock connection factory mirrors the one in commands-integration-test.js. // ============================================================================ const createMockConnection = (overrides = {}) => { const states = { NOT_AUTHENTICATED: 1, AUTHENTICATED: 2, SELECTED: 3, LOGOUT: 4 }; const defaultMailbox = { path: 'INBOX', flags: new Set(['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft']), permanentFlags: new Set(['\\*']), exists: 100, recent: 5, uidNext: 1000, uidValidity: BigInt(12345), noModseq: false }; return { states, state: overrides.state || states.SELECTED, id: 'test-connection-id', capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]), enabled: new Set(overrides.enabled || []), authCapabilities: new Map(), folders: overrides.folders || new Map(), mailbox: overrides.mailbox || { ...defaultMailbox }, namespace: overrides.namespace || { delimiter: '/', prefix: '' }, expectCapabilityUpdate: overrides.expectCapabilityUpdate || false, log: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {}, trace: () => {} }, close: overrides.close || (() => {}), emit: overrides.emit || (() => {}), write: overrides.write || (() => {}), currentSelectCommand: false, messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}), messageCopy: overrides.messageCopy || (async () => {}), messageDelete: overrides.messageDelete || (async () => {}), run: overrides.run || (async () => {}), exec: overrides.exec || (async () => ({ next: () => {}, response: { attributes: [] } })), ...overrides }; }; // ============================================================================ // copy.js — line 22 branch: `options = options || {}` with falsy options // Call copy with range + destination but no options argument so the `|| {}` // right-hand side is taken. // ============================================================================ const copyCommand = require('../lib/commands/copy'); module.exports['Branches: copy with undefined options uses {} fallback'] = async test => { let execCmd = null; const connection = createMockConnection({ state: 3, exec: async cmd => { execCmd = cmd; return { next: () => {}, response: { attributes: [] } }; } }); // No options argument -> options is undefined -> `options || {}` fallback const result = await copyCommand(connection, '1:5', 'Archive'); test.ok(result); test.equal(execCmd, 'COPY'); // not UID COPY, since options.uid is undefined test.done(); }; // ============================================================================ // move.js — line 22 branch: `options = options || {}` with falsy options // MOVE capability present, no options argument. // ============================================================================ const moveCommand = require('../lib/commands/move'); module.exports['Branches: move with undefined options uses {} fallback'] = async test => { let execCmd = null; const connection = createMockConnection({ state: 3, capabilities: new Map([['MOVE', true]]), exec: async cmd => { execCmd = cmd; return { next: () => {}, response: { attributes: [] } }; } }); const result = await moveCommand(connection, '1:5', 'Archive'); test.ok(result); test.equal(execCmd, 'MOVE'); test.done(); }; // ============================================================================ // expunge.js — line 20 branch: `options = options || {}` with falsy options // Call expunge with range but no options argument. // ============================================================================ const expungeCommand = require('../lib/commands/expunge'); module.exports['Branches: expunge with undefined options uses {} fallback'] = async test => { let execCmd = null; const connection = createMockConnection({ state: 3, exec: async cmd => { execCmd = cmd; return { next: () => {}, response: { attributes: [] } }; } }); // No options argument -> options || {} fallback; byUid false -> plain EXPUNGE const result = await expungeCommand(connection, '1:5'); test.equal(result, true); test.equal(execCmd, 'EXPUNGE'); test.done(); }; // ============================================================================ // store.js — line 56 branch: `: [].concat(flags || [])` — flags passed as a // single (non-array) value, so the Array.isArray() ternary takes the false side. // ============================================================================ const storeCommand = require('../lib/commands/store'); module.exports['Branches: store with a single non-array flag value'] = async test => { let storedFlags = null; const connection = createMockConnection({ state: 3, mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) }, exec: async (cmd, attrs) => { // attrs[2] is the flags list storedFlags = attrs[2].map(a => a.value); return { next: () => {}, response: { attributes: [] } }; } }); // flags is a single string, not an array -> exercises `[].concat(flags || [])` const result = await storeCommand(connection, '1:5', '\\Seen', { operation: 'add' }); test.equal(result, true); test.deepEqual(storedFlags, ['\\Seen']); test.done(); }; module.exports['Branches: store with falsy flags and set operation uses [] fallback'] = async test => { let storedFlags = null; const connection = createMockConnection({ state: 3, mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) }, exec: async (cmd, attrs) => { storedFlags = attrs[2].map(a => a.value); return { next: () => {}, response: { attributes: [] } }; } }); // flags is undefined (falsy, non-array) -> exercises the `flags || []` fallback in // `[].concat(flags || [])`. Empty flags are allowed for the 'set' operation (clears all). const result = await storeCommand(connection, '1:5', undefined, { operation: 'set' }); test.equal(result, true); test.deepEqual(storedFlags, []); test.done(); }; // ============================================================================ // status.js — line 30 branch: `Object.keys(query || {})` with undefined query // Call status with no query argument so the `|| {}` fallback runs. With no // query items, queryAttributes is empty and the command returns false. // ============================================================================ const statusCommand = require('../lib/commands/status'); module.exports['Branches: status with undefined query uses {} fallback'] = async test => { let execCalled = false; const connection = createMockConnection({ state: 2, exec: async () => { execCalled = true; return { next: () => {} }; } }); // query undefined -> Object.keys(query || {}) -> no attributes -> returns false const result = await statusCommand(connection, 'INBOX'); test.equal(result, false); test.equal(execCalled, false); test.done(); }; // ============================================================================ // append.js — line 110 branch: responseCode `: ''` fallback. // Tagged response has a section with length but section[0] has no string value, // so the ternary picks '' and APPENDUID parsing is skipped. // ============================================================================ const appendCommand = require('../lib/commands/append'); module.exports['Branches: append tagged section with non-string code uses empty fallback'] = async test => { const connection = createMockConnection({ state: 2, mailbox: { path: 'INBOX', exists: 0, flags: new Set(), permanentFlags: new Set() }, exec: async () => ({ next: () => {}, // section has length, but section[0].value is not a string -> responseCode = '' response: { attributes: [{ section: [{ value: 123 }, { value: '99' }] }] } }), // No EXISTS so map.seq stays unset; no UID search needed search: async () => [] }); const result = await appendCommand(connection, 'Sent', 'Subject: test\r\n\r\nbody'); test.ok(result); test.equal(result.destination, 'Sent'); // APPENDUID not parsed because responseCode === '' test.equal(result.uid, undefined); test.done(); }; // ============================================================================ // logout.js — line 33 / finally branch: successful LOGOUT where `response` // exists and response.next is a function (the `response && typeof ...` true // side of the finally cleanup). Ensures the try succeeds (return true) and the // finally runs response.next() + close(). // ============================================================================ const logoutCommand = require('../lib/commands/logout'); module.exports['Branches: logout success runs response.next in finally'] = async test => { let nextCalled = false; let closeCalled = false; const connection = createMockConnection({ state: 2, exec: async () => ({ next: () => { nextCalled = true; } }), close: () => { closeCalled = true; } }); const result = await logoutCommand(connection); test.equal(result, true); test.equal(nextCalled, true); test.equal(closeCalled, true); test.equal(connection.state, connection.states.LOGOUT); test.done(); }; module.exports['Branches: logout success with response lacking next() skips next call'] = async test => { let closeCalled = false; const connection = createMockConnection({ state: 2, // exec resolves with a truthy response object that has NO next function, // exercising the `typeof response.next === 'function'` false side in finally. exec: async () => ({ response: { attributes: [] } }), close: () => { closeCalled = true; } }); const result = await logoutCommand(connection); test.equal(result, true); test.equal(closeCalled, true); test.equal(connection.state, connection.states.LOGOUT); test.done(); }; // ============================================================================ // namespace.js — line 98 branch: getListPrefix LIST untagged where // attributes[2] is missing, so prefix `|| ''` fallback is taken. // Triggered via the no-NAMESPACE-capability fallback path. // ============================================================================ const namespaceCommand = require('../lib/commands/namespace'); module.exports['Branches: namespace fallback LIST without prefix uses empty string'] = async test => { const connection = createMockConnection({ state: 2, capabilities: new Map([['IMAP4rev1', true]]), // no NAMESPACE -> getListPrefix path exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) { // attributes[0]=flags, [1]=delimiter, but NO [2] -> prefix falls back to '' await opts.untagged.LIST({ attributes: [[{ value: '\\Noselect' }], { value: '/' }] }); } return { next: () => {} }; } }); const result = await namespaceCommand(connection); test.ok(result); test.equal(result.prefix, ''); test.equal(result.delimiter, '/'); test.done(); }; // ============================================================================ // quota.js — line 88 branch: QUOTAROOT untagged where attributes[1] is missing, // so quotaRoot resolves to `false` and map.quotaRoot is not set. // ============================================================================ const quotaCommand = require('../lib/commands/quota'); module.exports['Branches: quota QUOTAROOT without root name yields false'] = async test => { const connection = createMockConnection({ state: 2, capabilities: new Map([['QUOTA', true]]), exec: async (cmd, attrs, opts) => { if (cmd === 'GETQUOTAROOT' && opts && opts.untagged) { if (opts.untagged.QUOTAROOT) { // attributes[1] missing -> quotaRoot = false -> map.quotaRoot stays unset await opts.untagged.QUOTAROOT({ attributes: [{ value: 'INBOX' }] }); } } return { next: () => {} }; } }); const result = await quotaCommand(connection, 'INBOX'); test.ok(result); test.equal(result.path, 'INBOX'); test.equal(result.quotaRoot, undefined); test.done(); }; // ============================================================================ // fetch.js — line 234 branch: ETHROTTLE retry where err.throttleReset is set // and larger than the computed backoff, so the `err.throttleReset` side of the // delay ternary is taken. First exec throws ETHROTTLE, second succeeds. // throttleReset is just above the 1000ms backoff to keep the test fast. // ============================================================================ const fetchCommand = require('../lib/commands/fetch'); module.exports['Branches: fetch ETHROTTLE uses throttleReset delay then retries'] = async test => { let calls = 0; const connection = createMockConnection({ state: 3, mailbox: { path: 'INBOX', exists: 1, flags: new Set(), permanentFlags: new Set(), noModseq: true }, exec: async () => { calls++; if (calls === 1) { const err = new Error('throttled'); err.code = 'ETHROTTLE'; err.throttleReset = 1001; // > backoffDelay(1000) -> throttleReset branch taken err.responseText = 'throttled'; throw err; } return { next: () => {}, response: { attributes: [] } }; } }); const result = await fetchCommand(connection, '1:*', { uid: true }, { uid: true }); test.ok(result); test.equal(calls, 2); test.done(); }; // ============================================================================ // search.js — line 98 branch: `options = options || {}` with undefined options. // Plain SEARCH ALL call without an options argument. // ============================================================================ const searchCommand = require('../lib/commands/search'); module.exports['Branches: search with undefined options uses {} fallback'] = async test => { let execCmd = null; const connection = createMockConnection({ state: 3, exec: async (cmd, attrs, opts) => { execCmd = cmd; if (opts && opts.untagged && opts.untagged.SEARCH) { await opts.untagged.SEARCH({ attributes: [{ value: '1' }, { value: '2' }] }); } return { next: () => {} }; } }); // No options argument -> options || {} fallback; not UID -> plain SEARCH const result = await searchCommand(connection, true); test.deepEqual(result, [1, 2]); test.equal(execCmd, 'SEARCH'); test.done(); }; // ============================================================================ // search.js — line 141 branch: `: 'SEARCH'` side of ESEARCH command name. // ESEARCH path with options.uid falsy. // search.js — line 150 branch: `|| attrs[start].type === 'LIST'` — the leading // tag token is an object with type 'LIST' (not a plain Array), so the second // operand of the OR is evaluated and used to skip it. // ============================================================================ module.exports['Branches: search ESEARCH without uid emits SEARCH and skips LIST-typed tag'] = async test => { let execCmd = null; const connection = createMockConnection({ state: 3, capabilities: new Map([['ESEARCH', true]]), exec: async (cmd, attrs, opts) => { execCmd = cmd; if (opts && opts.untagged && opts.untagged.ESEARCH) { // attrs[0] is a LIST-typed object (not a plain Array) -> exercises // the `attrs[start].type === 'LIST'` branch on line 150. await opts.untagged.ESEARCH({ attributes: [{ type: 'LIST' }, { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '7' }] }); } return { next: () => {} }; } }); // returnOptions present + ESEARCH capability + options.uid falsy const result = await searchCommand(connection, true, { returnOptions: ['COUNT'] }); test.equal(execCmd, 'SEARCH'); // not 'UID SEARCH' test.equal(result.count, 7); test.done(); }; // ============================================================================ // search.js — line 59 `: null` and line 60 `break;` branches: // parseEsearchResponse PARTIAL where the value token is neither a plain Array // nor an object with an `.attributes` array, so `items` is null and the loop // breaks without setting result.partial. // ============================================================================ module.exports['Branches: parseEsearchResponse PARTIAL with non-list value is ignored'] = test => { const parse = searchCommand.parseEsearchResponse; // PARTIAL followed by a plain ATOM (no array, no .attributes) -> items === null -> break const result = parse([ { type: 'ATOM', value: 'PARTIAL' }, { type: 'ATOM', value: 'notalist' } ]); test.equal(result.partial, undefined); test.done(); }; // ============================================================================ // select.js — line 38 branch: `: false` — after running LIST, the requested // path is still not present in connection.folders, so folderListData is false. // ============================================================================ const selectCommand = require('../lib/commands/select'); module.exports['Branches: select with folder list missing requested path sets folderListData false'] = async test => { const connection = createMockConnection({ state: 2, folders: new Map(), run: async cmd => { if (cmd === 'LIST') { // Returns a folder that is NOT the requested 'INBOX' path return [{ path: 'Other', delimiter: '/' }]; } }, exec: async () => ({ next: () => {}, response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] } }), emit: () => {} }); const result = await selectCommand(connection, 'INBOX'); test.ok(result); test.equal(result.path, 'INBOX'); // No delimiter/specialUse copied since folderListData was false test.equal(result.delimiter, undefined); test.done(); }; // ============================================================================ // select.js — line 97 `: false` (PERMANENTFLAGS list entry not a string) and // line 170 `: false` (FLAGS list entry not a string). // ============================================================================ module.exports['Branches: select filters non-string flags in FLAGS and PERMANENTFLAGS'] = async test => { const connection = createMockConnection({ state: 2, folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]), run: async () => [], exec: async (cmd, attrs, opts) => { if (opts && opts.untagged) { if (opts.untagged.FLAGS) { // A list containing a non-string flag (numeric value) // -> exercises the `: false` filter on line 170. await opts.untagged.FLAGS({ attributes: [[{ value: '\\Seen' }, { value: 42 }]] }); } if (opts.untagged.OK) { // PERMANENTFLAGS list with a non-string entry -> line 97 `: false` await opts.untagged.OK({ attributes: [{ section: [{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\*' }, { value: 7 }]] }] }); } } return { next: () => {}, response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] } }; }, emit: () => {} }); const result = await selectCommand(connection, 'INBOX'); test.ok(result); // Non-string flag filtered out test.ok(result.flags.has('\\Seen')); test.equal(result.flags.size, 1); // Non-string permanent flag filtered out test.ok(result.permanentFlags.has('\\*')); test.equal(result.permanentFlags.size, 1); test.done(); }; // Regression: a malformed `* FLAGS` untagged response (empty attributes, or a // non-array attributes[0]) must be ignored, not crash on `attributes[0].map(...)`. module.exports['Branches: select FLAGS guard ignores malformed attributes without throwing'] = async test => { test.expect(4); const makeConn = flagsAttributes => createMockConnection({ state: 2, folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]), run: async () => [], exec: async (cmd, attrs, opts) => { if (opts && opts.untagged && opts.untagged.FLAGS) { await opts.untagged.FLAGS({ attributes: flagsAttributes }); } return { next: () => {}, response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] } }; }, emit: () => {} }); // Empty attributes (server sent `* FLAGS` with no list): bail on the length guard. const emptyResult = await selectCommand(makeConn([]), 'INBOX'); test.ok(emptyResult); test.equal(emptyResult.flags, undefined); // attributes[0] present but not an array: bail on the Array.isArray guard. const nonArrayResult = await selectCommand(makeConn([{ value: '\\Seen' }]), 'INBOX'); test.ok(nonArrayResult); test.equal(nonArrayResult.flags, undefined); test.done(); }; // ============================================================================ // list.js — multiple branches. // ============================================================================ const listCommand = require('../lib/commands/list'); // list.js line 47 branch: statusQuery key with a falsy value -> `return;`. module.exports['Branches: list statusQuery with falsy value is skipped'] = async test => { const connection = createMockConnection({ state: 2, capabilities: new Map([ ['LIST-STATUS', true], ['SPECIAL-USE', true] ]), exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) { await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); } return { next: () => {} }; } }); // messages:false is skipped (line 47), unseen:true is kept const result = await listCommand(connection, '', '*', { statusQuery: { messages: false, unseen: true } }); test.ok(Array.isArray(result)); test.done(); }; // list.js lines 128/129 (LIST untagged attributes[2] missing -> '' fallbacks), // 162 (parentPath '' when no delimiter) and 163 (parent [entry.path] when no // delimiter). LIST entry with a NIL delimiter and no name attribute. module.exports['Branches: list LIST entry without delimiter or name uses fallbacks'] = async test => { const connection = createMockConnection({ state: 2, exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) { // attributes[1] (delimiter) is NIL (null) and attributes[2] (name) missing await opts.untagged.LIST({ attributes: [[{ value: '\\Noselect' }], null] }); } return { next: () => {} }; } }); const result = await listCommand(connection, '', '*'); test.ok(Array.isArray(result)); test.equal(result.length, 1); // path defaulted to '' (no attributes[2]); no delimiter -> parentPath '' and parent [''] test.equal(result[0].path, ''); test.equal(result[0].parentPath, ''); test.done(); }; // list.js line 184 (`: false` when STATUS attributes[1] not an array) and // line 185 (`if (!statusList || !statusPath) return;`). module.exports['Branches: list STATUS with non-array values is ignored'] = async test => { const connection = createMockConnection({ state: 2, capabilities: new Map([ ['LIST-STATUS', true], ['SPECIAL-USE', true] ]), exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged) { if (opts.untagged.LIST) { await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); } if (opts.untagged.STATUS) { // attributes[0] missing -> statusPath '' fallback (line 183); and // attributes[1] is not an array -> statusList false -> early return (line 185) await opts.untagged.STATUS({ attributes: [undefined, { value: 'notanarray' }] }); } } return { next: () => {} }; } }); const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } }); test.ok(Array.isArray(result)); // No status attached because the STATUS untagged response was ignored const inbox = result.find(e => e.path === 'INBOX'); test.ok(inbox); test.done(); }; // list.js line 203 (`: false` when STATUS key value is not a string) and // line 206 (`if (!key || !entry || typeof entry.value !== 'string') return;`). module.exports['Branches: list STATUS with non-string key/value pairs skipped'] = async test => { const connection = createMockConnection({ state: 2, capabilities: new Map([ ['LIST-STATUS', true], ['SPECIAL-USE', true] ]), exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged) { if (opts.untagged.LIST) { await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); } if (opts.untagged.STATUS) { // key token has a non-string value (line 203 -> key = false), // then the value token also has a non-string value (line 206 guard). await opts.untagged.STATUS({ attributes: [{ value: 'INBOX' }, [{ value: 999 }, { value: 12 }]] }); } } return { next: () => {} }; } }); const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } }); test.ok(Array.isArray(result)); test.done(); }; // list.js line 231 / 269 branches: `mailbox || ''` empty side. Call list with // an undefined mailbox argument so both the LIST and LSUB calls take the '' // fallback for the mailbox pattern. module.exports['Branches: list with undefined mailbox pattern uses empty fallback'] = async test => { let listMailboxArg; let lsubMailboxArg; const connection = createMockConnection({ state: 2, exec: async (cmd, attrs, opts) => { if (cmd === 'LIST') { listMailboxArg = attrs[1]; if (opts && opts.untagged && opts.untagged.LIST) { await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); } } if (cmd === 'LSUB') { lsubMailboxArg = attrs[1]; } return { next: () => {} }; } }); // mailbox undefined -> `mailbox || ''` fallback used at lines 231 and 269 const result = await listCommand(connection, '', undefined); test.ok(Array.isArray(result)); test.equal(listMailboxArg, ''); test.equal(lsubMailboxArg, ''); test.done(); }; // list.js lines 278/279 (LSUB attributes[2] missing -> '' fallbacks) and // lines 293/294 (LSUB entry without delimiter -> parentPath '' / parent [path]). module.exports['Branches: list LSUB entry without delimiter or name uses fallbacks'] = async test => { const connection = createMockConnection({ state: 2, exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) { await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); } if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) { // delimiter NIL and no name attribute -> '' fallbacks + no-delimiter parent logic await opts.untagged.LSUB({ attributes: [[{ value: '\\HasNoChildren' }], null] }); } return { next: () => {} }; } }); const result = await listCommand(connection, '', '*'); test.ok(Array.isArray(result)); test.done(); }; // list.js line 342 branch: `if (!a.specialUse && b.specialUse) return 1;`. // To make V8's sort invoke the comparator with a non-special `a` and a // special-use `b`, the special-use entry must come FIRST in insertion order, // followed by several plain folders. module.exports['Branches: list sort returns 1 when a lacks specialUse but b has it'] = async test => { const connection = createMockConnection({ state: 2, exec: async (cmd, attrs, opts) => { if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) { // INBOX (name-based \\Inbox special use) emitted FIRST, then plain folders. await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }] }); await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'AAA' }] }); await opts.untagged.LIST({ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'BBB' }] }); } return { next: () => {} }; } }); const result = await listCommand(connection, '', '*'); test.ok(Array.isArray(result)); // INBOX (special use) sorts before the plain folders test.equal(result[0].path, 'INBOX'); test.deepEqual( result.slice(1).map(e => e.path), ['AAA', 'BBB'] ); test.done(); }; // ============================================================================ // idle.js — NOOP fallback path (no IDLE capability). // Covers line 216 (`case 'NOOP'`) and line 224 (`maxIdleTime ?` truthy side). // We trigger one NOOP poll, then break the loop via preCheck(). // ============================================================================ const idleCommand = require('../lib/commands/idle'); module.exports['Branches: idle NOOP fallback runs poll then breaks via preCheck'] = async test => { test.expect(1); let noopCalled = false; let resolveNoop; let noopRan = new Promise(resolve => { resolveNoop = resolve; }); const connection = createMockConnection({ state: 3, capabilities: new Map([['IMAP4rev1', true]]), // no IDLE -> NOOP polling path currentSelectCommand: { command: 'SELECT', arguments: [{ type: 'ATOM', value: 'INBOX' }] }, missingIdleCommand: 'NOOP', exec: async cmd => { if (cmd === 'NOOP') { noopCalled = true; resolveNoop(); } return { next: () => {} }; } }); // maxIdleTime provided -> exercises `maxIdleTime ? Math.min(...) : NOOP_INTERVAL` truthy side const idlePromise = idleCommand(connection, 60000); // Wait until the first NOOP poll has actually run, then cross a macrotask // boundary so the loop's .then() has armed its next-poll setTimeout before we // break out. This guarantees preCheck()'s clearTimeout clears the armed timer, // so no 60s timer leaks and the poll loop stops deterministically. await noopRan; await new Promise(resolve => setImmediate(resolve)); await connection.preCheck(); await idlePromise; test.equal(noopCalled, true); test.done(); }; // ============================================================================ // idle.js — IDLE path with currentLock set, exercising the `?.lockId` optional // chaining truthy sides (lines 37, 62, 83, 101) and the cleanup while-loop // (line 109) that drains preCheckWaitQueue. We request a break (preCheck) // before the server acknowledges with the "+" continuation, so that the // onPlusTag handler invokes preCheck() which sends DONE and drains the queue. // ============================================================================ module.exports['Branches: idle IDLE path with currentLock drives preCheck and cleanup'] = async test => { let doneWritten = false; let onPlusTagFn = null; let resolveExec = null; // Deferred that resolves once runIdle has entered exec (preCheck registered). let resolveEntered; const execEntered = new Promise(resolve => { resolveEntered = resolve; }); const connection = createMockConnection({ state: 3, capabilities: new Map([['IDLE', true]]), currentLock: { lockId: 'lock-1' }, // makes `currentLock?.lockId` take the defined (truthy) side write: data => { if (data === 'DONE') { doneWritten = true; } }, exec: (cmd, attrs, opts) => { // Capture onPlusTag, signal entry, and keep IDLE pending until resolved below. onPlusTagFn = opts.onPlusTag; resolveEntered(); return new Promise(resolve => { resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } }); }); } }); const idlePromise = idleCommand(connection); await execEntered; // Request an IDLE break before the "+" continuation. canEnd is still false, // so DONE is not sent yet; a waiter is queued in preCheckWaitQueue. const breakPromise = typeof connection.preCheck === 'function' ? connection.preCheck() : Promise.resolve(); // Simulate the server "+" continuation. doneRequested is already true, so // onPlusTag sets canEnd and calls preCheck(), which writes DONE and drains the // queue (covers the `?.lockId` truthy sides and the preCheckWaitQueue drain). if (onPlusTagFn) { await onPlusTagFn(); } // Let the IDLE exec resolve so runIdle proceeds to its cleanup block. if (resolveExec) { resolveExec(); } await breakPromise; await idlePromise; test.equal(doneWritten, true); test.done(); }; // ============================================================================ // idle.js — lines 109-112: the cleanup-block while-loop that drains // preCheckWaitQueue. This runs when a break was requested but DONE was never // sent (server never sent the "+" continuation, so canEnd stayed false and // preCheck remained registered). When exec then resolves, runIdle's cleanup // finds preCheck === connectionPreCheck and drains the still-queued waiters. // ============================================================================ module.exports['Branches: idle cleanup drains queued waiters when DONE never sent'] = async test => { let onPlusTagFn = null; let resolveExec = null; let resolveEntered; const execEntered = new Promise(resolve => { resolveEntered = resolve; }); const connection = createMockConnection({ state: 3, capabilities: new Map([['IDLE', true]]), currentLock: { lockId: 'lock-9' }, // exercises `currentLock?.lockId` truthy side in cleanup trace write: () => {}, // would only be called if DONE were sent; it never is here exec: (cmd, attrs, opts) => { onPlusTagFn = opts.onPlusTag; resolveEntered(); return new Promise(resolve => { resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } }); }); } }); const idlePromise = idleCommand(connection); await execEntered; // Request a break: canEnd is still false (no "+" continuation yet), so DONE is // NOT sent and connection.preCheck stays registered. The waiter is queued. const breakPromise = connection.preCheck(); // Resolve exec WITHOUT invoking onPlusTag, so runIdle reaches its cleanup block // with preCheck still === connectionPreCheck and the queue non-empty -> drains it. test.ok(typeof onPlusTagFn === 'function'); resolveExec(); await breakPromise; // resolves only if the cleanup drain ran await idlePromise; test.done(); }; // ============================================================================ // idle.js — line 88 catch: the onPlusTag handler wraps `await preCheck()` in a // try/catch. We make connection.write('DONE') throw so that preCheck rejects and // the catch on line 88 logs the error. A break is requested before the "+" // continuation so that onPlusTag invokes preCheck() (which calls write). // ============================================================================ module.exports['Branches: idle onPlusTag catches preCheck error when DONE write throws'] = async test => { let onPlusTagFn = null; let resolveExec = null; let resolveEntered; const execEntered = new Promise(resolve => { resolveEntered = resolve; }); const connection = createMockConnection({ state: 3, capabilities: new Map([['IDLE', true]]), write: data => { if (data === 'DONE') { throw new Error('socket write failed'); } }, exec: (cmd, attrs, opts) => { onPlusTagFn = opts.onPlusTag; resolveEntered(); return new Promise(resolve => { resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } }); }); } }); const idlePromise = idleCommand(connection); await execEntered; // Request a break before the "+" continuation: queues a waiter, sets // doneRequested true, but canEnd is still false so write isn't called yet. const breakPromise = connection.preCheck(); // Server "+" continuation: onPlusTag sets canEnd and calls preCheck(), which // attempts connection.write('DONE') -> throws -> caught by the line 88 catch. await onPlusTagFn(); // The break never resolves because DONE failed; resolve exec so IDLE completes, // and detach the rejected break handler to avoid an unhandled rejection. resolveExec(); breakPromise.catch(() => {}); await idlePromise; test.done(); }; // ============================================================================ // authenticate.js — error paths in authOauth (line 88), authLogin (line 136) // and authPlain (line 173). Each calls handleAuthError when exec rejects. // We make exec reject so the catch -> handleAuthError -> throw path runs. // ============================================================================ const authenticateCommand = require('../lib/commands/authenticate'); module.exports['Branches: authenticate OAuth error path calls handleAuthError'] = async test => { const connection = createMockConnection({ state: 1, // NOT_AUTHENTICATED servername: 'imap.example.com', capabilities: new Map([['AUTH=OAUTHBEARER', true]]), exec: async () => { const err = new Error('auth failed'); err.response = { attributes: [] }; throw err; } }); test.expect(1); try { await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' }); test.ok(false, 'should have thrown'); } catch (err) { test.equal(err.authenticationFailed, true); } test.done(); }; module.exports['Branches: authenticate PLAIN error path calls handleAuthError'] = async test => { const connection = createMockConnection({ state: 1, capabilities: new Map([['AUTH=PLAIN', true]]), exec: async () => { const err = new Error('auth failed'); err.response = { attributes: [] }; throw err; } }); test.expect(1); try { await authenticateCommand(connection, 'user@example.com', { password: 'secret' }); test.ok(false, 'should have thrown'); } catch (err) { test.equal(err.authenticationFailed, true); } test.done(); }; module.exports['Branches: authenticate LOGIN error path calls handleAuthError'] = async test => { const connection = createMockConnection({ state: 1, capabilities: new Map([['AUTH=LOGIN', true]]), // no AUTH=PLAIN -> LOGIN chosen exec: async () => { const err = new Error('auth failed'); err.response = { attributes: [] }; throw err; } }); test.expect(1); try { await authenticateCommand(connection, 'user@example.com', { password: 'secret' }); test.ok(false, 'should have thrown'); } catch (err) { test.equal(err.authenticationFailed, true); } test.done(); };