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.
package/test/agent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  var Backend = require('../lib/backend');
2
+ var MemoryDb = require('../lib/db/memory');
2
3
  var logger = require('../lib/logger');
3
4
  var sinon = require('sinon');
4
5
  var StreamSocket = require('../lib/stream-socket');
@@ -19,6 +20,141 @@ describe('Agent', function() {
19
20
  backend.close(done);
20
21
  });
21
22
 
23
+ describe('request validation', function() {
24
+ var socket;
25
+ var connection;
26
+
27
+ beforeEach(function(done) {
28
+ socket = new StreamSocket();
29
+ backend.listen(socket.stream);
30
+ connection = new Connection(socket);
31
+ socket._open();
32
+ connection.once('connected', function() {
33
+ done();
34
+ });
35
+ });
36
+
37
+ function expectRejected(message, expectedError, done) {
38
+ var dbReads = ['getSnapshot', 'getSnapshotBulk', 'getOps', 'getOpsBulk', 'query'].map(function(method) {
39
+ return sinon.spy(MemoryDb.prototype, method);
40
+ });
41
+ connection.on('receive', function(request) {
42
+ var reply = request.data;
43
+ if (!reply || !reply.error) return;
44
+ request.data = null;
45
+ expect(reply.error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: expectedError});
46
+ dbReads.forEach(function(dbRead) {
47
+ expect(dbRead).not.to.have.been.called;
48
+ });
49
+ done();
50
+ });
51
+ socket.send(JSON.stringify(message));
52
+ }
53
+
54
+ var unconvertible = {toString: 1};
55
+ var badIds = [unconvertible, 42, null, '__proto__'];
56
+ var badVersions = [unconvertible, '1', -1];
57
+
58
+ [ACTIONS.fetch, ACTIONS.subscribe, ACTIONS.snapshotFetch].forEach(function(action) {
59
+ badVersions.forEach(function(badVersion) {
60
+ it('rejects a ' + action + ' with version ' + JSON.stringify(badVersion), function(done) {
61
+ expectRejected({a: action, c: 'dogs', d: 'fido', v: badVersion}, 'Invalid version', done);
62
+ });
63
+ });
64
+ });
65
+
66
+ [unconvertible, '1'].forEach(function(badTimestamp) {
67
+ it('rejects a snapshot fetch with timestamp ' + JSON.stringify(badTimestamp), function(done) {
68
+ var message = {a: ACTIONS.snapshotFetchByTimestamp, c: 'dogs', d: 'fido', ts: badTimestamp};
69
+ expectRejected(message, 'Invalid timestamp', done);
70
+ });
71
+ });
72
+
73
+ [ACTIONS.bulkFetch, ACTIONS.bulkSubscribe, ACTIONS.bulkUnsubscribe].forEach(function(action) {
74
+ it('rejects a ' + action + ' without bulk data', function(done) {
75
+ expectRejected({a: action, c: 'dogs', b: null}, 'Invalid bulk subscribe data', done);
76
+ });
77
+
78
+ badIds.forEach(function(badId) {
79
+ it('rejects a ' + action + ' with id ' + JSON.stringify(badId), function(done) {
80
+ expectRejected({a: action, c: 'dogs', b: ['fido', badId]}, 'Invalid id', done);
81
+ });
82
+ });
83
+
84
+ badVersions.forEach(function(badVersion) {
85
+ it('rejects a ' + action + ' with version ' + JSON.stringify(badVersion), function(done) {
86
+ expectRejected({a: action, c: 'dogs', b: {fido: badVersion}}, 'Invalid version', done);
87
+ });
88
+ });
89
+
90
+ it('rejects a ' + action + ' with a __proto__ key in its version map', function(done) {
91
+ expectRejected({a: action, c: 'dogs', b: JSON.parse('{"__proto__": 0}')}, 'Invalid id', done);
92
+ });
93
+ });
94
+
95
+ [ACTIONS.queryFetch, ACTIONS.querySubscribe].forEach(function(action) {
96
+ [unconvertible, ['dogs'], '__proto__'].forEach(function(badCollection) {
97
+ it('rejects a ' + action + ' with collection ' + JSON.stringify(badCollection), function(done) {
98
+ expectRejected({a: action, id: 1, c: badCollection, q: {}}, 'Invalid collection', done);
99
+ });
100
+ });
101
+
102
+ badIds.forEach(function(badId) {
103
+ it('rejects a ' + action + ' with result id ' + JSON.stringify(badId), function(done) {
104
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: [[badId]]}, 'Invalid id', done);
105
+ });
106
+ });
107
+
108
+ badVersions.forEach(function(badVersion) {
109
+ it('rejects a ' + action + ' with result version ' + JSON.stringify(badVersion), function(done) {
110
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: [['fido', badVersion]]}, 'Invalid version', done);
111
+ });
112
+ });
113
+
114
+ [{fido: 0}, ['fido']].forEach(function(badResults) {
115
+ it('rejects a ' + action + ' with results ' + JSON.stringify(badResults), function(done) {
116
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: badResults}, 'Invalid query results', done);
117
+ });
118
+ });
119
+ });
120
+
121
+ it('accepts a query unsubscribe without a collection', function(done) {
122
+ connection.on('receive', function(request) {
123
+ var reply = request.data;
124
+ if (!reply || reply.a !== ACTIONS.queryUnsubscribe) return;
125
+ request.data = null;
126
+ expect(reply).not.to.have.property('error');
127
+ done();
128
+ });
129
+ socket.send(JSON.stringify({a: ACTIONS.queryUnsubscribe, id: 1}));
130
+ });
131
+
132
+ [ACTIONS.presenceSubscribe, ACTIONS.presenceUnsubscribe].forEach(function(action) {
133
+ [unconvertible, '1'].forEach(function(badSeq) {
134
+ it('rejects a ' + action + ' with seq ' + JSON.stringify(badSeq), function(done) {
135
+ expectRejected({a: action, ch: 'dogs.fido', seq: badSeq}, 'Invalid seq', done);
136
+ });
137
+ });
138
+ });
139
+
140
+ badVersions.forEach(function(badVersion) {
141
+ it('rejects presence with presence version ' + JSON.stringify(badVersion), function(done) {
142
+ var message = {a: ACTIONS.presence, ch: 'dogs.fido', id: 'rex', p: null, pv: badVersion};
143
+ expectRejected(message, 'Invalid presence version', done);
144
+ });
145
+ });
146
+
147
+ [unconvertible, true].forEach(function(badProtocol) {
148
+ it('rejects a handshake with protocol ' + JSON.stringify(badProtocol), function(done) {
149
+ expectRejected({a: ACTIONS.handshake, protocol: badProtocol, protocolMinor: 2}, 'Invalid protocol', done);
150
+ });
151
+
152
+ it('rejects a handshake with minor protocol ' + JSON.stringify(badProtocol), function(done) {
153
+ expectRejected({a: ACTIONS.handshake, protocol: 1, protocolMinor: badProtocol}, 'Invalid protocol', done);
154
+ });
155
+ });
156
+ });
157
+
22
158
  describe('handshake', function() {
23
159
  it('warns when messages are sent before the handshake', function(done) {
24
160
  var socket = new StreamSocket();
@@ -83,4 +219,108 @@ describe('Agent', function() {
83
219
  });
84
220
  });
85
221
  });
222
+
223
+ describe('pub/sub channel collisions', function() {
224
+ var writer;
225
+ var subscriber;
226
+
227
+ beforeEach(function() {
228
+ writer = backend.connect();
229
+ subscriber = backend.connect();
230
+ });
231
+
232
+ it('does not deliver ops for a doc whose collection and id join to the same channel', function(done) {
233
+ var doc = subscriber.get('notes', 'private.alice');
234
+ doc.subscribe(function(error) {
235
+ if (error) return done(error);
236
+ doc.once('create', function() {
237
+ expect(doc.data).to.eql({text: 'Hello'});
238
+ done();
239
+ });
240
+ writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
241
+ if (error) return done(error);
242
+ writer.get('notes', 'private.alice').create({text: 'Hello'}, function(error) {
243
+ if (error) done(error);
244
+ });
245
+ });
246
+ });
247
+ });
248
+
249
+ it('does not deliver ops from a collection whose channel matches the doc channel', function(done) {
250
+ var doc = subscriber.get('notes', 'private');
251
+ doc.subscribe(function(error) {
252
+ if (error) return done(error);
253
+ doc.once('create', function() {
254
+ expect(doc.data).to.eql({text: 'Hello'});
255
+ done();
256
+ });
257
+ writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
258
+ if (error) return done(error);
259
+ writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
260
+ if (error) done(error);
261
+ });
262
+ });
263
+ });
264
+ });
265
+
266
+ it('does not deliver ops to a query on a collection whose channel matches the doc channel', function(done) {
267
+ var opMessages = [];
268
+ var diaryEdit = {p: ['diary'], od: 'Dear diary', oi: 'Dear diary, again'};
269
+ subscriber.on('receive', function(request) {
270
+ if (request.data.a === ACTIONS.op) opMessages.push(request.data);
271
+ });
272
+ var privateDoc = writer.get('notes.private', 'private');
273
+ privateDoc.create({diary: 'Dear diary'}, function(error) {
274
+ if (error) return done(error);
275
+ subscriber.createSubscribeQuery('notes.private', {}, null, function(error, results) {
276
+ if (error) return done(error);
277
+ results[0].once('op', function() {
278
+ expect(opMessages).to.have.length(1);
279
+ expect(opMessages[0].op).to.eql([diaryEdit]);
280
+ done();
281
+ });
282
+ writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
283
+ if (error) return done(error);
284
+ privateDoc.submitOp(diaryEdit, function(error) {
285
+ if (error) done(error);
286
+ });
287
+ });
288
+ });
289
+ });
290
+ });
291
+
292
+ it('does not deliver ops to a query from another collection sharing its custom channel', function(done) {
293
+ backend.use('commit', function(context, next) {
294
+ context.channels.push('shared');
295
+ next();
296
+ });
297
+ backend.use('query', function(context, next) {
298
+ context.channels = ['shared'];
299
+ next();
300
+ });
301
+ var opMessages = [];
302
+ var rename = {p: ['name'], od: 'Fido', oi: 'Rex'};
303
+ subscriber.on('receive', function(request) {
304
+ if (request.data.a === ACTIONS.op) opMessages.push(request.data);
305
+ });
306
+ var dog = writer.get('dogs', 'fido');
307
+ dog.create({name: 'Fido'}, function(error) {
308
+ if (error) return done(error);
309
+ subscriber.createSubscribeQuery('dogs', {}, null, function(error, results) {
310
+ if (error) return done(error);
311
+ results[0].once('op', function() {
312
+ expect(opMessages).to.have.length(1);
313
+ expect(opMessages[0].op).to.eql([rename]);
314
+ done();
315
+ });
316
+ writer.get('cats', 'fido').create({name: 'Tom'}, function(error) {
317
+ if (error) return done(error);
318
+ dog.submitOp(rename, function(error) {
319
+ if (error) done(error);
320
+ });
321
+ });
322
+ });
323
+ });
324
+ });
325
+ });
86
326
  });
package/test/backend.js CHANGED
@@ -1,4 +1,5 @@
1
1
  var Backend = require('../lib/backend');
2
+ var MemoryDB = require('../lib/db/memory');
2
3
  var expect = require('chai').expect;
3
4
  var sinon = require('sinon');
4
5
  var logger = require('../lib/logger');
@@ -28,6 +29,46 @@ describe('Backend', function() {
28
29
  expect(handler.callCount).to.equal(1);
29
30
  });
30
31
  });
32
+
33
+ describe('extraDbs', function() {
34
+ var archive;
35
+
36
+ beforeEach(function() {
37
+ archive = new MemoryDB();
38
+ backend = new Backend({extraDbs: {archive: archive}});
39
+ });
40
+
41
+ ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) {
42
+ it(method + ' queries the extra DB named by the db option', function(done) {
43
+ var query = sinon.spy(archive, 'query');
44
+ backend.connect()[method]('books', {}, {db: 'archive'}, function(error) {
45
+ if (error) return done(error);
46
+ expect(query).to.have.been.calledWith('books');
47
+ done();
48
+ });
49
+ });
50
+
51
+ ['constructor', '__proto__', {toString: 1}].forEach(function(badValue) {
52
+ it(method + ' errors if the db option is ' + JSON.stringify(badValue), function(done) {
53
+ backend.connect()[method]('books', {}, {db: badValue}, function(error) {
54
+ expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
55
+ done();
56
+ });
57
+ });
58
+
59
+ it(method + ' errors if middleware sets the db option to ' + JSON.stringify(badValue), function(done) {
60
+ backend.use('query', function(request, next) {
61
+ request.options.db = badValue;
62
+ process.nextTick(next);
63
+ });
64
+ backend.connect()[method]('books', {}, null, function(error) {
65
+ expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
66
+ done();
67
+ });
68
+ });
69
+ });
70
+ });
71
+ });
31
72
  });
32
73
 
33
74
  describe('a simple document', function() {
@@ -207,7 +248,7 @@ describe('Backend', function() {
207
248
  title: '1984',
208
249
  author: 'George Orwell'
209
250
  });
210
- var op = {op: {p: ['publication'], oi: 1949}};
251
+ var op = {op: [{p: ['publication'], oi: 1949}]};
211
252
  stream.on('data', function(data) {
212
253
  expect(data.op).to.eql(op.op);
213
254
  done();
@@ -229,6 +270,48 @@ describe('Backend', function() {
229
270
  });
230
271
  });
231
272
 
273
+ describe('subscribeBulk', function() {
274
+ it('only subscribes once to a duplicated id', function(done) {
275
+ backend.subscribeBulk(agent, 'books', ['1984', '1984'], function(error, streams) {
276
+ if (error) return done(error);
277
+ expect(Object.keys(streams)).to.eql(['1984']);
278
+ expect(backend.pubsub.streamsCount).to.equal(1);
279
+ done();
280
+ });
281
+ });
282
+
283
+ describe('when one subscribe fails', function() {
284
+ var finishSubscribe;
285
+
286
+ beforeEach(function() {
287
+ finishSubscribe = {};
288
+ backend.pubsub._subscribe = function(channel, callback) {
289
+ finishSubscribe[channel] = callback;
290
+ };
291
+ });
292
+
293
+ it('destroys streams that subscribed before the failure', function(done) {
294
+ backend.subscribeBulk(agent, 'books', ['1984', 'bad'], function(error) {
295
+ expect(error.message).to.equal('subscribe failed');
296
+ expect(backend.pubsub.streamsCount).to.equal(0);
297
+ done();
298
+ });
299
+ finishSubscribe['books.1984']();
300
+ finishSubscribe['books.bad'](new Error('subscribe failed'));
301
+ });
302
+
303
+ it('destroys streams that subscribe after the failure', function(done) {
304
+ backend.subscribeBulk(agent, 'books', ['1984', 'bad'], function(error) {
305
+ expect(error.message).to.equal('subscribe failed');
306
+ finishSubscribe['books.1984']();
307
+ expect(backend.pubsub.streamsCount).to.equal(0);
308
+ done();
309
+ });
310
+ finishSubscribe['books.bad'](new Error('subscribe failed'));
311
+ });
312
+ });
313
+ });
314
+
232
315
  describe('submitRequestEnd', function() {
233
316
  it('emits after write', function(done) {
234
317
  var afterWriteCalled = false;
@@ -245,7 +328,7 @@ describe('Backend', function() {
245
328
  done();
246
329
  });
247
330
 
248
- var op = {op: {p: ['publicationYear'], oi: 1949}};
331
+ var op = {op: [{p: ['publicationYear'], oi: 1949}]};
249
332
  backend.submit(agent, 'books', '1984', op, null, function(error) {
250
333
  if (error) done(error);
251
334
  });
@@ -262,7 +345,7 @@ describe('Backend', function() {
262
345
  done();
263
346
  });
264
347
 
265
- var op = {op: {p: ['publicationYear'], oi: 1949}};
348
+ var op = {op: [{p: ['publicationYear'], oi: 1949}]};
266
349
  backend.submit(agent, 'books', '1984', op, null, function() {
267
350
  // Swallow the error
268
351
  });
@@ -657,31 +657,31 @@ describe('Doc', function() {
657
657
  });
658
658
  });
659
659
 
660
- describe('errors on ops that could cause prototype corruption', function() {
661
- function expectReceiveError(
662
- connection,
663
- collectionName,
664
- docId,
665
- expectedError,
666
- done
667
- ) {
668
- connection.on('receive', function(request) {
669
- var message = request.data;
670
- if (message.c === collectionName && message.d === docId) {
671
- if ('error' in message) {
672
- request.data = null; // Stop further processing of the message
673
- if (message.error.message === expectedError) {
674
- return done();
675
- } else {
676
- return done('Unexpected ShareDB error: ' + message.error.message);
677
- }
660
+ function expectReceiveError(
661
+ connection,
662
+ collectionName,
663
+ docId,
664
+ expectedError,
665
+ done
666
+ ) {
667
+ connection.on('receive', function(request) {
668
+ var message = request.data;
669
+ if (message.c === collectionName && message.d === docId) {
670
+ if ('error' in message) {
671
+ request.data = null; // Stop further processing of the message
672
+ if (message.error.message === expectedError) {
673
+ return done();
678
674
  } else {
679
- return done('Expected error on ' + collectionName + '.' + docId + ' but got no error');
675
+ return done('Unexpected ShareDB error: ' + message.error.message);
680
676
  }
677
+ } else {
678
+ return done('Expected error on ' + collectionName + '.' + docId + ' but got no error');
681
679
  }
682
- });
683
- }
680
+ }
681
+ });
682
+ }
684
683
 
684
+ describe('errors on ops that could cause prototype corruption', function() {
685
685
  afterEach(function() {
686
686
  delete Object.prototype.polluted;
687
687
  });
@@ -760,16 +760,21 @@ describe('Doc', function() {
760
760
  });
761
761
  });
762
762
 
763
- // ot-json0 walks ops with .length and numeric indexing, so it applies an
764
- // array-like object as if it were an op
765
763
  [
766
764
  {
767
765
  name: 'an array-like op',
768
- op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1}
766
+ op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1},
767
+ error: 'json0 op must be an array'
769
768
  },
770
769
  {
771
770
  name: 'ops with a path segment that is not a string',
772
- op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}]
771
+ op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}],
772
+ error: 'Invalid path segment'
773
+ },
774
+ {
775
+ name: 'ops with a component that is not an object',
776
+ op: [null],
777
+ error: 'Missing path'
773
778
  }
774
779
  ].forEach(function(test) {
775
780
  it('Rejects ' + test.name, function(done) {
@@ -780,7 +785,7 @@ describe('Doc', function() {
780
785
  if (err) {
781
786
  return done(err);
782
787
  }
783
- expectReceiveError(connection, collectionName, docId, 'Invalid path segment', function(error) {
788
+ expectReceiveError(connection, collectionName, docId, test.error, function(error) {
784
789
  if (error) {
785
790
  return done(error);
786
791
  }
@@ -911,6 +916,31 @@ describe('Doc', function() {
911
916
  });
912
917
  });
913
918
 
919
+ // normalize() runs before the paths are checked, and it takes a bare
920
+ // component as well as an op, fills in a missing path, and branches on
921
+ // Array.isArray() — so these all reach the check as a real op
922
+ [
923
+ {name: 'a bare op component', op: {p: ['__proto__', 'polluted'], oi: 'oops'}},
924
+ {name: 'an op that is also shaped like a component', op: (function() {
925
+ var op = [{p: ['__proto__', 'polluted'], oi: 'oops'}];
926
+ op.p = [];
927
+ return op;
928
+ })()},
929
+ {name: 'an op with a pathless component before a bad one', op: [
930
+ {p: [], od: {foo: 'bar'}, oi: {foo: 'bar'}},
931
+ {p: ['__proto__', 'polluted'], oi: 'oops'}
932
+ ]}
933
+ ].forEach(function(test) {
934
+ it('rejects ' + test.name + ' composed into a pending create', function(done) {
935
+ var doc = this.connection.get('test-collection', 'test-doc');
936
+ doc.create({foo: 'bar'});
937
+ doc.submitOp(test.op, function(error) {
938
+ expectInvalidPathSegment(error);
939
+ done();
940
+ });
941
+ });
942
+ });
943
+
914
944
  it('leaves the doc usable after rejecting an op', function(done) {
915
945
  var doc = this.connection.get('test-collection', 'test-doc');
916
946
  async.series([
@@ -932,6 +962,165 @@ describe('Doc', function() {
932
962
  });
933
963
  });
934
964
 
965
+ // ot-json0's apply() quietly ignores an op that isn't an array, but its
966
+ // compose() and invert() throw on one. $fixup() composes, and it is called
967
+ // from middleware, so the throw is uncaught and takes the process down
968
+ describe('errors on badly formed json0 ops', function() {
969
+ [
970
+ {
971
+ name: 'an array-like op',
972
+ op: {0: {p: ['colour'], oi: 'red'}, length: 1},
973
+ error: 'json0 op must be an array'
974
+ },
975
+ {
976
+ name: 'a bare op component',
977
+ op: {p: ['colour'], oi: 'red'},
978
+ error: 'json0 op must be an array'
979
+ },
980
+ {
981
+ name: 'an op component that is not an object',
982
+ op: [null],
983
+ error: 'Missing path'
984
+ }
985
+ ].forEach(function(test) {
986
+ it('Rejects ' + test.name + ' before the apply middleware can fix it up', function(done) {
987
+ var connection = this.connection;
988
+ var collectionName = 'test-collection';
989
+ var docId = 'test-doc';
990
+ this.backend.use('apply', function(request, next) {
991
+ if ('op' in request.op) request.$fixup([{p: ['fixed'], oi: true}]);
992
+ next();
993
+ });
994
+ connection.get(collectionName, docId).create({id: docId}, function(err) {
995
+ if (err) {
996
+ return done(err);
997
+ }
998
+ expectReceiveError(connection, collectionName, docId, test.error, done);
999
+ connection.send({
1000
+ a: 'op',
1001
+ c: collectionName,
1002
+ d: docId,
1003
+ v: 1,
1004
+ seq: connection.seq++,
1005
+ x: {},
1006
+ op: test.op
1007
+ });
1008
+ });
1009
+ });
1010
+ });
1011
+
1012
+ describe('locally submitted ops', function() {
1013
+ // json0's normalize() throws on a component that isn't an object, and it
1014
+ // runs before the op is checked, so this used to come straight out of
1015
+ // submitOp() rather than through the callback
1016
+ it('rejects an op component that is not an object without sending it to the server', function(done) {
1017
+ var doc = this.connection.get('test-collection', 'test-doc');
1018
+ doc.create({foo: 'bar'}, function(error) {
1019
+ if (error) return done(error);
1020
+ var calledBack = false;
1021
+ doc.submitOp([null], function(error) {
1022
+ calledBack = true;
1023
+ expect(error.code).to.equal(ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED);
1024
+ });
1025
+ // The server would only reject asynchronously, so calling back
1026
+ // synchronously is how we know the op never left the client
1027
+ expect(calledBack).to.equal(true);
1028
+ done();
1029
+ });
1030
+ });
1031
+
1032
+ it('emits an error for an op component that is not an object with no callback', function(done) {
1033
+ var doc = this.connection.get('test-collection', 'test-doc');
1034
+ doc.create({foo: 'bar'}, function(error) {
1035
+ if (error) return done(error);
1036
+ doc.on('error', function(error) {
1037
+ expect(error.code).to.equal(ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED);
1038
+ done();
1039
+ });
1040
+ doc.submitOp([null]);
1041
+ });
1042
+ });
1043
+
1044
+ // A type can throw anything at all, and reading .message off a thrown
1045
+ // null would throw out of the catch, which is what the catch is for
1046
+ [
1047
+ {
1048
+ name: 'a string',
1049
+ thrown: 'not an op',
1050
+ code: ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED,
1051
+ message: 'not an op'
1052
+ },
1053
+ {
1054
+ name: 'null',
1055
+ thrown: null,
1056
+ code: ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED,
1057
+ message: 'null'
1058
+ },
1059
+ {
1060
+ name: 'a ShareDBError',
1061
+ thrown: new ShareDBError('ERR_CUSTOM_TYPE_ERROR', 'Custom type error'),
1062
+ code: 'ERR_CUSTOM_TYPE_ERROR',
1063
+ message: 'Custom type error'
1064
+ }
1065
+ ].forEach(function(test) {
1066
+ it('reports ' + test.name + ' thrown by normalize()', function(done) {
1067
+ var doc = this.connection.get('test-collection', 'test-doc');
1068
+ doc.create({foo: 'bar'}, function(error) {
1069
+ if (error) return done(error);
1070
+ sinon.stub(json0, 'normalize').callsFake(function() {
1071
+ throw test.thrown;
1072
+ });
1073
+ doc.submitOp([{p: ['foo'], od: 'bar'}], function(error) {
1074
+ expect(error.code).to.equal(test.code);
1075
+ expect(error.message).to.equal(test.message);
1076
+ done();
1077
+ });
1078
+ });
1079
+ });
1080
+ });
1081
+
1082
+ // normalize() accepts more than the server does, and everything it
1083
+ // accepts has to stay submittable
1084
+ [
1085
+ {name: 'a bare op component', op: {p: ['baz'], oi: true}},
1086
+ {name: 'an op whose component has no path', op: [{od: {foo: 'bar'}, oi: {foo: 'bar', baz: true}}]},
1087
+ {name: 'a bare component with no path', op: {od: {foo: 'bar'}, oi: {foo: 'bar', baz: true}}}
1088
+ ].forEach(function(test) {
1089
+ it('accepts ' + test.name + ', which json0 normalizes into an op', function(done) {
1090
+ var doc = this.connection.get('test-collection', 'test-doc');
1091
+ async.series([
1092
+ doc.create.bind(doc, {foo: 'bar'}),
1093
+ doc.submitOp.bind(doc, test.op),
1094
+ doc.whenNothingPending.bind(doc),
1095
+ function(next) {
1096
+ expect(doc.data).to.eql({foo: 'bar', baz: true});
1097
+ next();
1098
+ }
1099
+ ], done);
1100
+ });
1101
+ });
1102
+
1103
+ it('leaves the doc usable after rejecting an op', function(done) {
1104
+ var doc = this.connection.get('test-collection', 'test-doc');
1105
+ async.series([
1106
+ doc.create.bind(doc, {foo: 'bar'}),
1107
+ function(next) {
1108
+ doc.submitOp([null], function(error) {
1109
+ expect(error).to.be.instanceOf(Error);
1110
+ next();
1111
+ });
1112
+ },
1113
+ doc.submitOp.bind(doc, [{p: ['baz'], oi: true}]),
1114
+ doc.whenNothingPending.bind(doc),
1115
+ function(next) {
1116
+ expect(doc.data).to.eql({foo: 'bar', baz: true});
1117
+ next();
1118
+ }
1119
+ ], done);
1120
+ });
1121
+ });
1122
+ });
1123
+
935
1124
  describe('toSnapshot', function() {
936
1125
  var doc;
937
1126
  beforeEach(function(done) {