imapflow 1.3.6 → 1.3.7

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.
@@ -0,0 +1,1068 @@
1
+ 'use strict';
2
+
3
+ /* eslint-disable new-cap */
4
+ // BigInt() is a standard JS function but triggers new-cap rule
5
+
6
+ // ============================================================================
7
+ // Additional branch-coverage tests for lib/commands/*.js
8
+ //
9
+ // Each test targets one or more specific uncovered branches identified via c8.
10
+ // The mock connection factory mirrors the one in commands-integration-test.js.
11
+ // ============================================================================
12
+
13
+ const createMockConnection = (overrides = {}) => {
14
+ const states = {
15
+ NOT_AUTHENTICATED: 1,
16
+ AUTHENTICATED: 2,
17
+ SELECTED: 3,
18
+ LOGOUT: 4
19
+ };
20
+
21
+ const defaultMailbox = {
22
+ path: 'INBOX',
23
+ flags: new Set(['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft']),
24
+ permanentFlags: new Set(['\\*']),
25
+ exists: 100,
26
+ recent: 5,
27
+ uidNext: 1000,
28
+ uidValidity: BigInt(12345),
29
+ noModseq: false
30
+ };
31
+
32
+ return {
33
+ states,
34
+ state: overrides.state || states.SELECTED,
35
+ id: 'test-connection-id',
36
+ capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]),
37
+ enabled: new Set(overrides.enabled || []),
38
+ authCapabilities: new Map(),
39
+ folders: overrides.folders || new Map(),
40
+ mailbox: overrides.mailbox || { ...defaultMailbox },
41
+ namespace: overrides.namespace || { delimiter: '/', prefix: '' },
42
+ expectCapabilityUpdate: overrides.expectCapabilityUpdate || false,
43
+ log: {
44
+ warn: () => {},
45
+ info: () => {},
46
+ error: () => {},
47
+ debug: () => {},
48
+ trace: () => {}
49
+ },
50
+ close: overrides.close || (() => {}),
51
+ emit: overrides.emit || (() => {}),
52
+ write: overrides.write || (() => {}),
53
+ currentSelectCommand: false,
54
+ messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
55
+ messageCopy: overrides.messageCopy || (async () => {}),
56
+ messageDelete: overrides.messageDelete || (async () => {}),
57
+ run: overrides.run || (async () => {}),
58
+ exec:
59
+ overrides.exec ||
60
+ (async () => ({
61
+ next: () => {},
62
+ response: { attributes: [] }
63
+ })),
64
+ ...overrides
65
+ };
66
+ };
67
+
68
+ // ============================================================================
69
+ // copy.js — line 22 branch: `options = options || {}` with falsy options
70
+ // Call copy with range + destination but no options argument so the `|| {}`
71
+ // right-hand side is taken.
72
+ // ============================================================================
73
+
74
+ const copyCommand = require('../lib/commands/copy');
75
+
76
+ module.exports['Branches: copy with undefined options uses {} fallback'] = async test => {
77
+ let execCmd = null;
78
+ const connection = createMockConnection({
79
+ state: 3,
80
+ exec: async cmd => {
81
+ execCmd = cmd;
82
+ return { next: () => {}, response: { attributes: [] } };
83
+ }
84
+ });
85
+
86
+ // No options argument -> options is undefined -> `options || {}` fallback
87
+ const result = await copyCommand(connection, '1:5', 'Archive');
88
+ test.ok(result);
89
+ test.equal(execCmd, 'COPY'); // not UID COPY, since options.uid is undefined
90
+ test.done();
91
+ };
92
+
93
+ // ============================================================================
94
+ // move.js — line 22 branch: `options = options || {}` with falsy options
95
+ // MOVE capability present, no options argument.
96
+ // ============================================================================
97
+
98
+ const moveCommand = require('../lib/commands/move');
99
+
100
+ module.exports['Branches: move with undefined options uses {} fallback'] = async test => {
101
+ let execCmd = null;
102
+ const connection = createMockConnection({
103
+ state: 3,
104
+ capabilities: new Map([['MOVE', true]]),
105
+ exec: async cmd => {
106
+ execCmd = cmd;
107
+ return { next: () => {}, response: { attributes: [] } };
108
+ }
109
+ });
110
+
111
+ const result = await moveCommand(connection, '1:5', 'Archive');
112
+ test.ok(result);
113
+ test.equal(execCmd, 'MOVE');
114
+ test.done();
115
+ };
116
+
117
+ // ============================================================================
118
+ // expunge.js — line 20 branch: `options = options || {}` with falsy options
119
+ // Call expunge with range but no options argument.
120
+ // ============================================================================
121
+
122
+ const expungeCommand = require('../lib/commands/expunge');
123
+
124
+ module.exports['Branches: expunge with undefined options uses {} fallback'] = async test => {
125
+ let execCmd = null;
126
+ const connection = createMockConnection({
127
+ state: 3,
128
+ exec: async cmd => {
129
+ execCmd = cmd;
130
+ return { next: () => {}, response: { attributes: [] } };
131
+ }
132
+ });
133
+
134
+ // No options argument -> options || {} fallback; byUid false -> plain EXPUNGE
135
+ const result = await expungeCommand(connection, '1:5');
136
+ test.equal(result, true);
137
+ test.equal(execCmd, 'EXPUNGE');
138
+ test.done();
139
+ };
140
+
141
+ // ============================================================================
142
+ // store.js — line 56 branch: `: [].concat(flags || [])` — flags passed as a
143
+ // single (non-array) value, so the Array.isArray() ternary takes the false side.
144
+ // ============================================================================
145
+
146
+ const storeCommand = require('../lib/commands/store');
147
+
148
+ module.exports['Branches: store with a single non-array flag value'] = async test => {
149
+ let storedFlags = null;
150
+ const connection = createMockConnection({
151
+ state: 3,
152
+ mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) },
153
+ exec: async (cmd, attrs) => {
154
+ // attrs[2] is the flags list
155
+ storedFlags = attrs[2].map(a => a.value);
156
+ return { next: () => {}, response: { attributes: [] } };
157
+ }
158
+ });
159
+
160
+ // flags is a single string, not an array -> exercises `[].concat(flags || [])`
161
+ const result = await storeCommand(connection, '1:5', '\\Seen', { operation: 'add' });
162
+ test.equal(result, true);
163
+ test.deepEqual(storedFlags, ['\\Seen']);
164
+ test.done();
165
+ };
166
+
167
+ module.exports['Branches: store with falsy flags and set operation uses [] fallback'] = async test => {
168
+ let storedFlags = null;
169
+ const connection = createMockConnection({
170
+ state: 3,
171
+ mailbox: { path: 'INBOX', flags: new Set(['\\Seen']), permanentFlags: new Set(['\\Seen']) },
172
+ exec: async (cmd, attrs) => {
173
+ storedFlags = attrs[2].map(a => a.value);
174
+ return { next: () => {}, response: { attributes: [] } };
175
+ }
176
+ });
177
+
178
+ // flags is undefined (falsy, non-array) -> exercises the `flags || []` fallback in
179
+ // `[].concat(flags || [])`. Empty flags are allowed for the 'set' operation (clears all).
180
+ const result = await storeCommand(connection, '1:5', undefined, { operation: 'set' });
181
+ test.equal(result, true);
182
+ test.deepEqual(storedFlags, []);
183
+ test.done();
184
+ };
185
+
186
+ // ============================================================================
187
+ // status.js — line 30 branch: `Object.keys(query || {})` with undefined query
188
+ // Call status with no query argument so the `|| {}` fallback runs. With no
189
+ // query items, queryAttributes is empty and the command returns false.
190
+ // ============================================================================
191
+
192
+ const statusCommand = require('../lib/commands/status');
193
+
194
+ module.exports['Branches: status with undefined query uses {} fallback'] = async test => {
195
+ let execCalled = false;
196
+ const connection = createMockConnection({
197
+ state: 2,
198
+ exec: async () => {
199
+ execCalled = true;
200
+ return { next: () => {} };
201
+ }
202
+ });
203
+
204
+ // query undefined -> Object.keys(query || {}) -> no attributes -> returns false
205
+ const result = await statusCommand(connection, 'INBOX');
206
+ test.equal(result, false);
207
+ test.equal(execCalled, false);
208
+ test.done();
209
+ };
210
+
211
+ // ============================================================================
212
+ // append.js — line 110 branch: responseCode `: ''` fallback.
213
+ // Tagged response has a section with length but section[0] has no string value,
214
+ // so the ternary picks '' and APPENDUID parsing is skipped.
215
+ // ============================================================================
216
+
217
+ const appendCommand = require('../lib/commands/append');
218
+
219
+ module.exports['Branches: append tagged section with non-string code uses empty fallback'] = async test => {
220
+ const connection = createMockConnection({
221
+ state: 2,
222
+ mailbox: { path: 'INBOX', exists: 0, flags: new Set(), permanentFlags: new Set() },
223
+ exec: async () => ({
224
+ next: () => {},
225
+ // section has length, but section[0].value is not a string -> responseCode = ''
226
+ response: { attributes: [{ section: [{ value: 123 }, { value: '99' }] }] }
227
+ }),
228
+ // No EXISTS so map.seq stays unset; no UID search needed
229
+ search: async () => []
230
+ });
231
+
232
+ const result = await appendCommand(connection, 'Sent', 'Subject: test\r\n\r\nbody');
233
+ test.ok(result);
234
+ test.equal(result.destination, 'Sent');
235
+ // APPENDUID not parsed because responseCode === ''
236
+ test.equal(result.uid, undefined);
237
+ test.done();
238
+ };
239
+
240
+ // ============================================================================
241
+ // logout.js — line 33 / finally branch: successful LOGOUT where `response`
242
+ // exists and response.next is a function (the `response && typeof ...` true
243
+ // side of the finally cleanup). Ensures the try succeeds (return true) and the
244
+ // finally runs response.next() + close().
245
+ // ============================================================================
246
+
247
+ const logoutCommand = require('../lib/commands/logout');
248
+
249
+ module.exports['Branches: logout success runs response.next in finally'] = async test => {
250
+ let nextCalled = false;
251
+ let closeCalled = false;
252
+ const connection = createMockConnection({
253
+ state: 2,
254
+ exec: async () => ({
255
+ next: () => {
256
+ nextCalled = true;
257
+ }
258
+ }),
259
+ close: () => {
260
+ closeCalled = true;
261
+ }
262
+ });
263
+
264
+ const result = await logoutCommand(connection);
265
+ test.equal(result, true);
266
+ test.equal(nextCalled, true);
267
+ test.equal(closeCalled, true);
268
+ test.equal(connection.state, connection.states.LOGOUT);
269
+ test.done();
270
+ };
271
+
272
+ module.exports['Branches: logout success with response lacking next() skips next call'] = async test => {
273
+ let closeCalled = false;
274
+ const connection = createMockConnection({
275
+ state: 2,
276
+ // exec resolves with a truthy response object that has NO next function,
277
+ // exercising the `typeof response.next === 'function'` false side in finally.
278
+ exec: async () => ({ response: { attributes: [] } }),
279
+ close: () => {
280
+ closeCalled = true;
281
+ }
282
+ });
283
+
284
+ const result = await logoutCommand(connection);
285
+ test.equal(result, true);
286
+ test.equal(closeCalled, true);
287
+ test.equal(connection.state, connection.states.LOGOUT);
288
+ test.done();
289
+ };
290
+
291
+ // ============================================================================
292
+ // namespace.js — line 98 branch: getListPrefix LIST untagged where
293
+ // attributes[2] is missing, so prefix `|| ''` fallback is taken.
294
+ // Triggered via the no-NAMESPACE-capability fallback path.
295
+ // ============================================================================
296
+
297
+ const namespaceCommand = require('../lib/commands/namespace');
298
+
299
+ module.exports['Branches: namespace fallback LIST without prefix uses empty string'] = async test => {
300
+ const connection = createMockConnection({
301
+ state: 2,
302
+ capabilities: new Map([['IMAP4rev1', true]]), // no NAMESPACE -> getListPrefix path
303
+ exec: async (cmd, attrs, opts) => {
304
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
305
+ // attributes[0]=flags, [1]=delimiter, but NO [2] -> prefix falls back to ''
306
+ await opts.untagged.LIST({
307
+ attributes: [[{ value: '\\Noselect' }], { value: '/' }]
308
+ });
309
+ }
310
+ return { next: () => {} };
311
+ }
312
+ });
313
+
314
+ const result = await namespaceCommand(connection);
315
+ test.ok(result);
316
+ test.equal(result.prefix, '');
317
+ test.equal(result.delimiter, '/');
318
+ test.done();
319
+ };
320
+
321
+ // ============================================================================
322
+ // quota.js — line 88 branch: QUOTAROOT untagged where attributes[1] is missing,
323
+ // so quotaRoot resolves to `false` and map.quotaRoot is not set.
324
+ // ============================================================================
325
+
326
+ const quotaCommand = require('../lib/commands/quota');
327
+
328
+ module.exports['Branches: quota QUOTAROOT without root name yields false'] = async test => {
329
+ const connection = createMockConnection({
330
+ state: 2,
331
+ capabilities: new Map([['QUOTA', true]]),
332
+ exec: async (cmd, attrs, opts) => {
333
+ if (cmd === 'GETQUOTAROOT' && opts && opts.untagged) {
334
+ if (opts.untagged.QUOTAROOT) {
335
+ // attributes[1] missing -> quotaRoot = false -> map.quotaRoot stays unset
336
+ await opts.untagged.QUOTAROOT({ attributes: [{ value: 'INBOX' }] });
337
+ }
338
+ }
339
+ return { next: () => {} };
340
+ }
341
+ });
342
+
343
+ const result = await quotaCommand(connection, 'INBOX');
344
+ test.ok(result);
345
+ test.equal(result.path, 'INBOX');
346
+ test.equal(result.quotaRoot, undefined);
347
+ test.done();
348
+ };
349
+
350
+ // ============================================================================
351
+ // fetch.js — line 234 branch: ETHROTTLE retry where err.throttleReset is set
352
+ // and larger than the computed backoff, so the `err.throttleReset` side of the
353
+ // delay ternary is taken. First exec throws ETHROTTLE, second succeeds.
354
+ // throttleReset is just above the 1000ms backoff to keep the test fast.
355
+ // ============================================================================
356
+
357
+ const fetchCommand = require('../lib/commands/fetch');
358
+
359
+ module.exports['Branches: fetch ETHROTTLE uses throttleReset delay then retries'] = async test => {
360
+ let calls = 0;
361
+ const connection = createMockConnection({
362
+ state: 3,
363
+ mailbox: { path: 'INBOX', exists: 1, flags: new Set(), permanentFlags: new Set(), noModseq: true },
364
+ exec: async () => {
365
+ calls++;
366
+ if (calls === 1) {
367
+ const err = new Error('throttled');
368
+ err.code = 'ETHROTTLE';
369
+ err.throttleReset = 1001; // > backoffDelay(1000) -> throttleReset branch taken
370
+ err.responseText = 'throttled';
371
+ throw err;
372
+ }
373
+ return { next: () => {}, response: { attributes: [] } };
374
+ }
375
+ });
376
+
377
+ const result = await fetchCommand(connection, '1:*', { uid: true }, { uid: true });
378
+ test.ok(result);
379
+ test.equal(calls, 2);
380
+ test.done();
381
+ };
382
+
383
+ // ============================================================================
384
+ // search.js — line 98 branch: `options = options || {}` with undefined options.
385
+ // Plain SEARCH ALL call without an options argument.
386
+ // ============================================================================
387
+
388
+ const searchCommand = require('../lib/commands/search');
389
+
390
+ module.exports['Branches: search with undefined options uses {} fallback'] = async test => {
391
+ let execCmd = null;
392
+ const connection = createMockConnection({
393
+ state: 3,
394
+ exec: async (cmd, attrs, opts) => {
395
+ execCmd = cmd;
396
+ if (opts && opts.untagged && opts.untagged.SEARCH) {
397
+ await opts.untagged.SEARCH({ attributes: [{ value: '1' }, { value: '2' }] });
398
+ }
399
+ return { next: () => {} };
400
+ }
401
+ });
402
+
403
+ // No options argument -> options || {} fallback; not UID -> plain SEARCH
404
+ const result = await searchCommand(connection, true);
405
+ test.deepEqual(result, [1, 2]);
406
+ test.equal(execCmd, 'SEARCH');
407
+ test.done();
408
+ };
409
+
410
+ // ============================================================================
411
+ // search.js — line 141 branch: `: 'SEARCH'` side of ESEARCH command name.
412
+ // ESEARCH path with options.uid falsy.
413
+ // search.js — line 150 branch: `|| attrs[start].type === 'LIST'` — the leading
414
+ // tag token is an object with type 'LIST' (not a plain Array), so the second
415
+ // operand of the OR is evaluated and used to skip it.
416
+ // ============================================================================
417
+
418
+ module.exports['Branches: search ESEARCH without uid emits SEARCH and skips LIST-typed tag'] = async test => {
419
+ let execCmd = null;
420
+ const connection = createMockConnection({
421
+ state: 3,
422
+ capabilities: new Map([['ESEARCH', true]]),
423
+ exec: async (cmd, attrs, opts) => {
424
+ execCmd = cmd;
425
+ if (opts && opts.untagged && opts.untagged.ESEARCH) {
426
+ // attrs[0] is a LIST-typed object (not a plain Array) -> exercises
427
+ // the `attrs[start].type === 'LIST'` branch on line 150.
428
+ await opts.untagged.ESEARCH({
429
+ attributes: [{ type: 'LIST' }, { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '7' }]
430
+ });
431
+ }
432
+ return { next: () => {} };
433
+ }
434
+ });
435
+
436
+ // returnOptions present + ESEARCH capability + options.uid falsy
437
+ const result = await searchCommand(connection, true, { returnOptions: ['COUNT'] });
438
+ test.equal(execCmd, 'SEARCH'); // not 'UID SEARCH'
439
+ test.equal(result.count, 7);
440
+ test.done();
441
+ };
442
+
443
+ // ============================================================================
444
+ // search.js — line 59 `: null` and line 60 `break;` branches:
445
+ // parseEsearchResponse PARTIAL where the value token is neither a plain Array
446
+ // nor an object with an `.attributes` array, so `items` is null and the loop
447
+ // breaks without setting result.partial.
448
+ // ============================================================================
449
+
450
+ module.exports['Branches: parseEsearchResponse PARTIAL with non-list value is ignored'] = test => {
451
+ const parse = searchCommand.parseEsearchResponse;
452
+ // PARTIAL followed by a plain ATOM (no array, no .attributes) -> items === null -> break
453
+ const result = parse([
454
+ { type: 'ATOM', value: 'PARTIAL' },
455
+ { type: 'ATOM', value: 'notalist' }
456
+ ]);
457
+ test.equal(result.partial, undefined);
458
+ test.done();
459
+ };
460
+
461
+ // ============================================================================
462
+ // select.js — line 38 branch: `: false` — after running LIST, the requested
463
+ // path is still not present in connection.folders, so folderListData is false.
464
+ // ============================================================================
465
+
466
+ const selectCommand = require('../lib/commands/select');
467
+
468
+ module.exports['Branches: select with folder list missing requested path sets folderListData false'] = async test => {
469
+ const connection = createMockConnection({
470
+ state: 2,
471
+ folders: new Map(),
472
+ run: async cmd => {
473
+ if (cmd === 'LIST') {
474
+ // Returns a folder that is NOT the requested 'INBOX' path
475
+ return [{ path: 'Other', delimiter: '/' }];
476
+ }
477
+ },
478
+ exec: async () => ({
479
+ next: () => {},
480
+ response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
481
+ }),
482
+ emit: () => {}
483
+ });
484
+
485
+ const result = await selectCommand(connection, 'INBOX');
486
+ test.ok(result);
487
+ test.equal(result.path, 'INBOX');
488
+ // No delimiter/specialUse copied since folderListData was false
489
+ test.equal(result.delimiter, undefined);
490
+ test.done();
491
+ };
492
+
493
+ // ============================================================================
494
+ // select.js — line 97 `: false` (PERMANENTFLAGS list entry not a string) and
495
+ // line 170 `: false` (FLAGS list entry not a string).
496
+ // ============================================================================
497
+
498
+ module.exports['Branches: select filters non-string flags in FLAGS and PERMANENTFLAGS'] = async test => {
499
+ const connection = createMockConnection({
500
+ state: 2,
501
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
502
+ run: async () => [],
503
+ exec: async (cmd, attrs, opts) => {
504
+ if (opts && opts.untagged) {
505
+ if (opts.untagged.FLAGS) {
506
+ // A list containing a non-string flag (numeric value)
507
+ // -> exercises the `: false` filter on line 170.
508
+ await opts.untagged.FLAGS({ attributes: [[{ value: '\\Seen' }, { value: 42 }]] });
509
+ }
510
+ if (opts.untagged.OK) {
511
+ // PERMANENTFLAGS list with a non-string entry -> line 97 `: false`
512
+ await opts.untagged.OK({
513
+ attributes: [{ section: [{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\*' }, { value: 7 }]] }]
514
+ });
515
+ }
516
+ }
517
+ return {
518
+ next: () => {},
519
+ response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
520
+ };
521
+ },
522
+ emit: () => {}
523
+ });
524
+
525
+ const result = await selectCommand(connection, 'INBOX');
526
+ test.ok(result);
527
+ // Non-string flag filtered out
528
+ test.ok(result.flags.has('\\Seen'));
529
+ test.equal(result.flags.size, 1);
530
+ // Non-string permanent flag filtered out
531
+ test.ok(result.permanentFlags.has('\\*'));
532
+ test.equal(result.permanentFlags.size, 1);
533
+ test.done();
534
+ };
535
+
536
+ // Regression: a malformed `* FLAGS` untagged response (empty attributes, or a
537
+ // non-array attributes[0]) must be ignored, not crash on `attributes[0].map(...)`.
538
+ module.exports['Branches: select FLAGS guard ignores malformed attributes without throwing'] = async test => {
539
+ test.expect(4);
540
+
541
+ const makeConn = flagsAttributes =>
542
+ createMockConnection({
543
+ state: 2,
544
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
545
+ run: async () => [],
546
+ exec: async (cmd, attrs, opts) => {
547
+ if (opts && opts.untagged && opts.untagged.FLAGS) {
548
+ await opts.untagged.FLAGS({ attributes: flagsAttributes });
549
+ }
550
+ return {
551
+ next: () => {},
552
+ response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
553
+ };
554
+ },
555
+ emit: () => {}
556
+ });
557
+
558
+ // Empty attributes (server sent `* FLAGS` with no list): bail on the length guard.
559
+ const emptyResult = await selectCommand(makeConn([]), 'INBOX');
560
+ test.ok(emptyResult);
561
+ test.equal(emptyResult.flags, undefined);
562
+
563
+ // attributes[0] present but not an array: bail on the Array.isArray guard.
564
+ const nonArrayResult = await selectCommand(makeConn([{ value: '\\Seen' }]), 'INBOX');
565
+ test.ok(nonArrayResult);
566
+ test.equal(nonArrayResult.flags, undefined);
567
+
568
+ test.done();
569
+ };
570
+
571
+ // ============================================================================
572
+ // list.js — multiple branches.
573
+ // ============================================================================
574
+
575
+ const listCommand = require('../lib/commands/list');
576
+
577
+ // list.js line 47 branch: statusQuery key with a falsy value -> `return;`.
578
+ module.exports['Branches: list statusQuery with falsy value is skipped'] = async test => {
579
+ const connection = createMockConnection({
580
+ state: 2,
581
+ capabilities: new Map([
582
+ ['LIST-STATUS', true],
583
+ ['SPECIAL-USE', true]
584
+ ]),
585
+ exec: async (cmd, attrs, opts) => {
586
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
587
+ await opts.untagged.LIST({
588
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
589
+ });
590
+ }
591
+ return { next: () => {} };
592
+ }
593
+ });
594
+
595
+ // messages:false is skipped (line 47), unseen:true is kept
596
+ const result = await listCommand(connection, '', '*', {
597
+ statusQuery: { messages: false, unseen: true }
598
+ });
599
+ test.ok(Array.isArray(result));
600
+ test.done();
601
+ };
602
+
603
+ // list.js lines 128/129 (LIST untagged attributes[2] missing -> '' fallbacks),
604
+ // 162 (parentPath '' when no delimiter) and 163 (parent [entry.path] when no
605
+ // delimiter). LIST entry with a NIL delimiter and no name attribute.
606
+ module.exports['Branches: list LIST entry without delimiter or name uses fallbacks'] = async test => {
607
+ const connection = createMockConnection({
608
+ state: 2,
609
+ exec: async (cmd, attrs, opts) => {
610
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
611
+ // attributes[1] (delimiter) is NIL (null) and attributes[2] (name) missing
612
+ await opts.untagged.LIST({
613
+ attributes: [[{ value: '\\Noselect' }], null]
614
+ });
615
+ }
616
+ return { next: () => {} };
617
+ }
618
+ });
619
+
620
+ const result = await listCommand(connection, '', '*');
621
+ test.ok(Array.isArray(result));
622
+ test.equal(result.length, 1);
623
+ // path defaulted to '' (no attributes[2]); no delimiter -> parentPath '' and parent ['']
624
+ test.equal(result[0].path, '');
625
+ test.equal(result[0].parentPath, '');
626
+ test.done();
627
+ };
628
+
629
+ // list.js line 184 (`: false` when STATUS attributes[1] not an array) and
630
+ // line 185 (`if (!statusList || !statusPath) return;`).
631
+ module.exports['Branches: list STATUS with non-array values is ignored'] = async test => {
632
+ const connection = createMockConnection({
633
+ state: 2,
634
+ capabilities: new Map([
635
+ ['LIST-STATUS', true],
636
+ ['SPECIAL-USE', true]
637
+ ]),
638
+ exec: async (cmd, attrs, opts) => {
639
+ if (cmd === 'LIST' && opts && opts.untagged) {
640
+ if (opts.untagged.LIST) {
641
+ await opts.untagged.LIST({
642
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
643
+ });
644
+ }
645
+ if (opts.untagged.STATUS) {
646
+ // attributes[0] missing -> statusPath '' fallback (line 183); and
647
+ // attributes[1] is not an array -> statusList false -> early return (line 185)
648
+ await opts.untagged.STATUS({ attributes: [undefined, { value: 'notanarray' }] });
649
+ }
650
+ }
651
+ return { next: () => {} };
652
+ }
653
+ });
654
+
655
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
656
+ test.ok(Array.isArray(result));
657
+ // No status attached because the STATUS untagged response was ignored
658
+ const inbox = result.find(e => e.path === 'INBOX');
659
+ test.ok(inbox);
660
+ test.done();
661
+ };
662
+
663
+ // list.js line 203 (`: false` when STATUS key value is not a string) and
664
+ // line 206 (`if (!key || !entry || typeof entry.value !== 'string') return;`).
665
+ module.exports['Branches: list STATUS with non-string key/value pairs skipped'] = async test => {
666
+ const connection = createMockConnection({
667
+ state: 2,
668
+ capabilities: new Map([
669
+ ['LIST-STATUS', true],
670
+ ['SPECIAL-USE', true]
671
+ ]),
672
+ exec: async (cmd, attrs, opts) => {
673
+ if (cmd === 'LIST' && opts && opts.untagged) {
674
+ if (opts.untagged.LIST) {
675
+ await opts.untagged.LIST({
676
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
677
+ });
678
+ }
679
+ if (opts.untagged.STATUS) {
680
+ // key token has a non-string value (line 203 -> key = false),
681
+ // then the value token also has a non-string value (line 206 guard).
682
+ await opts.untagged.STATUS({
683
+ attributes: [{ value: 'INBOX' }, [{ value: 999 }, { value: 12 }]]
684
+ });
685
+ }
686
+ }
687
+ return { next: () => {} };
688
+ }
689
+ });
690
+
691
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
692
+ test.ok(Array.isArray(result));
693
+ test.done();
694
+ };
695
+
696
+ // list.js line 231 / 269 branches: `mailbox || ''` empty side. Call list with
697
+ // an undefined mailbox argument so both the LIST and LSUB calls take the ''
698
+ // fallback for the mailbox pattern.
699
+ module.exports['Branches: list with undefined mailbox pattern uses empty fallback'] = async test => {
700
+ let listMailboxArg;
701
+ let lsubMailboxArg;
702
+ const connection = createMockConnection({
703
+ state: 2,
704
+ exec: async (cmd, attrs, opts) => {
705
+ if (cmd === 'LIST') {
706
+ listMailboxArg = attrs[1];
707
+ if (opts && opts.untagged && opts.untagged.LIST) {
708
+ await opts.untagged.LIST({
709
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
710
+ });
711
+ }
712
+ }
713
+ if (cmd === 'LSUB') {
714
+ lsubMailboxArg = attrs[1];
715
+ }
716
+ return { next: () => {} };
717
+ }
718
+ });
719
+
720
+ // mailbox undefined -> `mailbox || ''` fallback used at lines 231 and 269
721
+ const result = await listCommand(connection, '', undefined);
722
+ test.ok(Array.isArray(result));
723
+ test.equal(listMailboxArg, '');
724
+ test.equal(lsubMailboxArg, '');
725
+ test.done();
726
+ };
727
+
728
+ // list.js lines 278/279 (LSUB attributes[2] missing -> '' fallbacks) and
729
+ // lines 293/294 (LSUB entry without delimiter -> parentPath '' / parent [path]).
730
+ module.exports['Branches: list LSUB entry without delimiter or name uses fallbacks'] = async test => {
731
+ const connection = createMockConnection({
732
+ state: 2,
733
+ exec: async (cmd, attrs, opts) => {
734
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
735
+ await opts.untagged.LIST({
736
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
737
+ });
738
+ }
739
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
740
+ // delimiter NIL and no name attribute -> '' fallbacks + no-delimiter parent logic
741
+ await opts.untagged.LSUB({
742
+ attributes: [[{ value: '\\HasNoChildren' }], null]
743
+ });
744
+ }
745
+ return { next: () => {} };
746
+ }
747
+ });
748
+
749
+ const result = await listCommand(connection, '', '*');
750
+ test.ok(Array.isArray(result));
751
+ test.done();
752
+ };
753
+
754
+ // list.js line 342 branch: `if (!a.specialUse && b.specialUse) return 1;`.
755
+ // To make V8's sort invoke the comparator with a non-special `a` and a
756
+ // special-use `b`, the special-use entry must come FIRST in insertion order,
757
+ // followed by several plain folders.
758
+ module.exports['Branches: list sort returns 1 when a lacks specialUse but b has it'] = async test => {
759
+ const connection = createMockConnection({
760
+ state: 2,
761
+ exec: async (cmd, attrs, opts) => {
762
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
763
+ // INBOX (name-based \\Inbox special use) emitted FIRST, then plain folders.
764
+ await opts.untagged.LIST({
765
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
766
+ });
767
+ await opts.untagged.LIST({
768
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'AAA' }]
769
+ });
770
+ await opts.untagged.LIST({
771
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'BBB' }]
772
+ });
773
+ }
774
+ return { next: () => {} };
775
+ }
776
+ });
777
+
778
+ const result = await listCommand(connection, '', '*');
779
+ test.ok(Array.isArray(result));
780
+ // INBOX (special use) sorts before the plain folders
781
+ test.equal(result[0].path, 'INBOX');
782
+ test.deepEqual(
783
+ result.slice(1).map(e => e.path),
784
+ ['AAA', 'BBB']
785
+ );
786
+ test.done();
787
+ };
788
+
789
+ // ============================================================================
790
+ // idle.js — NOOP fallback path (no IDLE capability).
791
+ // Covers line 216 (`case 'NOOP'`) and line 224 (`maxIdleTime ?` truthy side).
792
+ // We trigger one NOOP poll, then break the loop via preCheck().
793
+ // ============================================================================
794
+
795
+ const idleCommand = require('../lib/commands/idle');
796
+
797
+ module.exports['Branches: idle NOOP fallback runs poll then breaks via preCheck'] = async test => {
798
+ test.expect(1);
799
+ let noopCalled = false;
800
+ let resolveNoop;
801
+ let noopRan = new Promise(resolve => {
802
+ resolveNoop = resolve;
803
+ });
804
+ const connection = createMockConnection({
805
+ state: 3,
806
+ capabilities: new Map([['IMAP4rev1', true]]), // no IDLE -> NOOP polling path
807
+ currentSelectCommand: { command: 'SELECT', arguments: [{ type: 'ATOM', value: 'INBOX' }] },
808
+ missingIdleCommand: 'NOOP',
809
+ exec: async cmd => {
810
+ if (cmd === 'NOOP') {
811
+ noopCalled = true;
812
+ resolveNoop();
813
+ }
814
+ return { next: () => {} };
815
+ }
816
+ });
817
+
818
+ // maxIdleTime provided -> exercises `maxIdleTime ? Math.min(...) : NOOP_INTERVAL` truthy side
819
+ const idlePromise = idleCommand(connection, 60000);
820
+
821
+ // Wait until the first NOOP poll has actually run, then cross a macrotask
822
+ // boundary so the loop's .then() has armed its next-poll setTimeout before we
823
+ // break out. This guarantees preCheck()'s clearTimeout clears the armed timer,
824
+ // so no 60s timer leaks and the poll loop stops deterministically.
825
+ await noopRan;
826
+ await new Promise(resolve => setImmediate(resolve));
827
+ await connection.preCheck();
828
+
829
+ await idlePromise;
830
+ test.equal(noopCalled, true);
831
+ test.done();
832
+ };
833
+
834
+ // ============================================================================
835
+ // idle.js — IDLE path with currentLock set, exercising the `?.lockId` optional
836
+ // chaining truthy sides (lines 37, 62, 83, 101) and the cleanup while-loop
837
+ // (line 109) that drains preCheckWaitQueue. We request a break (preCheck)
838
+ // before the server acknowledges with the "+" continuation, so that the
839
+ // onPlusTag handler invokes preCheck() which sends DONE and drains the queue.
840
+ // ============================================================================
841
+
842
+ module.exports['Branches: idle IDLE path with currentLock drives preCheck and cleanup'] = async test => {
843
+ let doneWritten = false;
844
+ let onPlusTagFn = null;
845
+ let resolveExec = null;
846
+
847
+ // Deferred that resolves once runIdle has entered exec (preCheck registered).
848
+ let resolveEntered;
849
+ const execEntered = new Promise(resolve => {
850
+ resolveEntered = resolve;
851
+ });
852
+
853
+ const connection = createMockConnection({
854
+ state: 3,
855
+ capabilities: new Map([['IDLE', true]]),
856
+ currentLock: { lockId: 'lock-1' }, // makes `currentLock?.lockId` take the defined (truthy) side
857
+ write: data => {
858
+ if (data === 'DONE') {
859
+ doneWritten = true;
860
+ }
861
+ },
862
+ exec: (cmd, attrs, opts) => {
863
+ // Capture onPlusTag, signal entry, and keep IDLE pending until resolved below.
864
+ onPlusTagFn = opts.onPlusTag;
865
+ resolveEntered();
866
+ return new Promise(resolve => {
867
+ resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
868
+ });
869
+ }
870
+ });
871
+
872
+ const idlePromise = idleCommand(connection);
873
+
874
+ await execEntered;
875
+
876
+ // Request an IDLE break before the "+" continuation. canEnd is still false,
877
+ // so DONE is not sent yet; a waiter is queued in preCheckWaitQueue.
878
+ const breakPromise = typeof connection.preCheck === 'function' ? connection.preCheck() : Promise.resolve();
879
+
880
+ // Simulate the server "+" continuation. doneRequested is already true, so
881
+ // onPlusTag sets canEnd and calls preCheck(), which writes DONE and drains the
882
+ // queue (covers the `?.lockId` truthy sides and the preCheckWaitQueue drain).
883
+ if (onPlusTagFn) {
884
+ await onPlusTagFn();
885
+ }
886
+
887
+ // Let the IDLE exec resolve so runIdle proceeds to its cleanup block.
888
+ if (resolveExec) {
889
+ resolveExec();
890
+ }
891
+
892
+ await breakPromise;
893
+ await idlePromise;
894
+
895
+ test.equal(doneWritten, true);
896
+ test.done();
897
+ };
898
+
899
+ // ============================================================================
900
+ // idle.js — lines 109-112: the cleanup-block while-loop that drains
901
+ // preCheckWaitQueue. This runs when a break was requested but DONE was never
902
+ // sent (server never sent the "+" continuation, so canEnd stayed false and
903
+ // preCheck remained registered). When exec then resolves, runIdle's cleanup
904
+ // finds preCheck === connectionPreCheck and drains the still-queued waiters.
905
+ // ============================================================================
906
+
907
+ module.exports['Branches: idle cleanup drains queued waiters when DONE never sent'] = async test => {
908
+ let onPlusTagFn = null;
909
+ let resolveExec = null;
910
+ let resolveEntered;
911
+ const execEntered = new Promise(resolve => {
912
+ resolveEntered = resolve;
913
+ });
914
+
915
+ const connection = createMockConnection({
916
+ state: 3,
917
+ capabilities: new Map([['IDLE', true]]),
918
+ currentLock: { lockId: 'lock-9' }, // exercises `currentLock?.lockId` truthy side in cleanup trace
919
+ write: () => {}, // would only be called if DONE were sent; it never is here
920
+ exec: (cmd, attrs, opts) => {
921
+ onPlusTagFn = opts.onPlusTag;
922
+ resolveEntered();
923
+ return new Promise(resolve => {
924
+ resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
925
+ });
926
+ }
927
+ });
928
+
929
+ const idlePromise = idleCommand(connection);
930
+ await execEntered;
931
+
932
+ // Request a break: canEnd is still false (no "+" continuation yet), so DONE is
933
+ // NOT sent and connection.preCheck stays registered. The waiter is queued.
934
+ const breakPromise = connection.preCheck();
935
+
936
+ // Resolve exec WITHOUT invoking onPlusTag, so runIdle reaches its cleanup block
937
+ // with preCheck still === connectionPreCheck and the queue non-empty -> drains it.
938
+ test.ok(typeof onPlusTagFn === 'function');
939
+ resolveExec();
940
+
941
+ await breakPromise; // resolves only if the cleanup drain ran
942
+ await idlePromise;
943
+ test.done();
944
+ };
945
+
946
+ // ============================================================================
947
+ // idle.js — line 88 catch: the onPlusTag handler wraps `await preCheck()` in a
948
+ // try/catch. We make connection.write('DONE') throw so that preCheck rejects and
949
+ // the catch on line 88 logs the error. A break is requested before the "+"
950
+ // continuation so that onPlusTag invokes preCheck() (which calls write).
951
+ // ============================================================================
952
+
953
+ module.exports['Branches: idle onPlusTag catches preCheck error when DONE write throws'] = async test => {
954
+ let onPlusTagFn = null;
955
+ let resolveExec = null;
956
+ let resolveEntered;
957
+ const execEntered = new Promise(resolve => {
958
+ resolveEntered = resolve;
959
+ });
960
+
961
+ const connection = createMockConnection({
962
+ state: 3,
963
+ capabilities: new Map([['IDLE', true]]),
964
+ write: data => {
965
+ if (data === 'DONE') {
966
+ throw new Error('socket write failed');
967
+ }
968
+ },
969
+ exec: (cmd, attrs, opts) => {
970
+ onPlusTagFn = opts.onPlusTag;
971
+ resolveEntered();
972
+ return new Promise(resolve => {
973
+ resolveExec = () => resolve({ next: () => {}, response: { attributes: [] } });
974
+ });
975
+ }
976
+ });
977
+
978
+ const idlePromise = idleCommand(connection);
979
+ await execEntered;
980
+
981
+ // Request a break before the "+" continuation: queues a waiter, sets
982
+ // doneRequested true, but canEnd is still false so write isn't called yet.
983
+ const breakPromise = connection.preCheck();
984
+
985
+ // Server "+" continuation: onPlusTag sets canEnd and calls preCheck(), which
986
+ // attempts connection.write('DONE') -> throws -> caught by the line 88 catch.
987
+ await onPlusTagFn();
988
+
989
+ // The break never resolves because DONE failed; resolve exec so IDLE completes,
990
+ // and detach the rejected break handler to avoid an unhandled rejection.
991
+ resolveExec();
992
+ breakPromise.catch(() => {});
993
+
994
+ await idlePromise;
995
+ test.done();
996
+ };
997
+
998
+ // ============================================================================
999
+ // authenticate.js — error paths in authOauth (line 88), authLogin (line 136)
1000
+ // and authPlain (line 173). Each calls handleAuthError when exec rejects.
1001
+ // We make exec reject so the catch -> handleAuthError -> throw path runs.
1002
+ // ============================================================================
1003
+
1004
+ const authenticateCommand = require('../lib/commands/authenticate');
1005
+
1006
+ module.exports['Branches: authenticate OAuth error path calls handleAuthError'] = async test => {
1007
+ const connection = createMockConnection({
1008
+ state: 1, // NOT_AUTHENTICATED
1009
+ servername: 'imap.example.com',
1010
+ capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
1011
+ exec: async () => {
1012
+ const err = new Error('auth failed');
1013
+ err.response = { attributes: [] };
1014
+ throw err;
1015
+ }
1016
+ });
1017
+
1018
+ test.expect(1);
1019
+ try {
1020
+ await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
1021
+ test.ok(false, 'should have thrown');
1022
+ } catch (err) {
1023
+ test.equal(err.authenticationFailed, true);
1024
+ }
1025
+ test.done();
1026
+ };
1027
+
1028
+ module.exports['Branches: authenticate PLAIN error path calls handleAuthError'] = async test => {
1029
+ const connection = createMockConnection({
1030
+ state: 1,
1031
+ capabilities: new Map([['AUTH=PLAIN', true]]),
1032
+ exec: async () => {
1033
+ const err = new Error('auth failed');
1034
+ err.response = { attributes: [] };
1035
+ throw err;
1036
+ }
1037
+ });
1038
+
1039
+ test.expect(1);
1040
+ try {
1041
+ await authenticateCommand(connection, 'user@example.com', { password: 'secret' });
1042
+ test.ok(false, 'should have thrown');
1043
+ } catch (err) {
1044
+ test.equal(err.authenticationFailed, true);
1045
+ }
1046
+ test.done();
1047
+ };
1048
+
1049
+ module.exports['Branches: authenticate LOGIN error path calls handleAuthError'] = async test => {
1050
+ const connection = createMockConnection({
1051
+ state: 1,
1052
+ capabilities: new Map([['AUTH=LOGIN', true]]), // no AUTH=PLAIN -> LOGIN chosen
1053
+ exec: async () => {
1054
+ const err = new Error('auth failed');
1055
+ err.response = { attributes: [] };
1056
+ throw err;
1057
+ }
1058
+ });
1059
+
1060
+ test.expect(1);
1061
+ try {
1062
+ await authenticateCommand(connection, 'user@example.com', { password: 'secret' });
1063
+ test.ok(false, 'should have thrown');
1064
+ } catch (err) {
1065
+ test.equal(err.authenticationFailed, true);
1066
+ }
1067
+ test.done();
1068
+ };