UNPKG

sharedb

Version:
425 lines (394 loc) 15.6 kB
var Backend = require('../../lib/backend'); var expect = require('chai').expect; var util = require('../util'); describe('Doc', function() { beforeEach(function() { this.backend = new Backend(); this.connection = this.backend.connect(); }); it('getting twice returns the same doc', function() { var doc = this.connection.get('dogs', 'fido'); var doc2 = this.connection.get('dogs', 'fido'); expect(doc).equal(doc2); }); it('calling doc.destroy unregisters it', function(done) { var connection = this.connection; var doc = connection.get('dogs', 'fido'); expect(connection.getExisting('dogs', 'fido')).equal(doc); doc.destroy(function(err) { if (err) return done(err); expect(connection.getExisting('dogs', 'fido')).equal(undefined); var doc2 = connection.get('dogs', 'fido'); expect(doc).not.equal(doc2); done(); }); // destroy is async expect(connection.getExisting('dogs', 'fido')).equal(doc); }); it('getting then destroying then getting returns a new doc object', function(done) { var connection = this.connection; var doc = connection.get('dogs', 'fido'); doc.destroy(function(err) { if (err) return done(err); var doc2 = connection.get('dogs', 'fido'); expect(doc).not.equal(doc2); expect(doc).eql(doc2); done(); }); }); it('doc.destroy() works without a callback', function() { var doc = this.connection.get('dogs', 'fido'); doc.destroy(); }); describe('when connection closed', function() { beforeEach(function(done) { this.op1 = [{p: ['snacks'], oi: true}]; this.op2 = [{p: ['color'], oi: 'gray'}]; this.doc = this.connection.get('dogs', 'fido'); this.doc.create({}, function(err) { if (err) return done(err); done(); }); }); it('do not mutate previously inflight op', function(done) { var doc = this.doc; var op1 = this.op1; var op2 = this.op2; var connection = this.connection; this.connection.on('send', function() { expect(doc.pendingOps).to.have.length(0); expect(doc.inflightOp.op).to.eql(op1); expect(doc.inflightOp.sentAt).to.not.be.undefined; connection.close(); expect(doc.pendingOps).to.have.length(1); doc.submitOp(op2); expect(doc.pendingOps).to.have.length(2); expect(doc.pendingOps[0].op).to.eql(op1); expect(doc.pendingOps[1].op).to.eql(op2); done(); }); this.doc.submitOp(this.op1, function() { done(new Error('Connection should have been closed')); }); }); }); describe('applyStack', function() { beforeEach(function(done) { this.doc = this.connection.get('dogs', 'fido'); this.doc2 = this.backend.connect().get('dogs', 'fido'); this.doc3 = this.backend.connect().get('dogs', 'fido'); var doc2 = this.doc2; this.doc.create({}, function(err) { if (err) return done(err); doc2.fetch(done); }); }); function verifyConsistency(doc, doc2, doc3, handlers, callback) { doc.whenNothingPending(function(err) { if (err) return callback(err); expect(handlers.length).equal(0); doc2.fetch(function(err) { if (err) return callback(err); doc3.fetch(function(err) { if (err) return callback(err); expect(doc.data).eql(doc2.data); expect(doc.data).eql(doc3.data); callback(); }); }); }); } it('single component ops emit an `op` event', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; var handlers = [ function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'white'}]); expect(doc.data).eql({color: 'white'}); doc.submitOp({p: ['color'], oi: 'gray'}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'gray'}]); expect(doc.data).eql({color: 'gray'}); }, function(op, source) { expect(source).equal(false); expect(op).eql([]); expect(doc.data).eql({color: 'gray'}); doc.submitOp({p: ['color'], oi: 'black'}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'black'}]); expect(doc.data).eql({color: 'black'}); } ]; doc.on('op', function(op, source) { var handler = handlers.shift(); handler(op, source); }); doc2.submitOp([{p: ['color'], oi: 'brown'}], function(err) { if (err) return done(err); doc.submitOp({p: ['color'], oi: 'white'}); expect(doc.data).eql({color: 'gray'}); verifyConsistency(doc, doc2, doc3, handlers, done); }); }); it('remote multi component ops emit individual `op` events', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; var handlers = [ function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'white'}]); expect(doc.data).eql({color: 'white'}); doc.submitOp([{p: ['color'], oi: 'gray'}, {p: ['weight'], oi: 40}]); expect(doc.data).eql({color: 'gray', weight: 40}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'gray'}, {p: ['weight'], oi: 40}]); expect(doc.data).eql({color: 'gray', weight: 40}); }, function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['age'], oi: 2}]); expect(doc.data).eql({color: 'gray', weight: 40, age: 2}); doc.submitOp([{p: ['color'], oi: 'black'}, {p: ['age'], na: 1}]); expect(doc.data).eql({color: 'black', weight: 40, age: 5}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['color'], oi: 'black'}, {p: ['age'], na: 1}]); expect(doc.data).eql({color: 'black', weight: 40, age: 3}); doc.submitOp({p: ['age'], na: 2}); expect(doc.data).eql({color: 'black', weight: 40, age: 5}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['age'], na: 2}]); expect(doc.data).eql({color: 'black', weight: 40, age: 5}); }, function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['owner'], oi: 'sue'}]); expect(doc.data).eql({color: 'black', weight: 40, age: 5, owner: 'sue'}); } ]; doc.on('op', function(op, source) { var handler = handlers.shift(); handler(op, source); }); doc2.submitOp([{p: ['age'], oi: 2}, {p: ['owner'], oi: 'sue'}], function(err) { if (err) return done(err); doc.submitOp({p: ['color'], oi: 'white'}); expect(doc.data).eql({color: 'gray', weight: 40}); verifyConsistency(doc, doc2, doc3, handlers, done); }); }); it('remote multi component ops are transformed by ops submitted in `op` event handlers', function(done) { var doc = this.doc; var doc2 = this.doc2; var doc3 = this.doc3; var handlers = [ function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['tricks'], oi: ['fetching']}]); expect(doc.data).eql({tricks: ['fetching']}); }, function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['tricks', 0], li: 'stand'}]); expect(doc.data).eql({tricks: ['stand', 'fetching']}); doc.submitOp([{p: ['tricks', 0], ld: 'stand'}, {p: ['tricks', 0, 8], si: ' stick'}]); expect(doc.data).eql({tricks: ['fetching stick']}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['tricks', 0], ld: 'stand'}, {p: ['tricks', 0, 8], si: ' stick'}]); expect(doc.data).eql({tricks: ['fetching stick']}); }, function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['tricks', 0], li: 'shake'}]); expect(doc.data).eql({tricks: ['shake', 'fetching stick']}); doc.submitOp([{p: ['tricks', 1, 0], sd: 'fetch'}, {p: ['tricks', 1, 0], si: 'tug'}]); expect(doc.data).eql({tricks: ['shake', 'tuging stick']}); }, function(op, source) { expect(source).equal(true); expect(op).eql([{p: ['tricks', 1, 0], sd: 'fetch'}, {p: ['tricks', 1, 0], si: 'tug'}]); expect(doc.data).eql({tricks: ['shake', 'tuging stick']}); }, function(op, source) { expect(source).equal(false); expect(op).eql([{p: ['tricks', 1, 3], sd: 'ing'}]); expect(doc.data).eql({tricks: ['shake', 'tug stick']}); }, function(op, source) { expect(source).equal(false); expect(op).eql([]); expect(doc.data).eql({tricks: ['shake', 'tug stick']}); } ]; doc.on('op', function(op, source) { var handler = handlers.shift(); handler(op, source); }); var remoteOp = [ {p: ['tricks'], oi: ['fetching']}, {p: ['tricks', 0], li: 'stand'}, {p: ['tricks', 1], li: 'shake'}, {p: ['tricks', 2, 5], sd: 'ing'}, {p: ['tricks', 0], lm: 2} ]; doc2.submitOp(remoteOp, function(err) { if (err) return done(err); doc.fetch(); verifyConsistency(doc, doc2, doc3, handlers, done); }); }); }); describe('submitting ops in callbacks', function() { beforeEach(function() { this.doc = this.connection.get('dogs', 'scooby'); }); it('succeeds with valid op', function(done) { var doc = this.doc; doc.create({name: 'Scooby Doo'}, function(error) { expect(error).to.not.exist; // Build valid op that deletes a substring at index 0 of name. var textOpComponents = [{p: 0, d: 'Scooby '}]; var op = [{p: ['name'], t: 'text0', o: textOpComponents}]; doc.submitOp(op, function(error) { if (error) return done(error); expect(doc.data).eql({name: 'Doo'}); done(); }); }); }); it('fails with invalid op', function(done) { var doc = this.doc; doc.create({name: 'Scooby Doo'}, function(error) { expect(error).to.not.exist; // Build op that tries to delete an invalid substring at index 0 of name. var textOpComponents = [{p: 0, d: 'invalid'}]; var op = [{p: ['name'], t: 'text0', o: textOpComponents}]; doc.submitOp(op, function(error) { expect(error).instanceOf(Error); done(); }); }); }); }); describe('submitting an invalid op', function() { var doc; var invalidOp; var validOp; beforeEach(function(done) { // This op is invalid because we try to perform a list deletion // on something that isn't a list invalidOp = {p: ['name'], ld: 'Scooby'}; validOp = {p: ['snacks'], oi: true}; doc = this.connection.get('dogs', 'scooby'); doc.create({name: 'Scooby'}, function(error) { if (error) return done(error); doc.whenNothingPending(done); }); }); it('returns an error to the submitOp callback', function(done) { doc.submitOp(invalidOp, function(error) { expect(error.message).to.equal('Referenced element not a list'); done(); }); }); it('rolls the doc back to a usable state', function(done) { util.callInSeries([ function(next) { doc.submitOp(invalidOp, function(error) { expect(error).instanceOf(Error); next(); }); }, function(next) { doc.whenNothingPending(next); }, function(next) { expect(doc.data).to.eql({name: 'Scooby'}); doc.submitOp(validOp, next); }, function(next) { expect(doc.data).to.eql({name: 'Scooby', snacks: true}); next(); }, done ]); }); it('rescues an irreversible op collision', function(done) { // This test case attempts to reconstruct the following corner case, with // two independent references to the same document. We submit two simultaneous, but // incompatible operations (eg one of them changes the data structure the other op is // attempting to manipulate). // // The second document to attempt to submit should have its op rejected, and its // state successfully rolled back to a usable state. var doc1 = this.backend.connect().get('dogs', 'snoopy'); var doc2 = this.backend.connect().get('dogs', 'snoopy'); var pauseSubmit = false; var fireSubmit; this.backend.use('submit', function(request, callback) { if (pauseSubmit) { fireSubmit = function() { pauseSubmit = false; callback(); }; } else { fireSubmit = null; callback(); } }); util.callInSeries([ function(next) { doc1.create({colours: ['white']}, next); }, function(next) { doc1.whenNothingPending(next); }, function(next) { doc2.fetch(next); }, function(next) { doc2.whenNothingPending(next); }, // Both documents start off at the same v1 state, with colours as a list function(next) { expect(doc1.data).to.eql({colours: ['white']}); expect(doc2.data).to.eql({colours: ['white']}); next(); }, // doc1 successfully submits an op which changes our list into a string in v2 function(next) { doc1.submitOp({p: ['colours'], oi: 'white,black'}, next); }, // This next step is a little fiddly. We abuse the middleware to pause the op submission and // ensure that we get this repeatable sequence of events: // 1. doc2 is still on v1, where 'colours' is a list (but it's a string in v2) // 2. doc2 submits an op that assumes 'colours' is still a list // 3. doc2 fetches v2 before the op submission completes - 'colours' is no longer a list locally // 4. doc2's op is rejected by the server, because 'colours' is not a list on the server // 5. doc2 attempts to roll back the inflight op by turning a list insertion into a list deletion // 6. doc2 applies this list deletion to a field that is no longer a list // 7. type.apply throws, because this is an invalid op function(next) { pauseSubmit = true; doc2.submitOp({p: ['colours', '0'], li: 'black'}, function(error) { expect(error.message).to.equal('Referenced element not a list'); next(); }); doc2.fetch(function(error) { if (error) return next(error); fireSubmit(); }); }, // Validate that - despite the error in doc2.submitOp - doc2 has been returned to a // workable state in v2 function(next) { expect(doc1.data).to.eql({colours: 'white,black'}); expect(doc2.data).to.eql(doc1.data); doc2.submitOp({p: ['colours'], oi: 'white,black,red'}, next); }, done ]); }); }); });