sharedb
Version:
JSON OT database backend
425 lines (394 loc) • 15.6 kB
JavaScript
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
]);
});
});
});