polyclay-redis
Version:
redis persistence adapter for polyclay, the schema-enforcing document mapper
692 lines (617 loc) • 15.4 kB
JavaScript
/*global describe:true, it:true, before:true, after:true */
var
demand = require('must'),
events = require('events'),
fs = require('fs'),
path = require('path'),
polyclay = require('polyclay'),
redis = require('redis'),
RedisAdapter = require('../index'),
sinon = require('sinon'),
util = require('util')
;
var testDir = process.cwd();
if (path.basename(testDir) !== 'test')
testDir = path.join(testDir, 'test');
var attachmentdata = fs.readFileSync(path.join(testDir, 'test.png'));
describe('redis adapter', function()
{
var modelDefinition = {
properties:
{
key: 'string',
name: 'string',
created: 'date',
foozles: 'array',
snozzers: 'hash',
is_valid: 'boolean',
count: 'number',
required_prop: 'string',
ttl: 'number'
},
optional: [ 'computed', 'ephemeral' ],
required: [ 'name', 'is_valid', 'required_prop'],
singular: 'model',
plural: 'models',
initialize: function()
{
this.ran_init = true;
}
};
var Model, instance, another, hookTest, hookid;
before(function()
{
Model = polyclay.Model.buildClass(modelDefinition);
polyclay.persist(Model);
});
it('can be configured for database access', function()
{
var options =
{
host: 'localhost',
port: 6379,
};
Model.setStorage(options, RedisAdapter);
Model.adapter.must.exist();
Model.adapter.redis.must.exist();
Model.adapter.constructor.must.equal(Model);
Model.adapter.dbname.must.equal(Model.prototype.plural);
});
it('provision does nothing', function(done)
{
Model.provision(function(err)
{
demand(err).not.exist();
done();
});
});
it('throws when asked to save a document without a key', function()
{
var noID = function()
{
var obj = new Model();
obj.name = 'idless';
obj.save(function(err, reply)
{
});
};
noID.must.throw(Error);
});
it('can save a document in the db', function(done)
{
instance = new Model();
instance.update({
key: '1',
name: 'test',
created: Date.now(),
foozles: ['three', 'two', 'one'],
snozzers: { field: 'value' },
is_valid: true,
count: 3,
required_prop: 'requirement met',
computed: 17
});
instance.save(function(err, reply)
{
demand(err).not.exist();
reply.must.exist();
done();
});
});
it('can retrieve the saved document', function(done)
{
Model.get(instance.key, function(err, retrieved)
{
demand(err).not.exist();
retrieved.must.exist();
retrieved.must.be.an.object();
retrieved.key.must.equal(instance.key);
retrieved.name.must.equal(instance.name);
retrieved.created.getTime().must.equal(instance.created.getTime());
retrieved.is_valid.must.equal(instance.is_valid);
retrieved.count.must.equal(instance.count);
retrieved.computed.must.equal(instance.computed);
done();
});
});
it('can update the document', function(done)
{
instance.name = "New name";
instance.isDirty().must.be.true();
instance.save(function(err, response)
{
demand(err).not.exist();
response.must.be.a.string();
response.must.equal('OK');
instance.isDirty().must.equal(false);
done();
});
});
it('can fetch in batches', function(done)
{
var ids = [ instance.key ];
var obj = new Model();
obj.name = 'two';
obj.key = '2';
obj.save(function(err, response)
{
ids.push(obj.key);
Model.get(ids, function(err, itemlist)
{
demand(err).not.exist();
itemlist.must.be.an.array();
itemlist.length.must.equal(2);
done();
});
});
});
it('the adapter get() can handle an id or an array of ids', function(done)
{
var ids = [ '1', '2' ];
Model.adapter.get(ids, function(err, itemlist)
{
demand(err).not.exist();
itemlist.must.be.an.array();
itemlist.length.must.equal(2);
done();
});
});
it('can fetch all', function(done)
{
Model.all(function(err, itemlist)
{
demand(err).not.exist();
itemlist.must.be.an.array();
itemlist.length.must.be.at(2);
done();
});
});
it('constructMany() retuns an empty list when given empty input', function(done)
{
Model.constructMany([], function(err, results)
{
demand(err).not.exist();
results.must.be.an.array();
results.length.must.equal(0);
done();
});
});
it('merge() updates properties then saves the object', function(done)
{
Model.get('2', function(err, item)
{
demand(err).not.exist();
item.merge({ is_valid: true, count: 1023 }, function(err, response)
{
demand(err).not.exist();
Model.get(item.key, function(err, stored)
{
demand(err).not.exist();
stored.count.must.equal(1023);
stored.is_valid.must.equal(true);
stored.name.must.equal(item.name);
done();
});
});
});
});
it('can add an attachment type', function()
{
Model.defineAttachment('frogs', 'text/plain');
Model.defineAttachment('avatar', 'image/png');
instance.set_frogs.must.be.a.function();
instance.fetch_frogs.must.be.a.function();
var property = Object.getOwnPropertyDescriptor(Model.prototype, 'frogs');
property.get.must.be.a.function();
property.set.must.be.a.function();
});
it('can save attachments', function(done)
{
instance.avatar = attachmentdata;
instance.frogs = 'This is bunch of frogs.';
instance.isDirty().must.equal.true;
instance.save(function(err, response)
{
demand(err).not.exist();
instance.isDirty().must.equal.false;
done();
});
});
it('can retrieve attachments', function(done)
{
Model.get(instance.key, function(err, retrieved)
{
retrieved.fetch_frogs(function(err, frogs)
{
demand(err).not.exist();
frogs.must.be.a.string();
frogs.must.equal('This is bunch of frogs.');
retrieved.fetch_avatar(function(err, imagedata)
{
demand(err).not.exist();
imagedata.must.be.instanceof(Buffer);
imagedata.length.must.equal(attachmentdata.length);
done();
});
});
});
});
it('can update an attachment', function(done)
{
instance.frogs = 'Poison frogs are awesome.';
instance.save(function(err, response)
{
demand(err).not.exist();
Model.get(instance.key, function(err, retrieved)
{
demand(err).not.exist();
retrieved.fetch_frogs(function(err, frogs)
{
demand(err).not.exist();
frogs.must.equal(instance.frogs);
retrieved.fetch_avatar(function(err, imagedata)
{
demand(err).not.exist();
imagedata.length.must.equal(attachmentdata.length);
done();
});
});
});
});
});
it('can store an attachment directly', function(done)
{
instance.frogs = 'Poison frogs are awesome, but I think sand frogs are adorable.';
instance.saveAttachment('frogs', function(err, response)
{
demand(err).not.exist();
Model.get(instance.key, function(err, retrieved)
{
demand(err).not.exist();
retrieved.fetch_frogs(function(err, frogs)
{
demand(err).not.exist();
frogs.must.equal(instance.frogs);
done();
});
});
});
});
it('saveAttachment() clears the dirty bit', function(done)
{
instance.frogs = 'This is bunch of frogs.';
instance.isDirty().must.equal(true);
instance.saveAttachment('frogs', function(err, response)
{
demand(err).not.exist();
instance.isDirty().must.equal(false);
done();
});
});
it('can remove an attachment', function(done)
{
instance.removeAttachment('frogs', function(err, deleted)
{
demand(err).not.exist();
deleted.must.be.true();
done();
});
});
it('caches an attachment after it is fetched', function(done)
{
instance.avatar = attachmentdata;
instance.save(function(err, response)
{
demand(err).not.exist();
instance.isDirty().must.be.false();
instance.fetch_avatar(function(err, imagedata)
{
demand(err).not.exist();
var cached = instance.__attachments['avatar'].body;
cached.must.exist();
(cached instanceof Buffer).must.equal(true);
polyclay.dataLength(cached).must.equal(polyclay.dataLength(attachmentdata));
done();
});
});
});
it('can fetch an attachment directly', function(done)
{
Model.adapter.attachment('1', 'avatar', function(err, body)
{
demand(err).not.exist();
(body instanceof Buffer).must.equal(true);
polyclay.dataLength(body).must.equal(polyclay.dataLength(attachmentdata));
done();
});
});
it('removes an attachment when its data is set to null', function(done)
{
instance.avatar = null;
instance.save(function(err, response)
{
demand(err).not.exist();
Model.get(instance.key, function(err, retrieved)
{
demand(err).not.exist();
retrieved.fetch_avatar(function(err, imagedata)
{
demand(err).not.exist();
demand(imagedata).not.exist();
done();
});
});
});
});
it('can remove a document from the db', function(done)
{
instance.destroy(function(err, deleted)
{
demand(err).not.exist();
deleted.must.exist();
instance.destroyed.must.be.true();
done();
});
});
it('can remove documents in batches', function(done)
{
var obj2 = new Model();
obj2.key = '4';
obj2.name = 'two';
obj2.save(function(err, response)
{
Model.get('2', function(err, obj)
{
demand(err).not.exist();
obj.must.be.an.object();
var itemlist = [obj, obj2.key];
Model.destroyMany(itemlist, function(err, response)
{
demand(err).not.exist();
// TODO examine response more carefully
done();
});
});
});
});
it('destroyMany() does nothing when given empty input', function(done)
{
Model.destroyMany(null, function(err)
{
demand(err).not.exist();
done();
});
});
it('destroy responds with an error when passed an object without an id', function(done)
{
var obj = new Model();
obj.destroy(function(err, destroyed)
{
err.must.be.an.object();
err.message.must.equal('cannot destroy object without an id');
done();
});
});
it('destroy responds with an error when passed an object that has already been destroyed', function(done)
{
var obj = new Model();
obj.key = 'foozle';
obj.destroyed = true;
obj.destroy(function(err, destroyed)
{
err.must.be.an.object();
err.message.must.equal('object already destroyed');
done();
});
});
it('removes attachments when removing an object', function(done)
{
var obj = new Model();
obj.key = 'cats';
obj.frogs = 'Cats do not eat frogs.';
obj.name = 'all about cats';
obj.save(function(err, reply)
{
demand(err).not.exist();
reply.must.equal('OK');
obj.destroy(function(err, destroyed)
{
demand(err).not.exist();
var k = Model.adapter.attachmentKey('cats');
Model.adapter.redis.hkeys(k, function(err, reply)
{
demand(err).not.exist();
reply.must.be.an.array();
reply.length.must.equal(0);
done();
});
});
});
});
it('inflate() handles bad json by assigning properties directly', function()
{
var bad =
{
name: 'this is not valid json'
};
var result = Model.adapter.inflate(bad);
result.name.must.equal(bad.name);
});
it('inflate() does not construct an object when given a null payload', function()
{
var result = Model.adapter.inflate(null);
demand(result).be.undefined();
});
it('listens for redis connection errors', function(done)
{
Model.adapter.on('log', function(msg)
{
if (msg.match(/ready/))
{
Model.adapter.removeAllListeners('log');
done();
}
});
Model.adapter.redis.emit('error', new Error('wat'));
});
it('attempts to reconnect until redis is available again', function(done)
{
var count = 0;
function notARedis(port, host)
{
if (++count > 3)
{
stub.restore();
return redis.createClient(port, host);
}
var obj = new events.EventEmitter();
setTimeout(function() { obj.emit('error', new Error('ECONNREFUSED fake')); }, 200);
return obj;
}
var stub = sinon.stub(redis, 'createClient', notARedis);
function handleLog(msg)
{
if (msg.match(/ready/))
{
stub.restore();
Model.adapter.removeAllListeners('log');
done();
}
}
Model.adapter.on('log', handleLog);
Model.adapter.redis.emit('error', new Error('wat'));
});
after(function(done)
{
Model.adapter.redis.del(Model.adapter.idskey(), function(err, deleted)
{
demand(err).not.exist();
done();
});
});
});
describe('ephemeral models', function()
{
var ephemeralDef =
{
properties:
{
key: 'string',
name: 'string',
},
required: [ 'name', ],
singular: 'ephemeral',
plural: 'ephemera',
initialize: function()
{
this.ran_init = true;
}
};
var Ephemeral;
before(function()
{
Ephemeral = polyclay.Model.buildClass(ephemeralDef);
polyclay.persist(Ephemeral);
var options =
{
host: 'localhost',
port: 6379,
ephemeral: true
};
Ephemeral.setStorage(options, RedisAdapter);
});
it('setting the ttl field on an object sets its time to live in redis', function(done)
{
var obj = new Ephemeral();
obj.key = 'mayfly';
obj.name = 'George';
obj.ttl = 2;
obj.save(function(err, reply)
{
demand(err).not.exist();
reply.must.equal('OK');
var okey = Ephemeral.adapter.hashKey(obj.key);
Ephemeral.adapter.redis.ttl(okey, function(err, response)
{
demand(err).not.exist();
var ttl = parseInt(response, 10);
ttl.must.be.a.number();
ttl.must.be.below(4);
setTimeout(function()
{
Ephemeral.adapter.redis.exists(okey, function(err, exists)
{
demand(err).not.exist();
exists.must.equal(0);
done();
});
}, ttl * 1000 + 100);
});
});
});
it('setting the expire_at field on an object sets its time to live in redis', function(done)
{
var obj = new Ephemeral();
obj.key = 'mayfly2';
obj.name = 'Fred';
var expireAt = Date.now()/1000 + 2;
obj.expire_at = expireAt;
obj.save(function(err, reply)
{
demand(err).not.exist();
reply.must.equal('OK');
Ephemeral.get(obj.key, function(err, model)
{
demand(err).not.exist();
model.must.have.property('ttl');
model.must.have.property('expire_at');
var ttl = model.ttl;
ttl.must.be.a.number();
ttl.must.be.below(3);
model.expire_at.must.be.a.number();
model.expire_at.must.be.at.most(expireAt);
setTimeout(function()
{
var okey = Ephemeral.adapter.hashKey(model.key);
Ephemeral.adapter.redis.exists(okey, function(err, exists)
{
demand(err).not.exist();
exists.must.equal(0);
done();
});
}, ttl * 1000 + 500);
});
});
});
it('updating an object with a ttl preserves the ttl', function(done)
{
var obj = new Ephemeral();
obj.key = 'mayfly2';
obj.name = 'Fred';
var start = Date.now();
var expires = start + 5000;
obj.expire_at = expires/1000;
obj.save(function(err, reply)
{
demand(err).not.exist();
reply.must.equal('OK');
Ephemeral.get(obj.key, function(err, obj)
{
demand(err).not.exist();
obj.expire_at.must.be.below(expires / 1000 + 1);
obj.name = 'weasel';
obj.save(function(err, reply)
{
demand(err).not.exist();
var okey = Ephemeral.adapter.hashKey(obj.key);
Ephemeral.adapter.redis.ttl(okey, function(err, timeleft)
{
demand(err).not.exist();
(+timeleft).must.be.below(obj.ttl + 1);
done();
});
});
});
});
});
});