2 /* eslint-disable capitalized-comments, sonarjs/no-duplicate-string */
6 const assert
= require('assert');
7 const sinon
= require('sinon'); // eslint-disable-line node/no-unpublished-require
9 const Communication
= require('../../src/communication');
10 const Config
= require('../../config');
11 const Errors
= require('../../src/errors');
13 const stubDb
= require('../stub-db');
14 const stubLogger
= require('../stub-logger');
16 const noExpectedException
= 'did not get expected exception';
18 describe('Communication', function () {
19 let communication
, options
;
21 beforeEach(function () {
22 options
= new Config('test');
23 communication
= new Communication(stubLogger
, stubDb
, options
);
27 afterEach(function () {
31 it('instantiates', function () {
32 assert(communication
);
35 it('covers config value', function () {
36 options
.dingus
.selfBaseUrl
= undefined;
37 communication
= new Communication(stubLogger
, stubDb
, options
);
40 describe('_init', function () {
41 it('covers', async
function () {
42 await communication
._init();
43 await communication
._init();
44 assert(communication
.Got
);
45 assert(communication
.got
);
49 describe('_onRetry', function () {
50 it('covers', function () {
53 communication
._onRetry(error
, retryCount
);
54 assert(communication
.logger
.debug
.called
);
58 describe('userAgentString', function () {
59 it('has default behavior', function () {
60 const result
= Communication
.userAgentString();
62 assert(result
.length
> 30);
64 it('is settable', function () {
65 const result
= Communication
.userAgentString({
68 implementation: 'custom',
71 assert
.strictEqual(result
, 'myhub/9.9.9 (custom)');
73 it('covers branches', function () {
74 const result
= Communication
.userAgentString({
80 assert
.strictEqual(result
, 'myhub/9.9.9');
84 describe('generateChallenge', function () {
85 it('generates a thing', async
function () {
86 const result
= await Communication
.generateChallenge();
88 assert(result
.length
);
92 describe('signature', function () {
93 let message
, secret
, algorithm
, expected
;
94 beforeEach(function () {
95 message
= 'Jackdaws love my big sphinx of quartz.';
96 secret
= 'secretsecret';
98 expected
= 'sha256=ee92148d9cd043cdfb8da7cf5ee1897abaafdb5ab840e85010abd4bf235fa31e';
100 it('signs a thing', function () {
101 const result
= Communication
.signature(message
, secret
, algorithm
);
102 assert
.strictEqual(result
, expected
);
106 describe('contentHash', function () {
107 let content
, algorithm
, expected
;
108 beforeEach(function () {
109 content
= 'Jived fox nymph grabs quick waltz.';
110 algorithm
= 'sha256';
111 expected
= '6e5e1a93bde78910b0d7c5fd8aba393294d4eca5d3fbf2bfd49100df3d5cc85d';
113 it('hashes', function () {
114 const result
= Communication
.contentHash(content
, algorithm
);
115 assert
.strictEqual(result
, expected
);
119 describe('verificationProcess', function () {
120 const challenge
= 'a_challenge';
121 let dbCtx
, callback
, requestId
, topicId
;
122 let topic
, verification
;
123 beforeEach(function () {
125 callback
= 'https://example.com/callback/?id=123';
126 requestId
= '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
127 topicId
= '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
130 url: 'https://example.com/blog/',
137 isPublisherValidated: true,
138 leaseSeconds: 864000,
141 sinon
.stub(Communication
, 'generateChallenge').resolves(challenge
);
142 sinon
.stub(communication
, 'publisherValidate').resolves(true);
143 sinon
.stub(communication
, 'got').resolves({
147 'content-type': 'text/plain',
152 communication
.db
.verificationGetById
.resolves(verification
);
153 communication
.db
.topicGetById
.resolves(topic
);
154 communication
.db
.verificationRelease
.resolves({});
155 communication
.db
.verificationUpdate
.resolves({});
156 communication
.db
.verificationIncomplete
.resolves({});
157 communication
.db
.verificationComplete
.resolves({});
160 it('errors on non-existent verification', async
function () {
161 communication
.db
.verificationGetById
.restore();
162 sinon
.stub(communication
.db
, 'verificationGetById').resolves();
165 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
166 assert
.fail(noExpectedException
);
168 assert(e
instanceof Errors
.InternalInconsistencyError
);
172 it('errors on non-existent topic', async
function () {
173 communication
.db
.topicGetById
.restore();
174 sinon
.stub(communication
.db
, 'topicGetById').resolves();
177 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
178 assert
.fail(noExpectedException
);
180 assert(e
instanceof Errors
.InternalInconsistencyError
);
184 it('skips inactive topic', async
function () {
185 communication
.db
.topicGetById
.restore();
186 topic
.isActive
= false;
187 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
189 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
191 assert(communication
.db
.verificationRelease
.called
);
192 assert(!communication
.got
.called
);
195 it('denies subscription to deleted topic', async
function () {
196 communication
.db
.topicGetById
.restore();
197 topic
.isDeleted
= true;
198 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
200 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
202 assert(communication
.db
.verificationUpdate
.called
);
203 assert
.strictEqual(verification
.mode
, 'denied');
206 it('checks publisher validation if needed', async
function() {
207 communication
.db
.verificationGetById
.restore();
208 verification
.isPublisherValidated
= false;
209 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
210 communication
.db
.topicGetById
.restore();
211 topic
.publisherValidationUrl
= 'https://example.com/publisher/';
212 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
214 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
216 assert(communication
.publisherValidate
.called
);
217 assert(communication
.db
.verificationComplete
.called
);
220 it('handles publisher validation failure', async
function() {
221 communication
.db
.verificationGetById
.restore();
222 verification
.isPublisherValidated
= false;
223 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
224 communication
.db
.topicGetById
.restore();
225 topic
.publisherValidationUrl
= 'https://example.com/publisher/';
226 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
227 communication
.publisherValidate
.restore();
228 sinon
.stub(communication
, 'publisherValidate').resolves(false);
230 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
232 assert(communication
.publisherValidate
.called
);
233 assert(communication
.db
.verificationIncomplete
.called
);
236 it('handles request error', async
function () {
237 communication
.got
.restore();
238 sinon
.stub(communication
, 'got').rejects(new Error());
240 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
242 assert(communication
.db
.verificationIncomplete
.called
);
245 it('handles 500 response', async
function () {
246 communication
.got
.restore();
247 sinon
.stub(communication
, 'got').resolves({
251 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
253 assert(communication
.db
.verificationIncomplete
.called
);
256 it('handles non-200 response', async
function () {
257 communication
.got
.restore();
258 sinon
.stub(communication
, 'got').resolves({
262 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
264 assert(communication
.db
.verificationComplete
.called
);
267 it('subscription succeeds', async
function () {
268 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
270 assert(communication
.db
.subscriptionUpsert
.called
);
271 assert(communication
.db
.verificationComplete
.called
);
274 it('unsubscription succeeds', async
function () {
275 communication
.db
.verificationGetById
.restore();
276 verification
.mode
= 'unsubscribe';
277 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
279 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
281 assert(communication
.db
.subscriptionDelete
.called
);
282 assert(communication
.db
.verificationComplete
.called
);
285 it('unsubscription from deleted topic deletes topic', async
function () {
286 communication
.db
.verificationGetById
.restore();
287 verification
.mode
= 'unsubscribe';
288 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
289 communication
.db
.topicGetById
.restore();
290 sinon
.stub(communication
.db
, 'topicGetById').resolves({
295 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
297 assert(communication
.db
.subscriptionDelete
.called
);
298 assert(communication
.db
.verificationComplete
.called
);
299 assert(communication
.db
.topicPendingDelete
.called
);
302 it('unsubscription denial succeeds', async
function () {
303 communication
.db
.verificationGetById
.restore();
304 verification
.mode
= 'unsubscribe';
305 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
306 communication
.got
.restore();
307 sinon
.stub(communication
, 'got').resolves({
311 'content-type': 'text/plain',
313 body: 'not the challenge',
316 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
318 assert(!communication
.db
.subscriptionDelete
.called
);
319 assert(communication
.db
.verificationComplete
.called
);
322 it('does not handle strange mode', async
function() {
323 communication
.db
.verificationGetById
.restore();
324 verification
.mode
= 'flarp';
325 sinon
.stub(communication
.db
, 'verificationGetById').resolves(verification
);
328 await communication
.verificationProcess(dbCtx
, callback
, topicId
, requestId
);
329 assert
.fail(noExpectedException
);
331 assert(e
instanceof Errors
.InternalInconsistencyError
);
334 }); // verificationProcess
336 describe('publisherValidate', function () {
337 let dbCtx
, topic
, verification
;
338 beforeEach(function () {
341 url: 'https://example.com/topic/',
342 publisherValidationUrl: 'https://example.com/pub_valid/',
345 callback: 'https://exmaple.com/callback/?id=123',
346 httpFrom: 'user@example.com',
347 httpRemoteAddr: '127.0.0.0',
350 sinon
.stub(communication
, 'got').resolves({
354 'content-type': 'application/json',
358 communication
.db
.verificationIncomplete
.resolves();
359 communication
.db
.verificationUpdate
.resolves();
360 communication
.db
.verificationValidated
.resolves();
363 it('succeeds', async
function () {
364 const result
= await communication
.publisherValidate(dbCtx
, topic
, verification
);
366 assert(communication
.db
.verificationValidated
.called
);
367 assert
.strictEqual(result
, true);
370 it('succeeds with rejection', async
function () {
371 communication
.got
.restore();
372 sinon
.stub(communication
, 'got').resolves({
374 statusMessage: 'Bad Request',
376 'content-type': 'application/json',
380 const result
= await communication
.publisherValidate(dbCtx
, topic
, verification
);
382 assert(communication
.db
.verificationValidated
.called
);
383 assert(communication
.db
.verificationUpdate
.called
);
384 assert
.strictEqual(result
, true);
387 it('defers on request server error', async
function () {
388 communication
.got
.restore();
389 sinon
.stub(communication
, 'got').resolves({
391 statusMessage: 'Bad Gateway',
393 'content-type': 'text/plain',
397 const result
= await communication
.publisherValidate(dbCtx
, topic
, verification
);
399 assert
.strictEqual(result
, false);
402 it('handles request error', async
function () {
403 communication
.got
.restore();
404 sinon
.stub(communication
, 'got').rejects(new Error());
406 const result
= await communication
.publisherValidate(dbCtx
, topic
, verification
);
408 assert
.strictEqual(result
, false);
411 }); // publisherValidate
413 describe('topicFetchProcess', function () {
414 let dbCtx
, topic
, requestId
, topicId
;
416 beforeEach(function () {
419 url: 'https://example.com/topic/',
421 contentHashAlgorithm: 'sha512',
423 requestId
= '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
424 topicId
= '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
426 sinon
.stub(communication
, 'got').resolves({
430 'content-type': 'text/plain',
431 link: '<https://example.com/hub/>; rel="hub"',
432 'last-modified': 'Thu, 18 Nov 2021 20:34:35 GMT',
433 'etag': '"9c104-1673e-5d1161636d742"',
435 body: 'Jackdaws love my big sphinx of quartz.',
438 communication
.db
.topicGetById
.resolves(topic
);
441 it('requires topic exists', async
function () {
442 communication
.db
.topicGetById
.restore();
443 sinon
.stub(communication
.db
, 'topicGetById').resolves();
446 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
447 assert
.fail(noExpectedException
);
449 assert(e
instanceof Errors
.InternalInconsistencyError
);
453 it ('skips deleted topic', async
function () {
454 communication
.db
.topicGetById
.restore();
455 topic
.isDeleted
= true;
456 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
458 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
460 assert(!communication
.got
.called
);
463 it('handles request error', async
function () {
464 communication
.got
.restore();
465 sinon
.stub(communication
, 'got').rejects(new Error());
467 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
469 assert(communication
.db
.topicFetchIncomplete
.called
);
472 it('handles 500 response', async
function () {
473 communication
.got
.restore();
474 sinon
.stub(communication
, 'got').resolves({
476 statusMessage: 'Internal Server Error',
478 'content-type': 'text/plain',
482 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
484 assert(communication
.db
.topicFetchIncomplete
.called
);
487 it('handles bad response', async
function () {
488 communication
.got
.restore();
489 sinon
.stub(communication
, 'got').resolves({
491 statusMessage: 'Not Found',
493 'content-type': 'text/plain',
497 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
499 assert(communication
.db
.topicFetchIncomplete
.called
);
502 it('recognizes unchanged content', async
function () {
503 communication
.db
.topicGetById
.restore();
504 topic
.contentHash
= 'a630999c61738f3e066d79a1b299a295c5d0598c173e0904d04a707d43988e3e81660bfc1b1779377f4ec26f837d1bb31fa2b860c9ad2d37495d83de32647fea';
505 sinon
.stub(communication
.db
, 'topicGetById').resolves(topic
);
507 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
509 assert(communication
.db
.topicFetchComplete
.called
);
510 assert(!communication
.db
.topicSetContent
.called
);
513 it('recognizes 304 response', async
function () {
514 topic
.httpLastModified
= 'Thu, 18 Nov 2021 20:34:35 GMT';
515 topic
.httpEtag
= '"9c104-1673e-5d1161636d742"';
516 communication
.db
.topicGetById
.resolves(topic
);
517 communication
.got
.resolves({
521 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
523 assert(communication
.db
.topicFetchComplete
.called
);
524 assert(!communication
.db
.topicSetContent
.called
);
527 it('updates content', async
function () {
528 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
530 assert(communication
.db
.topicFetchComplete
.called
);
531 assert(communication
.db
.topicSetContent
.called
);
534 it('updates content with lax link enforcement', async
function () {
535 communication
.got
.restore();
536 sinon
.stub(communication
, 'got').resolves({
540 'content-type': 'text/plain',
541 link: '<https://example.com/other/hub/>; rel="hub"',
543 body: 'Jackdaws love my big sphinx of quartz.',
546 communication
.options
.communication
.strictTopicHubLink
= false;
548 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
550 assert(communication
.db
.topicFetchComplete
.called
);
551 assert(communication
.db
.topicSetContent
.called
);
554 it('deletes topic when hub relation unsatisfied', async
function () {
555 communication
.got
.restore();
556 sinon
.stub(communication
, 'got').resolves({
560 'content-type': 'text/plain',
561 link: '<https://example.com/other/hub/>; rel="hub"',
563 body: 'Jackdaws love my big sphinx of quartz.',
566 await communication
.topicFetchProcess(dbCtx
, topicId
, requestId
);
568 assert(communication
.db
.topicFetchComplete
.called
);
569 assert(communication
.db
.topicDeleted
.called
);
571 }); // topicFetchProcess
573 describe('subscriptionDeliveryProcess', function () {
574 let dbCtx
, requestId
, topic
, topicId
, subscription
, subscriptionId
;
576 beforeEach(function () {
579 url: 'https://example.com/topic/',
581 contentHashAlgorithm: 'sha512',
582 content: 'Jackdaws love my big sphinx of quartz.',
584 requestId
= '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
585 topicId
= '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
586 subscriptionId
= 'c5e6a3ac-dab8-11eb-b758-0025905f714a';
589 callback: 'https://example.com/callback/123',
590 secret: 'superdupersecret',
591 signatureAlgorithm: 'sha512',
594 sinon
.stub(communication
, 'got').resolves({
598 'content-type': 'text/plain',
600 body: 'Jackdaws love my big sphinx of quartz.',
603 communication
.db
.topicGetContentById
.resolves(topic
);
604 communication
.db
.subscriptionGetById
.resolves(subscription
);
607 it('requires subscription to exist', async
function () {
608 communication
.db
.subscriptionGetById
.restore();
609 sinon
.stub(communication
.db
, 'subscriptionGetById').resolves();
611 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
612 assert
.fail(noExpectedException
);
614 assert(e
instanceof Errors
.InternalInconsistencyError
);
618 it('requires topic to exist', async
function () {
619 communication
.db
.topicGetContentById
.restore();
620 sinon
.stub(communication
.db
, 'topicGetContentById').resolves();
622 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
623 assert
.fail(noExpectedException
);
625 assert(e
instanceof Errors
.InternalInconsistencyError
);
629 it('succeeds', async
function () {
630 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
632 assert(communication
.db
.subscriptionDeliveryComplete
.called
);
635 it('handles request error', async
function () {
636 communication
.got
.restore();
637 sinon
.stub(communication
, 'got').throws();
639 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
641 assert(communication
.db
.subscriptionDeliveryIncomplete
.called
);
644 it('handles 5xx response', async
function () {
645 communication
.got
.restore();
646 sinon
.stub(communication
, 'got').resolves({
648 statusMessage: 'Internal Server Error',
650 'content-type': 'text/plain',
654 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
656 assert(communication
.db
.subscriptionDeliveryIncomplete
.called
);
659 it('handles 4xx response', async
function () {
660 communication
.got
.restore();
661 sinon
.stub(communication
, 'got').resolves({
663 statusMessage: 'Not Found',
665 'content-type': 'text/plain',
669 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
671 assert(communication
.db
.subscriptionDeliveryIncomplete
.called
);
674 it('handles 410 response', async
function () {
675 communication
.got
.restore();
676 sinon
.stub(communication
, 'got').resolves({
678 statusMessage: 'Gone',
680 'content-type': 'text/plain',
684 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
686 assert(communication
.db
.subscriptionDeliveryGone
.called
);
689 it('unsubscribes when topic is deleted', async
function () {
690 topic
.isDeleted
= true;
691 communication
.db
.topicGetContentById
.restore();
692 sinon
.stub(communication
.db
, 'topicGetContentById').resolves(topic
);
694 await communication
.subscriptionDeliveryProcess(dbCtx
, subscriptionId
, requestId
);
696 assert(communication
.db
.verificationInsert
.called
);
697 assert(communication
.db
.subscriptionDeliveryComplete
.called
);
699 }); // subscriptionDeliveryProcess
701 describe('topicFetchClaimAndProcessById', function () {
702 let dbCtx
, topicId
, requestId
;
703 beforeEach(function () {
705 requestId
= '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
706 topicId
= '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
707 sinon
.stub(communication
, 'topicFetchProcess');
709 it('covers claim', async
function () {
710 communication
.db
.topicFetchClaimById
.resolves({
713 await communication
.topicFetchClaimAndProcessById(dbCtx
, topicId
, requestId
);
714 assert(communication
.topicFetchProcess
.called
);
716 it('covers no claim', async
function () {
717 communication
.db
.topicFetchClaimById
.resolves({
720 await communication
.topicFetchClaimAndProcessById(dbCtx
, topicId
, requestId
);
721 assert(!communication
.topicFetchProcess
.called
);
723 }); // topicFetchClaimAndProcessById
725 describe('verificationClaimAndProcessById', function () {
726 let dbCtx
, verificationId
, requestId
;
727 beforeEach(function () {
729 verificationId
= '28488311-6652-42ea-9839-7bbc42b246cb';
730 requestId
= '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
731 sinon
.stub(communication
, 'verificationProcess');
733 it('covers claim', async
function () {
734 communication
.db
.verificationClaimById
.resolves({
737 await communication
.verificationClaimAndProcessById(dbCtx
, verificationId
, requestId
);
738 assert(communication
.verificationProcess
.called
);
740 it('covers no claim', async
function () {
741 communication
.db
.verificationClaimById
.resolves({
744 await communication
.verificationClaimAndProcessById(dbCtx
, verificationId
, requestId
);
745 assert(!communication
.verificationProcess
.called
);
747 }); // verificationClaimAndProcessById
749 describe('workFeed', function () {
751 beforeEach(function () {
753 sinon
.stub(communication
, 'topicFetchProcess');
754 sinon
.stub(communication
, 'verificationProcess');
755 sinon
.stub(communication
, 'subscriptionDeliveryProcess');
757 it('succeeds', async
function () {
758 const topicIds
= [ { id: '' }, { id: '' } ];
759 communication
.db
.topicFetchClaim
.resolves(topicIds
);
760 const verificationIds
= [ { id: '' }, { id: '' } ];
761 communication
.db
.verificationClaim
.resolves(verificationIds
);
762 const subscriptionIds
= [ { id: '' }, { id: '' } ];
763 communication
.db
.subscriptionDeliveryClaim
.resolves(subscriptionIds
);
764 const expectedLength
= [topicIds
, verificationIds
, subscriptionIds
].map((x
) => x
.length
).reduce((a
, b
) => a
+ b
, 0);
767 const result
= await communication
.workFeed(stubCtx
, wanted
);
769 assert
.strictEqual(result
.length
, expectedLength
);
771 it('covers no wanted work', async
function () {
773 const result
= await communication
.workFeed(stubCtx
, wanted
);
774 assert
.strictEqual(result
.length
, 0);
775 assert(!communication
.db
.topicFetchClaim
.called
);
776 assert(!communication
.db
.verificationClaim
.called
);
777 assert(!communication
.db
.subscriptionDeliveryClaim
.called
);
779 it('deals with failure', async
function () {
780 const topicIds
= [ { id: '' }, { id: '' } ];
781 communication
.db
.topicFetchClaim
.resolves(topicIds
);
782 communication
.db
.verificationClaim
.throws();
783 const expectedLength
= topicIds
.length
;
786 const result
= await communication
.workFeed(stubCtx
, wanted
);
788 assert
.strictEqual(result
.length
, expectedLength
);