sharedb 6.0.4 → 7.0.0

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.
@@ -6,6 +6,7 @@ var presenceTestType = require('./presence-test-type');
6
6
  var errorHandler = require('../../util').errorHandler;
7
7
  var PresencePauser = require('./presence-pauser');
8
8
  var sinon = require('sinon');
9
+ var ACTIONS = require('../../../lib/message-actions').ACTIONS;
9
10
  types.register(presenceTestType.type);
10
11
 
11
12
  describe('DocPresence', function() {
@@ -971,6 +972,59 @@ describe('DocPresence', function() {
971
972
  ], done);
972
973
  });
973
974
 
975
+ it('emits an error and drops stale remote presence that cannot be transformed', function(done) {
976
+ var localPresence1 = presence1.create('presence-1');
977
+ var catchUpCount = 0;
978
+
979
+ function sendUntypedPresenceAtVersion(version) {
980
+ var message = localPresence1._message();
981
+ message.d = null;
982
+ message.p = {index: 0};
983
+ message.v = version;
984
+ connection1.send(message);
985
+ }
986
+
987
+ async.series([
988
+ presence2.subscribe.bind(presence2),
989
+ function(next) {
990
+ connection2.on('send', function(message) {
991
+ if (message.a !== ACTIONS.presenceRequest) return;
992
+ catchUpCount++;
993
+ if (catchUpCount === 1) next();
994
+ });
995
+ sendUntypedPresenceAtVersion(0);
996
+ },
997
+ function(next) {
998
+ doc2.once('op', function() {
999
+ next();
1000
+ });
1001
+ doc1.submitOp({index: 5, value: 'ern'}, errorHandler(done));
1002
+ },
1003
+ function(next) {
1004
+ presence2.once('error', function(error) {
1005
+ expect(error).to.be.an.instanceOf(Error);
1006
+ expect(error.code).to.equal('ERR_DOC_TYPE_NOT_RECOGNIZED');
1007
+ next();
1008
+ });
1009
+ sendUntypedPresenceAtVersion(1);
1010
+ },
1011
+ function(next) {
1012
+ doc2.once('op', function() {
1013
+ next();
1014
+ });
1015
+ doc1.submitOp({index: 0, value: 'The'}, errorHandler(done));
1016
+ },
1017
+ function(next) {
1018
+ presence2.once('receive', function(id, presence) {
1019
+ expect(presence).to.eql({index: 1});
1020
+ expect(catchUpCount).to.equal(1);
1021
+ next();
1022
+ });
1023
+ localPresence1.submit({index: 1}, errorHandler(done));
1024
+ }
1025
+ ], done);
1026
+ });
1027
+
974
1028
  it('sends null presence when the doc is destroyed', function(done) {
975
1029
  var localPresence1 = presence1.create('presence-1');
976
1030
 
@@ -486,6 +486,54 @@ describe('Presence', function() {
486
486
  ], done);
487
487
  });
488
488
 
489
+ it('does not leak Streams when subscribing the same presence multiple times in parallel', function(done) {
490
+ var streamsCount = backend.pubsub.streamsCount;
491
+ async.series([
492
+ function(next) {
493
+ presence1.subscribe();
494
+ // Trick it into sending a duplicate request
495
+ presence1.wantSubscribe = false;
496
+ presence1.subscribe(next);
497
+ },
498
+ function(next) {
499
+ expect(backend.pubsub.streamsCount).to.equal(streamsCount + 1);
500
+ next();
501
+ },
502
+ presence1.unsubscribe.bind(presence1),
503
+ function(next) {
504
+ expect(backend.pubsub.streamsCount).to.equal(streamsCount);
505
+ next();
506
+ }
507
+ ], done);
508
+ });
509
+
510
+ it('does not broadcast a disconnect when subscribing the same presence multiple times in parallel', function(done) {
511
+ var localPresence1 = presence1.create('presence-1');
512
+
513
+ async.series([
514
+ presence2.subscribe.bind(presence2),
515
+ function(next) {
516
+ localPresence1.submit({index: 1}, errorHandler(done));
517
+ presence2.once('receive', function() {
518
+ next();
519
+ });
520
+ },
521
+ function(next) {
522
+ presence1.subscribe();
523
+ // Trick it into sending a duplicate request
524
+ presence1.wantSubscribe = false;
525
+ presence1.subscribe(next);
526
+ },
527
+ function(next) {
528
+ localPresence1.submit({index: 2}, errorHandler(done));
529
+ presence2.once('receive', function(id, presence) {
530
+ expect(presence).to.eql({index: 2});
531
+ next();
532
+ });
533
+ }
534
+ ], done);
535
+ });
536
+
489
537
  it('throws an error when trying to create a presence with a non-string ID', function() {
490
538
  expect(function() {
491
539
  presence1.create(123);
@@ -127,11 +127,13 @@ module.exports = function(options) {
127
127
 
128
128
  it('does not duplicate messages', function(done) {
129
129
  var connection = this.backend.connect();
130
+ this.backend.db.pollInterval = 0;
131
+ this.backend.db.pollDebounce = 0;
130
132
  var count = 0;
131
133
  var query = connection.createSubscribeQuery(
132
134
  'dogs',
133
135
  this.matchAllDbQuery,
134
- {pollInterval: 0, pollDebounce: 0},
136
+ null,
135
137
  function(err) {
136
138
  if (err) return done(err);
137
139
  connection.get('dogs', '1').on('error', done).create({});
@@ -519,13 +521,17 @@ function commonTests(options) {
519
521
  });
520
522
  });
521
523
 
522
- it('pollDebounce option reduces subsequent poll interval', function(done) {
524
+ it('query middleware pollDebounce option reduces subsequent poll interval', function(done) {
523
525
  var clock = util.useFakeTimers();
524
526
  var connection = this.backend.connect();
525
527
  this.backend.db.canPollDoc = function() {
526
528
  return false;
527
529
  };
528
- var query = connection.createSubscribeQuery('items', this.matchAllDbQuery, {pollDebounce: 2000});
530
+ this.backend.use('query', function(context, next) {
531
+ context.options.pollDebounce = 2000;
532
+ next();
533
+ });
534
+ var query = connection.createSubscribeQuery('items', this.matchAllDbQuery);
529
535
  query.on('error', done);
530
536
  var batchSizes = [];
531
537
  var total = 0;
@@ -618,21 +624,21 @@ function commonTests(options) {
618
624
  });
619
625
  });
620
626
 
621
- it('pollInterval updates a subscribed query after an unpublished create', function(done) {
627
+ it('query middleware pollInterval updates a subscribed query after an unpublished create', function(done) {
622
628
  var clock = util.useFakeTimers();
623
629
  var connection = this.backend.connect();
624
630
  this.backend.suppressPublish = true;
625
- var query = connection.createSubscribeQuery(
626
- 'dogs',
627
- this.matchAllDbQuery,
628
- {pollDebounce: 0, pollInterval: 50},
629
- function(err) {
630
- if (err) return done(err);
631
- connection.get('dogs', 'fido').on('error', done).create({}, function() {
632
- clock.tick(51);
633
- });
634
- }
635
- );
631
+ this.backend.use('query', function(context, next) {
632
+ context.options.pollDebounce = 0;
633
+ context.options.pollInterval = 50;
634
+ next();
635
+ });
636
+ var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) {
637
+ if (err) return done(err);
638
+ connection.get('dogs', 'fido').on('error', done).create({}, function() {
639
+ clock.tick(51);
640
+ });
641
+ });
636
642
  query.on('error', done);
637
643
  query.on('insert', function(docs) {
638
644
  expect(util.pluck(docs, 'id')).eql(['fido']);
@@ -659,13 +665,17 @@ function commonTests(options) {
659
665
  });
660
666
  });
661
667
 
662
- it('pollInterval captures additional unpublished creates', function(done) {
668
+ it('query middleware pollInterval captures additional unpublished creates', function(done) {
663
669
  var clock = util.useFakeTimers();
664
670
  var connection = this.backend.connect();
665
671
  this.backend.suppressPublish = true;
672
+ this.backend.use('query', function(context, next) {
673
+ context.options.pollInterval = 1000;
674
+ next();
675
+ });
666
676
  var count = 0;
667
677
 
668
- var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 1000}, function(err) {
678
+ var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) {
669
679
  if (err) return done(err);
670
680
  var doc = connection.get('dogs', count.toString()).on('error', done);
671
681
  doc.create({}, function(e) {
@@ -686,6 +696,34 @@ function commonTests(options) {
686
696
  clock.tick(1);
687
697
  });
688
698
 
699
+ it('does not pass client pollInterval or pollDebounce to query middleware', function(done) {
700
+ var connection = this.backend.connect();
701
+ var queryOptions;
702
+ this.backend.use('query', function(context, next) {
703
+ queryOptions = context.options;
704
+ next();
705
+ });
706
+ connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 1, pollDebounce: 0}, function(err) {
707
+ if (err) return done(err);
708
+ expect(queryOptions).not.to.have.any.keys('pollInterval', 'pollDebounce');
709
+ done();
710
+ });
711
+ });
712
+
713
+ it('ignores pollInterval from client query options', function(done) {
714
+ var clock = util.useFakeTimers();
715
+ var connection = this.backend.connect();
716
+ var db = this.backend.db;
717
+ db.pollInterval = 0;
718
+ sinon.spy(db, 'queryPoll');
719
+ connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) {
720
+ if (err) return done(err);
721
+ clock.tick(1000);
722
+ expect(db.queryPoll.called).to.be.false;
723
+ done();
724
+ });
725
+ });
726
+
689
727
  it('query extra is returned to client', function(done) {
690
728
  var connection = this.backend.connect();
691
729
  this.backend.db.query = function(collection, query, fields, options, callback) {
@@ -906,7 +944,7 @@ function commonTests(options) {
906
944
  connection.createSubscribeQuery(
907
945
  'dogs',
908
946
  this.matchAllDbQuery,
909
- {pollInterval: 0, pollDebounce: 0},
947
+ null,
910
948
  function(err) {
911
949
  if (err) {
912
950
  expect(err.message).to.be.equal('TEST_ERROR');
@@ -128,6 +128,17 @@ describe('SnapshotTimestampRequest', function() {
128
128
  ], done);
129
129
  });
130
130
 
131
+ it('fetches ops up to the current version when there are no milestones', function(done) {
132
+ sinon.spy(backend.db, 'getOps');
133
+ var halfwayBetweenDays2and3 = (day2 + day3) * 0.5;
134
+
135
+ backend.connect().fetchSnapshotByTimestamp('books', 'time-machine', halfwayBetweenDays2and3, function(error) {
136
+ if (error) return done(error);
137
+ expect(backend.db.getOps.calledWith('books', 'time-machine', 0, 3)).to.equal(true);
138
+ done();
139
+ });
140
+ });
141
+
131
142
  it('fetches the day 3 version when asking for a time after day 3', function(done) {
132
143
  var connection = backend.connect();
133
144
  async.waterfall([
@@ -229,8 +240,11 @@ describe('SnapshotTimestampRequest', function() {
229
240
  });
230
241
 
231
242
  it('returns an empty snapshot if trying to fetch a non-existent document', function(done) {
243
+ sinon.spy(backend.db, 'getOps');
244
+
232
245
  backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function(error, snapshot) {
233
246
  if (error) return done(error);
247
+ expect(backend.db.getOps.called).to.be.false;
234
248
  expect(snapshot).to.eql({
235
249
  id: 'does-not-exist',
236
250
  v: 0,
@@ -469,6 +483,7 @@ describe('SnapshotTimestampRequest', function() {
469
483
  it('fetches a snapshot that matches a milestone snapshot', function(done) {
470
484
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
471
485
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
486
+ sinon.spy(db, 'getOps');
472
487
 
473
488
  backendWithMilestones.connect()
474
489
  .fetchSnapshotByTimestamp('books', 'mocking-bird', day2, function(error, snapshot) {
@@ -476,6 +491,7 @@ describe('SnapshotTimestampRequest', function() {
476
491
 
477
492
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
478
493
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
494
+ expect(db.getOps.called).to.be.false;
479
495
 
480
496
  expect(snapshot.v).to.equal(2);
481
497
  expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lea'});
@@ -483,6 +499,27 @@ describe('SnapshotTimestampRequest', function() {
483
499
  });
484
500
  });
485
501
 
502
+ it('skips fetching ops when milestone times are out of version order', function(done) {
503
+ var milestoneAtVersion = function(version) {
504
+ return function(collection, id, _timestamp, callback) {
505
+ milestoneDb.getMilestoneSnapshot(collection, id, version, callback);
506
+ };
507
+ };
508
+ sinon.stub(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime').callsFake(milestoneAtVersion(4));
509
+ sinon.stub(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime').callsFake(milestoneAtVersion(2));
510
+ sinon.spy(db, 'getOps');
511
+ var halfwayBetweenDays3and4 = (day3 + day4) * 0.5;
512
+
513
+ backendWithMilestones.connect()
514
+ .fetchSnapshotByTimestamp('books', 'mocking-bird', halfwayBetweenDays3and4, function(error, snapshot) {
515
+ if (error) return done(error);
516
+ expect(db.getOps.called).to.be.false;
517
+ expect(snapshot.v).to.equal(4);
518
+ expect(snapshot.data).to.eql({title: 'To Kill a Mocking Bird', author: 'Harper Lee', year: 1959});
519
+ done();
520
+ });
521
+ });
522
+
486
523
  it('fetches a snapshot before any milestones', function(done) {
487
524
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrBeforeTime');
488
525
  sinon.spy(milestoneDb, 'getMilestoneSnapshotAtOrAfterTime');
@@ -513,7 +550,7 @@ describe('SnapshotTimestampRequest', function() {
513
550
 
514
551
  expect(milestoneDb.getMilestoneSnapshotAtOrBeforeTime.calledOnce).to.equal(true);
515
552
  expect(milestoneDb.getMilestoneSnapshotAtOrAfterTime.calledOnce).to.equal(true);
516
- expect(db.getOps.calledWith('books', 'mocking-bird', 4, null)).to.equal(true);
553
+ expect(db.getOps.calledWith('books', 'mocking-bird', 4, 5)).to.equal(true);
517
554
 
518
555
  expect(snapshot.v).to.equal(5);
519
556
  expect(snapshot.data).to.eql({
@@ -5,6 +5,7 @@ var types = require('../lib/types');
5
5
  var errorHandler = util.errorHandler;
6
6
  var ShareDBError = require('../lib/error');
7
7
  var sinon = require('sinon');
8
+ var crypto = require('crypto');
8
9
  var ACTIONS = require('../lib/message-actions').ACTIONS;
9
10
 
10
11
  var ERROR_CODE = ShareDBError.CODES;
@@ -63,6 +64,101 @@ describe('middleware', function() {
63
64
  done();
64
65
  });
65
66
  });
67
+
68
+ it('uses a clientId set on connect as the connection id and op src', function(done) {
69
+ var backend = this.backend;
70
+ backend.use('connect', function(request, next) {
71
+ request.agent.clientId = 'alice:1';
72
+ next();
73
+ });
74
+
75
+ var srcs = [];
76
+ backend.use('submit', function(request, next) {
77
+ srcs.push(request.op.src);
78
+ next();
79
+ });
80
+
81
+ backend.connect(null, null, function(connection) {
82
+ expect(connection.id).to.equal('alice:1');
83
+ connection.get('dogs', 'fido').create({age: 3}, function(error) {
84
+ if (error) return done(error);
85
+ expect(srcs).to.eql(['alice:1']);
86
+ done();
87
+ });
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('restricting op sources', function() {
93
+ beforeEach(function() {
94
+ function srcFor(userId) {
95
+ return userId + ':' + crypto.randomUUID();
96
+ }
97
+
98
+ function isSrcOf(userId, src) {
99
+ if (typeof src !== 'string') return false;
100
+ var separator = src.lastIndexOf(':');
101
+ return separator !== -1 && src.slice(0, separator) === String(userId);
102
+ }
103
+
104
+ this.backend.use('connect', function(context, next) {
105
+ var userId = context.req.userId;
106
+ context.agent.custom.userId = userId;
107
+ context.agent.clientId = srcFor(userId);
108
+ next();
109
+ });
110
+
111
+ this.backend.use('receive', function(context, next) {
112
+ var message = context.data;
113
+ var userId = context.agent.custom.userId;
114
+ if (message.a === ACTIONS.handshake && message.id != null && !isSrcOf(userId, message.id)) {
115
+ delete message.id;
116
+ }
117
+ if (message.a === ACTIONS.op && message.src != null && !isSrcOf(userId, message.src)) {
118
+ return next(new Error('Op src belongs to another user'));
119
+ }
120
+ next();
121
+ });
122
+ });
123
+
124
+ it('keeps a reclaimed id that names the same user', function(done) {
125
+ var backend = this.backend;
126
+ backend.connect(null, {userId: 42}, function(connection) {
127
+ var id = connection.id;
128
+ expect(id).to.match(/^42:/);
129
+ connection.close();
130
+ backend.connect(connection, {userId: 42}, function() {
131
+ expect(connection.id).to.equal(id);
132
+ done();
133
+ });
134
+ });
135
+ });
136
+
137
+ it('replaces a reclaimed id that names another user', function(done) {
138
+ var backend = this.backend;
139
+ backend.connect(null, {userId: 42}, function(connection) {
140
+ connection.close();
141
+ connection.id = '7:stale';
142
+ backend.connect(connection, {userId: 42}, function() {
143
+ expect(connection.id).to.match(/^42:/);
144
+ expect(connection.id).to.equal(connection.agent.clientId);
145
+ done();
146
+ });
147
+ });
148
+ });
149
+
150
+ it('rejects an op whose src names another user', function(done) {
151
+ this.backend.connect(null, {userId: 42}, function(connection) {
152
+ connection.get('dogs', 'fido').on('error', function(error) {
153
+ expect(error.message).to.equal('Op src belongs to another user');
154
+ done();
155
+ });
156
+ connection.send({
157
+ a: ACTIONS.op, c: 'dogs', d: 'fido', v: 0, src: '7:1', seq: 1,
158
+ create: {type: 'json0', data: {}}
159
+ });
160
+ });
161
+ });
66
162
  });
67
163
 
68
164
  describe('readSnapshots', function() {
package/test/ot.js CHANGED
@@ -159,6 +159,22 @@ describe('ot', function() {
159
159
  expect({}.polluted).to.equal(undefined);
160
160
  });
161
161
 
162
+ it('returns an error for a path segment that cannot be converted to a key', function() {
163
+ var error;
164
+ expect(function() {
165
+ error = ot.apply(snapshot, {v: 6, op: [{p: ['colour', {toString: 'x'}], oi: 'yes'}]});
166
+ }).to.not.throw();
167
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
168
+ });
169
+
170
+ it('returns an error for an op whose length cannot be converted to a number', function() {
171
+ var error;
172
+ expect(function() {
173
+ error = ot.apply(snapshot, {v: 6, op: {length: {toString: 'x'}}});
174
+ }).to.not.throw();
175
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
176
+ });
177
+
162
178
  ['__proto__', 'constructor'].forEach(function(badProp) {
163
179
  it('rejects ' + badProp + ' as a path segment', function() {
164
180
  var error = ot.apply(snapshot, {v: 6, op: [{p: [badProp, 'polluted'], oi: 'yes'}]});
@@ -187,6 +203,52 @@ describe('ot', function() {
187
203
  });
188
204
  });
189
205
 
206
+ describe('checkSubmittedOpForType', function() {
207
+ it('rejects an array-like json0 op', function() {
208
+ var op = {op: {0: {p: ['colour'], oi: 'red'}, length: 1}};
209
+ var error = ot.checkSubmittedOpForType(type.uri, op);
210
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
211
+ });
212
+
213
+ it('rejects a json0 op that is not an array', function() {
214
+ var error = ot.checkSubmittedOpForType(type.uri, {op: {p: ['colour'], oi: 'red'}});
215
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
216
+ });
217
+
218
+ it('rejects a json0 op component that is not an object', function() {
219
+ var error = ot.checkSubmittedOpForType(type.uri, {op: [null]});
220
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
221
+ expect(error.message).to.equal('Missing path');
222
+ });
223
+
224
+ it('rejects a dangerous path segment', function() {
225
+ var error = ot.checkSubmittedOpForType(type.uri, {op: [{p: ['__proto__', 'x'], oi: 'yes'}]});
226
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
227
+ expect(error.message).to.equal('Invalid path segment');
228
+ });
229
+
230
+ it('returns an error for a path segment that cannot be converted to a key', function() {
231
+ var error;
232
+ expect(function() {
233
+ error = ot.checkSubmittedOpForType(type.uri, {op: [{p: ['colour', {toString: 'x'}], oi: 'yes'}]});
234
+ }).to.not.throw();
235
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
236
+ });
237
+
238
+ it('accepts a valid json0 op', function() {
239
+ expect(ot.checkSubmittedOpForType(type.uri, {op: [{p: ['colour'], oi: 'red'}]})).equal();
240
+ });
241
+
242
+ it('leaves ops for other types alone', function() {
243
+ expect(ot.checkSubmittedOpForType(presenceType.uri, {op: {index: 0, value: 'hi'}})).equal();
244
+ });
245
+
246
+ it('leaves creates and deletes alone', function() {
247
+ expect(ot.checkSubmittedOpForType(type.uri, {create: {type: type.uri}})).equal();
248
+ expect(ot.checkSubmittedOpForType(type.uri, {del: true})).equal();
249
+ });
250
+ });
251
+
190
252
  describe('no-op', function() {
191
253
  it('works on existing docs', function() {
192
254
  var doc = {v: 6, type: type.uri, data: 'Hi'};
@@ -383,6 +383,24 @@ describe('projection utility methods', function() {
383
383
  {x: true},
384
384
  {op: [{p: [], oi: {y: 1}}]}
385
385
  );
386
+ test(
387
+ {0: true},
388
+ {op: [{p: [0], na: 1}]}
389
+ );
390
+ });
391
+
392
+ [
393
+ null,
394
+ {},
395
+ [null],
396
+ [{}],
397
+ [{p: null}],
398
+ [{p: 'x'}],
399
+ [{p: [{toString: 'x'}]}]
400
+ ].forEach(function(edit) {
401
+ it('does not allow the malformed op ' + JSON.stringify(edit), function() {
402
+ expect(projections.isOpAllowed(null, {x: true}, {op: edit})).equal(false);
403
+ });
386
404
  });
387
405
  });
388
406
 
@@ -1,6 +1,7 @@
1
1
  var MemoryPubSub = require('../lib/pubsub/memory');
2
2
  var PubSub = require('../lib/pubsub');
3
3
  var expect = require('chai').expect;
4
+ var sinon = require('sinon');
4
5
 
5
6
  require('./pubsub')(function(callback) {
6
7
  callback(null, new MemoryPubSub());
@@ -29,6 +30,50 @@ describe('PubSub base class', function() {
29
30
  pubsub.subscribe('x');
30
31
  });
31
32
 
33
+ it('returns the _subscribe error to every concurrent subscriber', function(done) {
34
+ var pubsub = new PubSub();
35
+ sinon.spy(pubsub, '_subscribe');
36
+ var errors = 0;
37
+ function onSubscribe(err) {
38
+ expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED');
39
+ if (++errors < 2) return;
40
+ expect(pubsub._subscribe).to.have.been.calledOnce;
41
+ done();
42
+ }
43
+ pubsub.subscribe('x', onSubscribe);
44
+ pubsub.subscribe('x', onSubscribe);
45
+ });
46
+
47
+ it('calls _subscribe again after a failed subscribe', function(done) {
48
+ var pubsub = new PubSub();
49
+ pubsub.subscribe('x', function(err) {
50
+ expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED');
51
+ pubsub._subscribe = function(channel, callback) {
52
+ callback();
53
+ };
54
+ pubsub.subscribe('x', function(err) {
55
+ if (err) return done(err);
56
+ expect(pubsub.streamsCount).to.equal(1);
57
+ done();
58
+ });
59
+ });
60
+ });
61
+
62
+ it('can resubscribe from a callback that destroyed the last stream', function(done) {
63
+ var pubsub = new PubSub();
64
+ pubsub._subscribe = function(channel, callback) {
65
+ callback();
66
+ };
67
+ pubsub._unsubscribe = function(channel, callback) {
68
+ callback();
69
+ };
70
+ pubsub.subscribe('x', function(err, stream) {
71
+ if (err) return done(err);
72
+ stream.destroy();
73
+ pubsub.subscribe('x', done);
74
+ });
75
+ });
76
+
32
77
  it('emits an error if _unsubscribe is unimplemented', function(done) {
33
78
  var pubsub = new PubSub();
34
79
  pubsub._subscribe = function(channel, callback) {
package/test/pubsub.js CHANGED
@@ -60,6 +60,51 @@ module.exports = function(create) {
60
60
  });
61
61
  });
62
62
 
63
+ it('calls _subscribe once for concurrent subscribes to a channel', function(done) {
64
+ var pubsub = this.pubsub;
65
+ var _subscribe = pubsub._subscribe;
66
+ var subscribeCalls = 0;
67
+ pubsub._subscribe = function() {
68
+ subscribeCalls++;
69
+ return _subscribe.apply(this, arguments);
70
+ };
71
+ var received = [];
72
+ var finished = 0;
73
+ function onSubscribe(err, stream) {
74
+ if (err) return done(err);
75
+ var messages = [];
76
+ received.push(messages);
77
+ stream.on('data', function(data) {
78
+ messages.push(data.n);
79
+ if (data.n !== 2 || ++finished < 2) return;
80
+ expect(received).eql([[1, 2], [1, 2]]);
81
+ done();
82
+ });
83
+ if (received.length < 2) return;
84
+ expect(subscribeCalls).equal(1);
85
+ pubsub.publish(['x'], {n: 1});
86
+ pubsub.publish(['x'], {n: 2});
87
+ }
88
+ pubsub.subscribe('x', onSubscribe);
89
+ pubsub.subscribe('x', onSubscribe);
90
+ });
91
+
92
+ it('keeps a channel subscribed when a concurrent subscriber destroys its stream', function(done) {
93
+ var pubsub = this.pubsub;
94
+ pubsub.subscribe('x', function(err, stream) {
95
+ if (err) return done(err);
96
+ stream.destroy();
97
+ });
98
+ pubsub.subscribe('x', function(err, stream) {
99
+ if (err) return done(err);
100
+ stream.on('data', function(data) {
101
+ expect(data).eql({test: true});
102
+ done();
103
+ });
104
+ pubsub.publish(['x'], {test: true});
105
+ });
106
+ });
107
+
63
108
  it('stream.destroy() unsubscribes from a channel', function(done) {
64
109
  var pubsub = this.pubsub;
65
110
  pubsub.subscribe('x', function(err, stream) {