falcor
Version:
A JavaScript library for efficient data fetching.
845 lines (767 loc) • 33.7 kB
JavaScript
var falcor = require("./../../../lib/");
var Model = falcor.Model;
var Rx = require('rx');
var noOp = function() {};
var LocalDataSource = require('../../data/LocalDataSource');
var Observable = Rx.Observable;
var strip = require('./../../cleanData').stripDerefAndVersionKeys;
var cacheGenerator = require('./../../CacheGenerator');
var jsonGraph = require('falcor-json-graph');
var toObservable = require('../../toObs');
var M = function(m) {
return cacheGenerator(0, 1);
};
var Cache = function(c) {
return cacheGenerator(0, 40);
};
describe('DataSource and Partial Cache', function() {
it('should onNext only once even if a subset of the requested values is found in the cache', function(done) {
var model = new Model({
cache: {
paths: {
0: 'test',
1: 'test'
}
},
source: new LocalDataSource({
paths: {
2: Model.atom('test'),
3: Model.atom(undefined)
}
}, {materialize: true})
});
var onNextCount = 0;
toObservable(model.
get(['paths', {to:3}])).
doAction(function(value) {
onNextCount++;
if (onNextCount === 1){
expect(strip(value)).toEqual({
json: {
paths: {
0: 'test',
1: 'test',
2: 'test'
}
}
});
}
}).subscribe(noOp, done, function(){
expect(onNextCount).toBe(1);
done();
});
});
describe('Preload Functions', function() {
it('should get multiple arguments with multiple selector function args.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
var secondOnNext = jest.fn();
toObservable(model.
preload(['videos', 0, 'title'], ['videos', 1, 'title'])).
doAction(onNext, noOp, function() {
expect(onNext).not.toHaveBeenCalled();
}).
defaultIfEmpty({}).
flatMap(function() {
return model.get(['videos', 0, 'title'], ['videos', 1, 'title']);
}).
doAction(secondOnNext, noOp, function() {
expect(secondOnNext).toHaveBeenCalledTimes(1);
expect(strip(secondOnNext.mock.calls[0][0])).toEqual({
json: {
videos: {
0: {
title: 'Video 0'
},
1: {
title: 'Video 1'
}
}
}
});
}).
subscribe(noOp, done, done);
});
it('should get a complex argument into a single arg.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
var secondOnNext = jest.fn();
toObservable(model.
preload(['lolomo', 0, {to: 1}, 'item', 'title'])).
doAction(onNext).
doAction(noOp, noOp, function() {
expect(onNext).not.toHaveBeenCalled();
}).
defaultIfEmpty({}).
flatMap(function() {
return model.get(['lolomo', 0, {to: 1}, 'item', 'title']);
}).
doAction(secondOnNext, noOp, function() {
expect(secondOnNext).toHaveBeenCalledTimes(1);
expect(strip(secondOnNext.mock.calls[0][0])).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}).
subscribe(noOp, done, done);
});
});
describe('PathMap', function() {
it('should ensure empty paths do not cause dataSource requests {from:1, to:0}', function(done) {
var onGet = jest.fn();
var model = new Model({
cache: { b: {} },
source: new LocalDataSource({}, { onGet: onGet })
});
var modelGet = model.get(['b', { from: 1, to: 0 }, 'leaf']);
var onNext = jest.fn();
toObservable(modelGet).
doAction(onNext, noOp, function() {
expect(onGet).not.toHaveBeenCalled();
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: { b: {} }
});
}).
subscribe(noOp, done, done);
});
it('should ensure empty paths do not cause dataSource requests [].', function(done) {
var onGet = jest.fn();
var model = new Model({
cache: { b: {} },
source: new LocalDataSource({}, { onGet: onGet })
});
var modelGet = model.get(['b', [], 'leaf']);
var onNext = jest.fn();
toObservable(modelGet).
doAction(onNext, noOp, function() {
expect(onGet).not.toHaveBeenCalled();
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: { b: {} }
});
}).
subscribe(noOp, done, done);
});
it('should get multiple arguments into a single toJSON response.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
toObservable(model.
get(['lolomo', 0, 0, 'item', 'title'], ['lolomo', 0, 1, 'item', 'title'])).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}).
subscribe(noOp, done, done);
});
it('should get a complex argument into a single arg.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
toObservable(model.
get(['lolomo', 0, {to: 1}, 'item', 'title'])).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}).
subscribe(noOp, done, done);
});
it('should get a complex argument into a single arg and collect to max cache size.', function(done) {
var model = new Model({
cache: M(),
source: new LocalDataSource(Cache()),
maxSize: 0
});
var cache = model._root.cache;
var onNext = jest.fn();
toObservable(model.
get(['lolomo', 0, {to: 1}, 'item', 'title'])).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}).
finally(function() {
expect(cache['$size']).toBe(0);
done();
}).
subscribe(noOp, done, noOp);
});
it('should ensure that a response where only materialized atoms come ' +
'through still onNexts a value if one is present in cache.', function(done) {
var model = new Model({
cache: {
paths: {
0: 'test',
1: 'test'
}
},
source: new LocalDataSource({
paths: {
2: Model.atom(undefined),
3: Model.atom(undefined)
}
}, {materialize: true})
});
var onNext = jest.fn();
toObservable(model.
get(['paths', {to:3}])).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
expect(strip(onNext.mock.calls[0][0])).toEqual({
json: {
paths: {
0: 'test',
1: 'test'
}
}
});
}).
subscribe(noOp, done, done);
});
});
describe('_toJSONG', function() {
it('should get multiple arguments into a single _toJSONG response.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
toObservable(model.
get(['lolomo', 0, 0, 'item', 'title'], ['lolomo', 0, 1, 'item', 'title']).
_toJSONG()).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
var out = strip(onNext.mock.calls[0][0]);
var expected = strip({
jsonGraph: cacheGenerator(0, 2),
paths: [['lolomo', 0, 0, 'item', 'title'],
['lolomo', 0, 1, 'item', 'title']]
});
expect(out).toEqual(expected);
}).
subscribe(noOp, done, done);
});
it('should get a complex argument into a single arg.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var onNext = jest.fn();
toObservable(model.
get(['lolomo', 0, {to: 1}, 'item', 'title']).
_toJSONG()).
doAction(onNext, noOp, function() {
expect(onNext).toHaveBeenCalledTimes(1);
var out = strip(onNext.mock.calls[0][0]);
var expected = strip({
jsonGraph: cacheGenerator(0, 2),
paths: [['lolomo', 0, 0, 'item', 'title'],
['lolomo', 0, 1, 'item', 'title']]
});
expect(out).toEqual(expected);
}).
subscribe(noOp, done, done);
});
});
describe('Progressively', function() {
it('should onNext twice if at least one value found in the cache - even if it is an atom of undefined', function(done) {
var model = new Model({
cache: {
paths: {
0: 'test',
1: 'test'
}
},
source: new LocalDataSource({
paths: {
2: Model.atom('test'),
3: Model.atom(undefined)
}
}, {materialize: true})
});
var onNextCount = 0;
toObservable(model.
get(['paths', {to:3}]).
progressively()).
doAction(function(value) {
onNextCount++;
if (onNextCount === 1){
expect(strip(value)).toEqual({
json: {
paths: {
0: 'test',
1: 'test'
}
}
});
}
else if (onNextCount === 2){
expect(strip(value)).toEqual({
json: {
paths: {
0: 'test',
1: 'test',
2: 'test'
}
}
});
}
}).subscribe(noOp, done, function(){
expect(onNextCount).toBe(2);
done();
});
});
it('should get multiple arguments with multiple trips to the dataSource into a single toJSON response.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var count = 0;
toObservable(model.
get(['lolomo', 0, 0, 'item', 'title'], ['lolomo', 0, 1, 'item', 'title']).
progressively()).
doAction(function(x) {
count++;
if (count === 1) {
expect(strip(x)).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
}
}
}
}
});
} else {
expect(strip(x)).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}
}, noOp, function() {
expect(count).toBe(2);
}).
subscribe(noOp, done, done);
});
it('should get complex path with multiple trips to the dataSource into a single toJSON response.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var count = 0;
toObservable(model.
get(['lolomo', 0, {to: 1}, 'item', 'title']).
progressively()).
doAction(function(x) {
count++;
if (count === 1) {
expect(strip(x)).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
}
}
}
}
});
} else {
expect(strip(x)).toEqual({
json: {
lolomo: {
0: {
0: {
item: {
title: 'Video 0'
}
},
1: {
item: {
title: 'Video 1'
}
}
}
}
}
});
}
}, noOp, function() {
expect(count).toBe(2);
}).
subscribe(noOp, done, done);
});
it('should get different response objects with multiple trips to the dataSource.', function(done) {
var model = new Model({cache: M(), source: new LocalDataSource(Cache())});
var revisions = [];
toObservable(model.
get(['lolomo', 0, 0, 'item', 'title'], ['lolomo', 0, 1, 'item', 'title']).
progressively()).
doAction(function(x) {
revisions.push(x);
}, noOp, function() {
expect(revisions.length).toBe(2);
expect(revisions[1]).not.toBe(revisions[0]);
expect(revisions[1].json.lolomo[0]).not.toBe(revisions[0].json.lolomo[0]);
expect(revisions[1].json.lolomo[0][0]).toBe(revisions[0].json.lolomo[0][0]);
}).
subscribe(noOp, done, done);
});
});
describe('Error Selector (during merge)', function() {
function generateErrorSelectorSpy(expectedPath) {
return jest.fn(function(path, atom) {
// Needs to be asserted before mutation.
expect(atom.$type).toBe('error');
expect(atom.value).toEqual({message:'errormsg'});
atom.$custom = 'custom';
atom.value.customtype = 'customtype';
return atom;
});
}
function assertExpectedErrorPayload(e, expectedPath) {
var path = e.path;
var value = e.value;
// To avoid hardcoding/scrubbing $size, and other internals
expect(path).toEqual(expectedPath);
expect(value.$type).toBe('error');
expect(value.$custom).toBe('custom');
expect(value.value).toEqual({
message: 'errormsg',
customtype: 'customtype'
});
}
it('should get invoked with the right arguments for branches in cache', function(done) {
// Cache has [lolomo,0,0,item]
var testPath = ['lolomo',0,0,'item','errorPath'];
var modelCache = M();
var dataSourceCache = Cache();
// [lolomo,0,0,item]->[videos,0]
dataSourceCache.videos[0].errorPath = jsonGraph.error({message:'errormsg'});
var onNextSpy = jest.fn();
var onErrorSpy = jest.fn();
var errorSelectorSpy = generateErrorSelectorSpy(testPath);
var model = new Model({
cache: modelCache,
source: new LocalDataSource(dataSourceCache),
errorSelector: errorSelectorSpy
});
toObservable(model.
boxValues().
get(testPath)).
doAction(onNextSpy, onErrorSpy, noOp).
subscribe(
noOp,
function(e) {
expect(errorSelectorSpy).toHaveBeenCalledTimes(1);
expect(errorSelectorSpy.mock.calls[0][0]).toEqual(testPath);
expect(onErrorSpy).toHaveBeenCalledTimes(1);
expect(e.length).toBe(1);
assertExpectedErrorPayload(e[0], testPath);
done();
},
function() {
expect(onNextSpy).toHaveBeenCalledTimes(1);
expect(strip(onNextSpy.mock.calls[0][0])).toEqual({
json: {}
});
expect(onErrorSpy).toHaveBeenCalledTimes(1);
done();
});
});
it('should get invoked with the right arguments for branches not in cache', function(done) {
// Cache doesn't have [lolomo,1,0,item]
var testPath = ['lolomo',1,0,'item','errorPath'];
var modelCache = M();
var dataSourceCache = Cache();
// [lolomo,1,0,item]->[videos,10]
dataSourceCache.videos[10].errorPath = jsonGraph.error({message:'errormsg'});
var onNextSpy = jest.fn();
var onErrorSpy = jest.fn();
var errorSelectorSpy = generateErrorSelectorSpy(testPath);
var model = new Model({
cache: modelCache,
source: new LocalDataSource(dataSourceCache),
errorSelector: errorSelectorSpy
});
toObservable(model.
boxValues().
get(testPath)).
doAction(onNextSpy, onErrorSpy, noOp).
subscribe(
noOp,
function(e) {
expect(errorSelectorSpy).toHaveBeenCalledTimes(1);
expect(errorSelectorSpy.mock.calls[0][0]).toEqual(testPath);
expect(onErrorSpy).toHaveBeenCalledTimes(1);
expect(e.length).toBe(1);
assertExpectedErrorPayload(e[0], testPath);
done();
},
function() {
expect(onNextSpy).not.toHaveBeenCalled();
expect(onErrorSpy).toHaveBeenCalledTimes(1);
done();
});
});
it('should get invoked with the correct error paths for a keyset', function(done) {
var testPath = ['lolomo',[0,1],0,'item','errorPath'];
var modelCache = M();
var dataSourceCache = Cache();
dataSourceCache.videos[0].errorPath = jsonGraph.error({message:'errormsg'});
dataSourceCache.videos[10].errorPath = jsonGraph.error({message:'errormsg'});
var onNextSpy = jest.fn();
var onErrorSpy = jest.fn();
var errorSelectorSpy = generateErrorSelectorSpy(testPath);
var model = new Model({
cache: modelCache,
source: new LocalDataSource(dataSourceCache),
errorSelector: errorSelectorSpy
});
toObservable(model.
boxValues().
get(testPath)).
doAction(onNextSpy, onErrorSpy, noOp).
subscribe(
noOp,
function(e) {
expect(onErrorSpy).toHaveBeenCalledTimes(1);
expect(errorSelectorSpy).toHaveBeenCalledTimes(2);
expect(errorSelectorSpy.mock.calls[0][0]).toEqual(['lolomo',0,0,'item','errorPath']);
expect(errorSelectorSpy.mock.calls[1][0]).toEqual(['lolomo',1,0,'item','errorPath']);
expect(e.length).toBe(2);
assertExpectedErrorPayload(e[0], ['lolomo',0,0,'item','errorPath']);
assertExpectedErrorPayload(e[1], ['lolomo',1,0,'item','errorPath']);
done();
},
function() {
expect(onNextSpy).not.toHaveBeenCalled();
expect(onErrorSpy).toHaveBeenCalledTimes(1);
done();
});
});
it('should be allowed to change $type', function(done) {
var testPath = ['lolomo',0,0,'item','errorPath'];
var modelCache = M();
var dataSourceCache = Cache();
// [lolomo,0,0,item]->[videos,0]
dataSourceCache.videos[0].errorPath = jsonGraph.error({message:'errormsg'});
var onNextSpy = jest.fn();
var onErrorSpy = jest.fn();
var model = new Model({
cache : modelCache,
source: new LocalDataSource(dataSourceCache),
errorSelector : function(path, atom) {
var o = {
$type: 'atom',
$custom: 'custom',
value: {
message: atom.value.message,
customtype: 'customtype'
}
};
return o;
}
});
toObservable(model.
boxValues().
setValue(testPath, jsonGraph.error({message:'errormsg'}))).
doAction(onNextSpy, onErrorSpy, noOp).
subscribe(
noOp,
function(e) {
expect(onErrorSpy).not.toHaveBeenCalled();
done();
},
function() {
expect(onErrorSpy).not.toHaveBeenCalled();
expect(onNextSpy).toHaveBeenCalledTimes(1);
expect(onNextSpy.mock.calls[0][0]).toEqual({
$type: 'atom',
$custom: 'custom',
value: {
message: 'errormsg',
customtype: 'customtype'
},
$size:51
});
done();
});
});
it('should safely merge references over existing branches', function(done) {
var dataSource = new LocalDataSource({"shows": {"80025172": {"seasons": {"current": {"$type": "ref","value": ["seasons","80025272"],"$size": 52}}}},"seasons": {"80025272": {"episodes": {"0": {"$type": "ref","value": ["episodes","80025313"],"$size": 52}}}},"episodes": {"80025313": {"currentUser": {"$type": "ref","value": ["currentUser"],"$size": 51}}},"currentUser": {"localized": {"preferences": {"$type": "atom","value": {"languages": ["en"],"direction": ["ltr"]},"$size": 51}},"stringTable": {"$type": "ref","value": ["stringTables","en"],"$size": 52}},"stringTables": {"en": {"detailsPopup": {"expired": {"$type": "atom","value": "Expired","$size": 57}}}}});
var originalGet = dataSource.get;
dataSource.get = function() {
return Rx.Observable.throw({
$type: 'error',
value: {
status: 404,
"message": "Timed out"
}
});
};
var model = new Model({
_treatDataSourceErrorsAsJSONGraphErrors: true,
source: dataSource,
errorSelector : function(path, atom) {
var isError = path.indexOf('stringTable') !== -1;
var o = {
$type: !isError ? 'atom' : 'error',
value: {
message: atom.value.message,
customtype: 'customtype'
}
};
return o;
}
});
var fetch = toObservable(model.
get(
["shows",80025172,"seasons","current","episodes",0,"currentUser","localized","preferences"],
["shows",80025172,"seasons","current","episodes",0,"currentUser","stringTable","detailsPopup","expired"]
));
var onNext = jest.fn();
fetch.
delay(1).
catch(function(_) {
dataSource.get = originalGet;
model.invalidate(["shows",80025172,"seasons","current","episodes",0,"currentUser","stringTable","detailsPopup","expired"])
return fetch;
}).
doAction(onNext).
subscribe(noOp, done,
function() {
var expected = ['currentUser', 'localized'];
expect(model._root.cache.currentUser.localized.$_absolutePath).toEqual(expected);
expect(onNext.mock.calls[0][0].json.shows[80025172].seasons.current.episodes[0].currentUser.localized.$__path).toEqual(expected);
done();
});
});
});
describe("Cached data with timestamp", function() {
var t0 = Date.parse('2000/01/01');
var t1 = t0 + 1;
function remoteData() {
return {
videos: {
1: {
bookmark: Model.atom('remote value', {$timestamp: t0})
},
2: {
previous: Model.ref(['videos', 1])
}
}
};
}
it("should not be replaced by data with an older timestamp", function(done) {
var cache = {
videos: {
1: {
bookmark: Model.atom('cached value', {$timestamp: t1})
}
}
};
var source = new LocalDataSource(remoteData());
var model = new Model({cache: cache, source: source});
model.getValue(['videos', 2, 'previous', 'bookmark']).
then(function(value) {
expect(value).toBe('cached value');
done();
}).
catch(function(e) {
done(e);
});
});
it("when expired should be replaced by data with an older timestamp", function(done) {
var cache = {
videos: {
1: {
bookmark: Model.atom('cached value', {$timestamp: t1, $expires: t1})
}
}
};
var source = new LocalDataSource(remoteData());
var model = new Model({cache: cache, source: source});
model.getValue(['videos', 2, 'previous', 'bookmark']).
then(function(value) {
expect(value).toBe('remote value');
done();
}).
catch(function(e) {
done(e);
});
});
});
});