Commit 122da4b3 authored by Romain Courteaud's avatar Romain Courteaud

Rewrite replicateStorage.

Synchronisation status is based on document signature (sha) comparison.
Signature are stored each time a document is successfully synchronised.

Conflict are detected but reported as an error for now, as no solve process is implemented.
parent ece651db
......@@ -171,6 +171,9 @@ module.exports = function (grunt) {
'src/jio.js',
'node_modules/rusha/rusha.js',
'src/jio.storage/replicatestorage.js',
'src/jio.storage/uuidstorage.js',
'src/jio.storage/memorystorage.js',
'src/jio.storage/localstorage.js',
......
......@@ -31,7 +31,8 @@
"dependencies": {
"rsvp": "git+http://git.erp5.org/repos/rsvp.js.git",
"uritemplate": "git+http://git.erp5.org/repos/uritemplate-js.git",
"moment": "2.8.3"
"moment": "2.8.3",
"rusha": "0.8.2"
},
"devDependencies": {
"renderjs": "git+http://git.erp5.org/repos/renderjs.git",
......
/*
* JIO extension for resource replication.
* Copyright (C) 2013 Nexedi SA
* Copyright (C) 2013, 2015 Nexedi SA
*
* This library is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
......@@ -16,405 +16,314 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*jslint indent: 2, maxlen: 80, nomen: true */
/*global define, module, require, jIO, RSVP */
/*jslint nomen: true*/
/*global jIO, RSVP, Rusha*/
(function (factory) {
(function (jIO, RSVP, Rusha) {
"use strict";
if (typeof define === 'function' && define.amd) {
return define(["jio", "rsvp"], function () {
return factory(require);
});
}
if (typeof require === 'function') {
module.exports = factory(require);
return;
}
factory(function (name) {
return {
"jio": jIO,
"rsvp": RSVP
}[name];
});
}(function (require) {
"use strict";
var Promise = require('rsvp').Promise,
all = require('rsvp').all,
addStorageFunction = require('jio').addStorage,
uniqueJSONStringify = require('jio').util.uniqueJSONStringify;
function success(promise) {
return promise.then(null, function (reason) { return reason; });
}
/**
* firstFulfilled(promises): promises< last_fulfilment_value >
*
* Responds with the first resolved promise answer recieved. If all promises
* are rejected, it returns the latest rejected promise answer
* received. Promises are cancelled only by calling
* `firstFulfilled(promises).cancel()`.
*
* @param {Array} promises An array of promises
* @return {Promise} A new promise
*/
function firstFulfilled(promises) {
var length = promises.length;
function onCancel() {
var i, l, promise;
for (i = 0, l = promises.length; i < l; i += 1) {
promise = promises[i];
if (typeof promise.cancel === "function") {
promise.cancel();
}
}
}
var rusha = new Rusha();
return new Promise(function (resolve, reject, notify) {
var i, count = 0;
function resolver(answer) {
resolve(answer);
onCancel();
}
function rejecter(answer) {
count += 1;
if (count === length) {
return reject(answer);
}
}
/****************************************************
Use a local jIO to read/write/search documents
Synchronize in background those document with a remote jIO.
Synchronization status is stored for each document as an local attachment.
****************************************************/
for (i = 0; i < length; i += 1) {
promises[i].then(resolver, rejecter, notify);
}
}, onCancel);
function generateHash(content) {
// XXX Improve performance by moving calculation to WebWorker
return rusha.digestFromString(content);
}
// //////////////////////////////////////////////////////////////////////
// /**
// * An Universal Unique ID generator
// *
// * @return {String} The new UUID.
// */
// function generateUuid() {
// function S4() {
// return ('0000' + Math.floor(
// Math.random() * 0x10000 /* 65536 */
// ).toString(16)).slice(-4);
// }
// return S4() + S4() + "-" +
// S4() + "-" +
// S4() + "-" +
// S4() + "-" +
// S4() + S4() + S4();
// }
function ReplicateStorage(spec) {
if (!Array.isArray(spec.storage_list)) {
throw new TypeError("ReplicateStorage(): " +
"storage_list is not of type array");
}
this._storage_list = spec.storage_list;
this._local_sub_storage = jIO.createJIO(spec.local_sub_storage);
this._remote_sub_storage = jIO.createJIO(spec.remote_sub_storage);
this._signature_hash = "_replicate_" + generateHash(
JSON.stringify(spec.local_sub_storage) +
JSON.stringify(spec.remote_sub_storage)
);
this._signature_sub_storage = jIO.createJIO({
type: "document",
document_id: this._signature_hash,
sub_storage: spec.local_sub_storage
});
}
ReplicateStorage.prototype.syncGetAnswerList = function (command,
answer_list) {
var i, l, answer, answer_modified_date, winner, winner_modified_date,
winner_str, promise_list = [], winner_index, winner_id;
/*jslint continue: true */
for (i = 0, l = answer_list.length; i < l; i += 1) {
answer = answer_list[i];
if (!answer || answer === 404) { continue; }
if (!winner) {
winner = answer;
winner_index = i;
winner_modified_date = new Date(answer.data.modified).getTime();
} else {
answer_modified_date = new Date(answer.data.modified).getTime();
if (isFinite(answer_modified_date) &&
answer_modified_date > winner_modified_date) {
winner = answer;
winner_index = i;
winner_modified_date = answer_modified_date;
}
}
}
winner = winner.data;
if (!winner) { return; }
// winner_attachments = winner._attachments;
delete winner._attachments;
winner_id = winner._id;
winner_str = uniqueJSONStringify(winner);
// document synchronisation
for (i = 0, l = answer_list.length; i < l; i += 1) {
answer = answer_list[i];
if (!answer) { continue; }
if (i === winner_index) { continue; }
if (answer === 404) {
delete winner._id;
promise_list.push(success(
command.storage(this._storage_list[i]).post(winner)
));
winner._id = winner_id;
// delete _id AND reassign _id -> avoid modifying document before
// resolving the get method.
continue;
}
delete answer._attachments;
if (uniqueJSONStringify(answer.data) !== winner_str) {
promise_list.push(success(
command.storage(this._storage_list[i]).put(winner)
));
}
ReplicateStorage.prototype.remove = function (id) {
if (id === this._signature_hash) {
throw new jIO.util.jIOError(this._signature_hash + " is frozen",
403);
}
return all(promise_list);
// XXX .then synchronize attachments
return this._local_sub_storage.remove.apply(this._local_sub_storage,
arguments);
};
ReplicateStorage.prototype.post = function (command, metadata, option) {
var promise_list = [], index, length = this._storage_list.length;
// if (!isDate(metadata.modified)) {
// command.error(
// 409,
// "invalid 'modified' metadata",
// "The metadata 'modified' should be a valid date string or date object"
// );
// return;
// }
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).post(metadata, option);
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
ReplicateStorage.prototype.post = function () {
return this._local_sub_storage.post.apply(this._local_sub_storage,
arguments);
};
ReplicateStorage.prototype.put = function (command, metadata, option) {
var promise_list = [], index, length = this._storage_list.length;
// if (!isDate(metadata.modified)) {
// command.error(
// 409,
// "invalid 'modified' metadata",
// "The metadata 'modified' should be a valid date string or date object"
// );
// return;
// }
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).put(metadata, option);
ReplicateStorage.prototype.put = function (id) {
if (id === this._signature_hash) {
throw new jIO.util.jIOError(this._signature_hash + " is frozen",
403);
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
return this._local_sub_storage.put.apply(this._local_sub_storage,
arguments);
};
ReplicateStorage.prototype.putAttachment = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).putAttachment(param, option);
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
ReplicateStorage.prototype.get = function () {
return this._local_sub_storage.get.apply(this._local_sub_storage,
arguments);
};
ReplicateStorage.prototype.remove = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).remove(param, option);
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
ReplicateStorage.prototype.hasCapacity = function () {
return this._local_sub_storage.hasCapacity.apply(this._local_sub_storage,
arguments);
};
ReplicateStorage.prototype.removeAttachment = function (
command,
param,
option
) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).
removeAttachment(param, option);
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
ReplicateStorage.prototype.buildQuery = function () {
// XXX Remove signature document?
return this._local_sub_storage.buildQuery.apply(this._local_sub_storage,
arguments);
};
/**
* Respond with the first get answer received and synchronize the document to
* the other storages in the background.
*/
ReplicateStorage.prototype.get = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length,
answer_list = [], this_ = this;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).get(param, option);
ReplicateStorage.prototype.repair = function () {
var context = this,
argument_list = arguments,
skip_document_dict = {};
// Do not sync the signature document
skip_document_dict[context._signature_hash] = null;
function propagateModification(destination, doc, hash, id) {
return destination.put(id, doc)
.push(function () {
return context._signature_sub_storage.put(id, {
"hash": hash
});
})
.push(function () {
skip_document_dict[id] = null;
});
}
new Promise(function (resolve, reject, notify) {
var count = 0, error_count = 0;
function resolver(index) {
return function (answer) {
count += 1;
if (count === 1) {
resolve(answer);
function checkLocalCreation(queue, source, destination, id) {
var remote_doc;
queue
.push(function () {
return destination.get(id);
})
.push(function (doc) {
remote_doc = doc;
}, function (error) {
if ((error instanceof jIO.util.jIOError) &&
(error.status_code === 404)) {
// This document was never synced.
// Push it to the remote storage and store sync information
return;
}
answer_list[index] = answer;
if (count + error_count === length && count > 0) {
this_.syncGetAnswerList(command, answer_list);
throw error;
})
.push(function () {
// This document was never synced.
// Push it to the remote storage and store sync information
return source.get(id);
})
.push(function (doc) {
var local_hash = generateHash(JSON.stringify(doc)),
remote_hash;
if (remote_doc === undefined) {
return propagateModification(destination, doc, local_hash, id);
}
};
}
function rejecter(index) {
return function (reason) {
error_count += 1;
if (reason.status === 404) {
answer_list[index] = 404;
remote_hash = generateHash(JSON.stringify(remote_doc));
if (local_hash === remote_hash) {
// Same document
return context._signature_sub_storage.put(id, {
"hash": local_hash
})
.push(function () {
skip_document_dict[id] = null;
});
}
if (error_count === length) {
reject(reason);
}
if (count + error_count === length && count > 0) {
this_.syncGetAnswerList(command, answer_list);
}
};
}
for (index = 0; index < length; index += 1) {
promise_list[index].then(resolver(index), rejecter(index), notify);
}
}, function () {
for (index = 0; index < length; index += 1) {
promise_list[index].cancel();
}
}).then(command.success, command.error, command.notify);
};
// Already exists on destination
throw new jIO.util.jIOError("Conflict on '" + id + "'",
409);
});
}
ReplicateStorage.prototype.getAttachment = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).getAttachment(param, option);
function checkLocalDeletion(queue, destination, id, source) {
var status_hash;
queue
.push(function () {
return context._signature_sub_storage.get(id);
})
.push(function (result) {
status_hash = result.hash;
return destination.get(id)
.push(function (doc) {
var remote_hash = generateHash(JSON.stringify(doc));
if (remote_hash === status_hash) {
return destination.remove(id)
.push(function () {
return context._signature_sub_storage.remove(id);
})
.push(function () {
skip_document_dict[id] = null;
});
}
// Modifications on remote side
// Push them locally
return propagateModification(source, doc, remote_hash, id);
}, function (error) {
if ((error instanceof jIO.util.jIOError) &&
(error.status_code === 404)) {
return context._signature_sub_storage.remove(id)
.push(function () {
skip_document_dict[id] = null;
});
}
throw error;
});
});
}
firstFulfilled(promise_list).
then(command.success, command.error, command.notify);
};
ReplicateStorage.prototype.allDocs = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
success(command.storage(this._storage_list[index]).allDocs(option));
function checkSignatureDifference(queue, source, destination, id) {
queue
.push(function () {
return RSVP.all([
source.get(id),
context._signature_sub_storage.get(id)
]);
})
.push(function (result_list) {
var doc = result_list[0],
local_hash = generateHash(JSON.stringify(doc)),
status_hash = result_list[1].hash;
if (local_hash !== status_hash) {
// Local modifications
return destination.get(id)
.push(function (remote_doc) {
var remote_hash = generateHash(JSON.stringify(remote_doc));
if (remote_hash !== status_hash) {
// Modifications on both sides
if (local_hash === remote_hash) {
// Same modifications on both side \o/
return context._signature_sub_storage.put(id, {
"hash": local_hash
})
.push(function () {
skip_document_dict[id] = null;
});
}
throw new jIO.util.jIOError("Conflict on '" + id + "'",
409);
}
return propagateModification(destination, doc, local_hash, id);
}, function (error) {
if ((error instanceof jIO.util.jIOError) &&
(error.status_code === 404)) {
// Document has been deleted remotely
return propagateModification(destination, doc, local_hash,
id);
}
throw error;
});
}
});
}
all(promise_list).then(function (answers) {
// merge responses
var i, j, k, found, rows;
// browsing answers
for (i = 0; i < answers.length; i += 1) {
if (answers[i].result === "success") {
rows = answers[i].data.rows;
break;
}
}
for (i += 1; i < answers.length; i += 1) {
if (answers[i].result === "success") {
// browsing answer rows
for (j = 0; j < answers[i].data.rows.length; j += 1) {
found = false;
// browsing result rows
for (k = 0; k < rows.length; k += 1) {
if (rows[k].id === answers[i].data.rows[j].id) {
found = true;
break;
function pushStorage(source, destination) {
var queue = new RSVP.Queue();
return queue
.push(function () {
return RSVP.all([
source.allDocs(),
context._signature_sub_storage.allDocs()
]);
})
.push(function (result_list) {
var i,
local_dict = {},
signature_dict = {},
key;
for (i = 0; i < result_list[0].data.total_rows; i += 1) {
if (!skip_document_dict.hasOwnProperty(
result_list[0].data.rows[i].id
)) {
local_dict[result_list[0].data.rows[i].id] = i;
}
}
for (i = 0; i < result_list[1].data.total_rows; i += 1) {
if (!skip_document_dict.hasOwnProperty(
result_list[1].data.rows[i].id
)) {
signature_dict[result_list[1].data.rows[i].id] = i;
}
}
for (key in local_dict) {
if (local_dict.hasOwnProperty(key)) {
if (!signature_dict.hasOwnProperty(key)) {
checkLocalCreation(queue, source, destination, key);
}
}
if (!found) {
rows.push(answers[i].data.rows[j]);
}
for (key in signature_dict) {
if (signature_dict.hasOwnProperty(key)) {
if (local_dict.hasOwnProperty(key)) {
checkSignatureDifference(queue, source, destination, key);
} else {
checkLocalDeletion(queue, destination, key, source);
}
}
}
}
}
return {"data": {"total_rows": (rows || []).length, "rows": rows || []}};
}).then(command.success, command.error, command.notify);
/*jslint unparam: true */
};
ReplicateStorage.prototype.check = function (command, param, option) {
var promise_list = [], index, length = this._storage_list.length;
for (index = 0; index < length; index += 1) {
promise_list[index] =
command.storage(this._storage_list[index]).check(param, option);
}
return all(promise_list).
then(function () { return; }).
then(command.success, command.error, command.notify);
};
ReplicateStorage.prototype.repair = function (command, param, option) {
var storage_list = this._storage_list, length = storage_list.length,
this_ = this;
if (typeof param._id !== 'string' || !param._id) {
command.error("bad_request");
return;
}
storage_list = storage_list.map(function (description) {
return command.storage(description);
});
function repairSubStorages() {
var promise_list = [], i;
for (i = 0; i < length; i += 1) {
promise_list[i] = success(storage_list[i].repair(param, option));
}
return all(promise_list);
});
}
function returnThe404ReasonsElseNull(reason) {
if (reason.status === 404) {
return 404;
}
return null;
}
function getSubStoragesDocument() {
var promise_list = [], i;
for (i = 0; i < length; i += 1) {
promise_list[i] =
storage_list[i].get(param).then(null, returnThe404ReasonsElseNull);
}
return all(promise_list);
}
function synchronizeDocument(answers) {
return this_.syncGetAnswerList(command, answers);
}
function checkAnswers(answers) {
var i;
for (i = 0; i < answers.length; i += 1) {
if (answers[i].result !== "success") {
throw answers[i];
return new RSVP.Queue()
.push(function () {
// Ensure that the document storage is usable
return context._signature_sub_storage.__storage._sub_storage.get(
context._signature_hash
);
})
.push(undefined, function (error) {
if ((error instanceof jIO.util.jIOError) &&
(error.status_code === 404)) {
return context._signature_sub_storage.__storage._sub_storage.put(
context._signature_hash,
{}
);
}
}
}
return repairSubStorages().
then(getSubStoragesDocument).
then(synchronizeDocument).
then(checkAnswers).
then(command.success, command.error, command.notify);
throw error;
})
.push(function () {
return RSVP.all([
// Don't repair local_sub_storage twice
// context._signature_sub_storage.repair.apply(
// context._signature_sub_storage,
// argument_list
// ),
context._local_sub_storage.repair.apply(
context._local_sub_storage,
argument_list
),
context._remote_sub_storage.repair.apply(
context._remote_sub_storage,
argument_list
)
]);
})
.push(function () {
return pushStorage(context._local_sub_storage,
context._remote_sub_storage);
})
.push(function () {
return pushStorage(context._remote_sub_storage,
context._local_sub_storage);
});
};
addStorageFunction('replicate', ReplicateStorage);
jIO.addStorage('replicate', ReplicateStorage);
}));
}(jIO, RSVP, Rusha));
/*jslint indent: 2, maxlen: 80, nomen: true */
/*global define, RSVP, jIO, fake_storage, module, test, stop, start, deepEqual,
setTimeout, clearTimeout, XMLHttpRequest, window, ok */
(function (dependencies, factory) {
/*jslint nomen: true*/
(function (jIO, QUnit) {
"use strict";
if (typeof define === 'function' && define.amd) {
return define(dependencies, factory);
var test = QUnit.test,
stop = QUnit.stop,
start = QUnit.start,
ok = QUnit.ok,
expect = QUnit.expect,
deepEqual = QUnit.deepEqual,
equal = QUnit.equal,
module = QUnit.module,
throws = QUnit.throws;
/////////////////////////////////////////////////////////////////
// Custom test substorage definition
/////////////////////////////////////////////////////////////////
function Storage200() {
return this;
}
factory(RSVP, jIO, fake_storage);
}([
"rsvp",
"jio",
"fakestorage",
"replicatestorage"
], function (RSVP, jIO, fake_storage) {
"use strict";
jIO.addStorage('replicatestorage200', Storage200);
var all = RSVP.all, chain = RSVP.resolve, Promise = RSVP.Promise;
/**
* sleep(delay, [value]): promise< value >
*
* Produces a new promise which will resolve with `value` after `delay`
* milliseconds.
*
* @param {Number} delay The time to sleep.
* @param {Any} [value] The value to resolve.
* @return {Promise} A new promise.
*/
function sleep(delay, value) {
var ident;
return new Promise(function (resolve) {
ident = setTimeout(resolve, delay, value);
}, function () {
clearTimeout(ident);
});
function Storage500() {
return this;
}
jIO.addStorage('replicatestorage500', Storage500);
/////////////////////////////////////////////////////////////////
// replicateStorage.constructor
/////////////////////////////////////////////////////////////////
module("replicateStorage.constructor");
test("create substorage", function () {
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
function jsonClone(object, replacer) {
if (object === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(object, replacer));
}
ok(jio.__storage._local_sub_storage instanceof jio.constructor);
equal(jio.__storage._local_sub_storage.__type, "replicatestorage200");
ok(jio.__storage._remote_sub_storage instanceof jio.constructor);
equal(jio.__storage._remote_sub_storage.__type, "replicatestorage500");
function reverse(promise) {
return promise.then(function (a) { throw a; }, function (e) { return e; });
}
equal(jio.__storage._signature_hash,
"_replicate_7b54b9b5183574854e5870beb19b15152a36ef4e");
function orderRowsById(a, b) {
return a.id > b.id ? 1 : b.id > a.id ? -1 : 0;
}
ok(jio.__storage._signature_sub_storage instanceof jio.constructor);
equal(jio.__storage._signature_sub_storage.__type, "document");
module("Replicate + GID + Local");
equal(jio.__storage._signature_sub_storage.__storage._document_id,
jio.__storage._signature_hash);
test("Get", function () {
var shared = {}, i, jio_list, replicate_jio;
ok(jio.__storage._signature_sub_storage.__storage._sub_storage
instanceof jio.constructor);
equal(jio.__storage._signature_sub_storage.__storage._sub_storage.__type,
"replicatestorage200");
// this test can work with at least 2 sub storages
shared.gid_description = {
"type": "gid",
"constraints": {
"default": {
"identifier": "list"
}
},
"sub_storage": null
};
});
shared.storage_description_list = [];
for (i = 0; i < 4; i += 1) {
shared.storage_description_list[i] = jsonClone(shared.gid_description);
shared.storage_description_list[i].sub_storage = {
"type": "local",
"username": "replicate scenario test for get method - " + (i + 1),
"mode": "memory"
};
}
/////////////////////////////////////////////////////////////////
// replicateStorage.get
/////////////////////////////////////////////////////////////////
module("replicateStorage.get");
test("get called substorage get", function () {
stop();
expect(2);
shared.replicate_storage_description = {
"type": "replicate",
"storage_list": shared.storage_description_list
};
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
shared.workspace = {};
shared.jio_option = {
"workspace": shared.workspace,
"max_retry": 0
Storage200.prototype.get = function (id) {
equal(id, "bar", "get 200 called");
return {title: "foo"};
};
jio_list = shared.storage_description_list.map(function (description) {
return jIO.createJIO(description, shared.jio_option);
});
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
jio.get("bar")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// replicateStorage.post
/////////////////////////////////////////////////////////////////
module("replicateStorage.post");
test("post called substorage post", function () {
stop();
expect(2);
shared.modified_date_list = [
new Date("1995"),
new Date("2000"),
null,
new Date("Invalid Date")
];
shared.winner_modified_date = shared.modified_date_list[1];
function setFakeStorage() {
setFakeStorage.original = shared.storage_description_list[0].sub_storage;
shared.storage_description_list[0].sub_storage = {
"type": "fake",
"id": "replicate scenario test for get method - 1"
};
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function unsetFakeStorage() {
shared.storage_description_list[0].sub_storage = setFakeStorage.original;
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function putSimilarDocuments() {
return all(jio_list.map(function (jio) {
return jio.post({
"identifier": "a",
"modified": shared.modified_date_list[0]
});
}));
}
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
function getDocumentNothingToSynchronize() {
return replicate_jio.get({"_id": "{\"identifier\":[\"a\"]}"});
}
Storage200.prototype.post = function (param) {
deepEqual(param, {title: "bar"}, "post 200 called");
return "foo";
};
function getDocumentNothingToSynchronizeTest(answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"modified": shared.modified_date_list[0].toJSON()
},
"id": "{\"identifier\":[\"a\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Get document, nothing to synchronize.");
// check storage state
return sleep(1000).
// possible synchronization in background (should not occur)
then(function () {
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"a\"]}"});
}));
}).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"modified": shared.modified_date_list[0].toJSON()
},
"id": "{\"identifier\":[\"a\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
});
}
jio.post({title: "bar"})
.then(function (result) {
equal(result, "foo", "Check id");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
function putDifferentDocuments() {
return all(jio_list.map(function (jio, i) {
return jio.post({
"identifier": "b",
"modified": shared.modified_date_list[i]
});
}));
}
/////////////////////////////////////////////////////////////////
// replicateStorage.hasCapacity
/////////////////////////////////////////////////////////////////
module("replicateStorage.hasCapacity");
test("hasCapacity return substorage value", function () {
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
function getDocumentWithSynchronization() {
return replicate_jio.get({"_id": "{\"identifier\":[\"b\"]}"});
}
delete Storage200.prototype.hasCapacity;
function getDocumentWithSynchronizationTest(answer) {
if (answer && answer.data) {
ok(shared.modified_date_list.map(function (v) {
return (v && v.toJSON()) || undefined;
}).indexOf(answer.data.modified) !== -1, "Should be a known date");
delete answer.data.modified;
throws(
function () {
jio.hasCapacity("foo");
},
function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.status_code, 501);
equal(error.message,
"Capacity 'foo' is not implemented on 'replicatestorage200'");
return true;
}
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b"
},
"id": "{\"identifier\":[\"b\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Get document, pending synchronization.");
// check storage state
return sleep(1000).
// synchronizing in background
then(function () {
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"b\"]}"});
}));
}).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"b\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
});
}
);
});
function putOneDocument() {
return jio_list[1].post({
"identifier": "c",
"modified": shared.modified_date_list[1]
});
}
/////////////////////////////////////////////////////////////////
// replicateStorage.buildQuery
/////////////////////////////////////////////////////////////////
module("replicateStorage.buildQuery");
function getDocumentWith404Synchronization() {
return replicate_jio.get({"_id": "{\"identifier\":[\"c\"]}"});
}
test("buildQuery return substorage buildQuery", function () {
stop();
expect(2);
function getDocumentWith404SynchronizationTest(answer) {
if (answer && answer.data) {
ok(shared.modified_date_list.map(function (v) {
return (v && v.toJSON()) || undefined;
}).indexOf(answer.data.modified) !== -1, "Should be a known date");
delete answer.data.modified;
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"c\"]}",
"identifier": "c"
},
"id": "{\"identifier\":[\"c\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Get document, synchronizing with not found document.");
// check storage state
return sleep(1000).
// synchronizing in background
then(function () {
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"c\"]}"});
}));
}).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"c\"]}",
"identifier": "c",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"c\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
});
}
});
function putDifferentDocuments2() {
return all(jio_list.map(function (jio, i) {
return jio.post({
"identifier": "d",
"modified": shared.modified_date_list[i]
});
}));
}
Storage200.prototype.hasCapacity = function () {
return true;
};
function getDocumentWithUnavailableStorage() {
setFakeStorage();
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for get method - 1/allDocs"
].error({"status": 0});
}, 100);
return replicate_jio.get({"_id": "{\"identifier\":[\"d\"]}"});
}
Storage200.prototype.buildQuery = function (options) {
deepEqual(options, {
include_docs: false,
sort_on: [["title", "ascending"]],
limit: [5],
select_list: ["title", "id"],
replicate: 'title: "two"'
}, "allDocs parameter");
return "bar";
};
function getDocumentWithUnavailableStorageTest(answer) {
if (answer && answer.data) {
ok(shared.modified_date_list.map(function (v) {
return (v && v.toJSON()) || undefined;
}).indexOf(answer.data.modified) !== -1, "Should be a known date");
delete answer.data.modified;
}
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"d\"]}",
"identifier": "d"
},
"id": "{\"identifier\":[\"d\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Get document, synchronizing with unavailable storage.");
unsetFakeStorage();
// check storage state
return sleep(1000).
// synchronizing in background
then(function () {
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"d\"]}"});
}));
}).then(function (answers) {
deepEqual(answers[0], {
"data": {
"_id": "{\"identifier\":[\"d\"]}",
"identifier": "d",
"modified": shared.modified_date_list[0].toJSON()
},
"id": "{\"identifier\":[\"d\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
answers.slice(1).forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"d\"]}",
"identifier": "d",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"d\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
jio.allDocs({
include_docs: false,
sort_on: [["title", "ascending"]],
limit: [5],
select_list: ["title", "id"],
replicate: 'title: "two"'
})
.then(function (result) {
deepEqual(result, {
data: {
rows: "bar",
total_rows: 3
}
});
}
function unexpectedError(error) {
if (error instanceof Error) {
deepEqual([
error.name + ": " + error.message,
error
], "NO ERROR", "Unexpected error");
} else {
deepEqual(error, "NO ERROR", "Unexpected error");
}
}
chain().
// get without synchronizing anything
then(putSimilarDocuments).
then(getDocumentNothingToSynchronize).
then(getDocumentNothingToSynchronizeTest).
// get with synchronization
then(putDifferentDocuments).
then(getDocumentWithSynchronization).
then(getDocumentWithSynchronizationTest).
// get with 404 synchronization
then(putOneDocument).
then(getDocumentWith404Synchronization).
then(getDocumentWith404SynchronizationTest).
// XXX get with attachment synchronization
// get with unavailable storage
then(putDifferentDocuments2).
then(getDocumentWithUnavailableStorage).
then(getDocumentWithUnavailableStorageTest).
// End of scenario
then(null, unexpectedError).
then(start, start);
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("Post + Put", function () {
var shared = {}, i, jio_list, replicate_jio;
/////////////////////////////////////////////////////////////////
// replicateStorage.put
/////////////////////////////////////////////////////////////////
module("replicateStorage.put");
test("put called substorage put", function () {
stop();
expect(3);
// this test can work with at least 2 sub storages
shared.gid_description = {
"type": "gid",
"constraints": {
"default": {
"identifier": "list"
}
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
"sub_storage": null
remote_sub_storage: {
type: "replicatestorage500"
}
});
Storage200.prototype.put = function (id, param) {
equal(id, "bar", "put 200 called");
deepEqual(param, {"title": "foo"}, "put 200 called");
return id;
};
shared.storage_description_list = [];
for (i = 0; i < 4; i += 1) {
shared.storage_description_list[i] = jsonClone(shared.gid_description);
shared.storage_description_list[i].sub_storage = {
"type": "local",
"username": "replicate scenario test for post method - " + (i + 1),
"mode": "memory"
};
}
shared.replicate_storage_description = {
"type": "replicate",
"storage_list": shared.storage_description_list
};
jio.put("bar", {"title": "foo"})
.then(function (result) {
equal(result, "bar");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
shared.workspace = {};
shared.jio_option = {
"workspace": shared.workspace,
"max_retry": 0
};
test("put can not modify the signature", function () {
stop();
expect(3);
jio_list = shared.storage_description_list.map(function (description) {
return jIO.createJIO(description, shared.jio_option);
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
delete Storage200.prototype.put;
jio.put(jio.__storage._signature_hash, {"title": "foo"})
.then(function () {
ok(false);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, jio.__storage._signature_hash + " is frozen");
equal(error.status_code, 403);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// replicateStorage.remove
/////////////////////////////////////////////////////////////////
module("replicateStorage.remove");
test("remove called substorage remove", function () {
stop();
expect(2);
function setFakeStorage() {
setFakeStorage.original = shared.storage_description_list[0].sub_storage;
shared.storage_description_list[0].sub_storage = {
"type": "fake",
"id": "replicate scenario test for post method - 1"
};
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function unsetFakeStorage() {
shared.storage_description_list[0].sub_storage = setFakeStorage.original;
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function createDocument() {
return replicate_jio.post({"identifier": "a"});
}
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
Storage200.prototype.remove = function (id) {
equal(id, "bar", "remove 200 called");
return id;
};
function createDocumentTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"a\"]}",
"method": "post",
"result": "success",
"status": 201,
"statusText": "Created"
}, "Post document");
jio.remove("bar", {"title": "foo"})
.then(function (result) {
equal(result, "bar");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
return sleep(100);
}
test("remove can not modify the signature", function () {
stop();
expect(3);
function checkStorageContent() {
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"a\"]}"});
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a"
},
"id": "{\"identifier\":[\"a\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
var jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200"
},
remote_sub_storage: {
type: "replicatestorage500"
}
});
delete Storage200.prototype.remove;
jio.remove(jio.__storage._signature_hash)
.then(function () {
ok(false);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, jio.__storage._signature_hash + " is frozen");
equal(error.status_code, 403);
})
.always(function () {
start();
});
}
});
function updateDocument() {
return replicate_jio.put({
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"title": "b"
/////////////////////////////////////////////////////////////////
// replicateStorage.repair use cases
/////////////////////////////////////////////////////////////////
module("replicateStorage.repair", {
setup: function () {
// Uses memory substorage, so that it is flushed after each run
this.jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "uuid",
sub_storage: {
type: "memory"
}
},
remote_sub_storage: {
type: "uuid",
sub_storage: {
type: "memory"
}
}
});
}
function updateDocumentTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"a\"]}",
"method": "put",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Update document");
return sleep(100);
}
});
function checkStorageContent3() {
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"a\"]}"});
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"title": "b"
},
"id": "{\"identifier\":[\"a\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
test("local document creation", function () {
stop();
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo"
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "5ea9013447539ad65de308cbd75b5826a2ae30e5"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
function createDocumentWithUnavailableStorage() {
setFakeStorage();
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for post method - 1/allDocs"
].error({"status": 0});
}, 100);
return replicate_jio.post({"identifier": "b"});
}
function createDocumentWithUnavailableStorageTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"b\"]}",
"method": "post",
"result": "success",
"status": 201,
"statusText": "Created"
}, "Post document with unavailable storage");
return sleep(100);
}
});
function checkStorageContent2() {
unsetFakeStorage();
// check storage state
return all(jio_list.map(function (jio, i) {
if (i === 0) {
return reverse(jio.get({"_id": "{\"identifier\":[\"b\"]}"}));
}
return jio.get({"_id": "{\"identifier\":[\"b\"]}"});
})).then(function (answers) {
deepEqual(answers[0], {
"error": "not_found",
"id": "{\"identifier\":[\"b\"]}",
"message": "Cannot get document",
"method": "get",
"reason": "missing",
"result": "error",
"status": 404,
"statusText": "Not Found"
}, "Check storage content");
answers.slice(1).forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b"
},
"id": "{\"identifier\":[\"b\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
test("remote document creation", function () {
stop();
expect(2);
var id,
context = this;
context.jio.__storage._remote_sub_storage.post({"title": "bar"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "bar"
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "6799f3ea80e325b89f19589282a343c376c1f1af"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
function unexpectedError(error) {
if (error instanceof Error) {
deepEqual([
error.name + ": " + error.message,
error
], "NO ERROR", "Unexpected error");
} else {
deepEqual(error, "NO ERROR", "Unexpected error");
}
}
chain().
// create a document
then(createDocument).
then(createDocumentTest).
then(checkStorageContent).
// update document
then(updateDocument).
then(updateDocumentTest).
then(checkStorageContent3).
// create a document with unavailable storage
then(createDocumentWithUnavailableStorage).
then(createDocumentWithUnavailableStorageTest).
then(checkStorageContent2).
// End of scenario
then(null, unexpectedError).
then(start, start);
});
test("Remove", function () {
var shared = {}, i, jio_list, replicate_jio;
// this test can work with at least 2 sub storages
shared.gid_description = {
"type": "gid",
"constraints": {
"default": {
"identifier": "list"
}
},
"sub_storage": null
};
shared.storage_description_list = [];
for (i = 0; i < 4; i += 1) {
shared.storage_description_list[i] = jsonClone(shared.gid_description);
shared.storage_description_list[i].sub_storage = {
"type": "local",
"username": "replicate scenario test for remove method - " + (i + 1),
"mode": "memory"
};
}
shared.replicate_storage_description = {
"type": "replicate",
"storage_list": shared.storage_description_list
};
shared.workspace = {};
shared.jio_option = {
"workspace": shared.workspace,
"max_retry": 0
};
jio_list = shared.storage_description_list.map(function (description) {
return jIO.createJIO(description, shared.jio_option);
});
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
test("local and remote document creations", function () {
stop();
expect(5);
var context = this;
RSVP.all([
context.jio.put("conflict", {"title": "foo"}),
context.jio.__storage._remote_sub_storage.put("conflict",
{"title": "bar"})
])
.then(function () {
return context.jio.repair();
})
.then(function () {
ok(false);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Conflict on 'conflict'");
equal(error.status_code, 409);
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get("conflict");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
// equal(error.message, "Cannot find document: conflict");
equal(error.status_code, 404);
})
.always(function () {
start();
});
});
function setFakeStorage() {
setFakeStorage.original = shared.storage_description_list[0].sub_storage;
shared.storage_description_list[0].sub_storage = {
"type": "fake",
"id": "replicate scenario test for remove method - 1"
};
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function unsetFakeStorage() {
shared.storage_description_list[0].sub_storage = setFakeStorage.original;
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function putSomeDocuments() {
return all(jio_list.map(function (jio) {
return jio.post({"identifier": "a"});
}));
}
function removeDocument() {
return replicate_jio.remove({"_id": "{\"identifier\":[\"a\"]}"});
}
function removeDocumentTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"a\"]}",
"method": "remove",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Remove document");
return sleep(100);
}
function checkStorageContent() {
// check storage state
return all(jio_list.map(function (jio) {
return reverse(jio.get({"_id": "{\"identifier\":[\"a\"]}"}));
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"error": "not_found",
"id": "{\"identifier\":[\"a\"]}",
"message": "Cannot get document",
"method": "get",
"reason": "missing",
"result": "error",
"status": 404,
"statusText": "Not Found"
}, "Check storage content");
test("local and remote same document creations", function () {
stop();
expect(1);
var context = this;
RSVP.all([
context.jio.put("conflict", {"title": "foo"}),
context.jio.__storage._remote_sub_storage.put("conflict",
{"title": "foo"})
])
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get("conflict");
})
.then(function (result) {
deepEqual(result, {
hash: "5ea9013447539ad65de308cbd75b5826a2ae30e5"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
function putSomeDocuments2() {
return all(jio_list.map(function (jio) {
return jio.post({"identifier": "b"});
}));
}
function removeDocumentWithUnavailableStorage() {
setFakeStorage();
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for remove method - 1/allDocs"
].error({"status": 0});
}, 100);
return replicate_jio.remove({"_id": "{\"identifier\":[\"b\"]}"});
}
function removeDocumentWithUnavailableStorageTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"b\"]}",
"method": "remove",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Remove document with unavailable storage");
return sleep(100);
}
});
function checkStorageContent2() {
unsetFakeStorage();
// check storage state
return all(jio_list.map(function (jio, i) {
if (i === 0) {
return jio.get({"_id": "{\"identifier\":[\"b\"]}"});
}
return reverse(jio.get({"_id": "{\"identifier\":[\"b\"]}"}));
})).then(function (answers) {
deepEqual(answers[0], {
"data": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b"
},
"id": "{\"identifier\":[\"b\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
answers.slice(1).forEach(function (answer) {
deepEqual(answer, {
"error": "not_found",
"id": "{\"identifier\":[\"b\"]}",
"message": "Cannot get document",
"method": "get",
"reason": "missing",
"result": "error",
"status": 404,
"statusText": "Not Found"
}, "Check storage content");
test("no modification", function () {
stop();
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo"
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "5ea9013447539ad65de308cbd75b5826a2ae30e5"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
function unexpectedError(error) {
if (error instanceof Error) {
deepEqual([
error.name + ": " + error.message,
error
], "NO ERROR", "Unexpected error");
} else {
deepEqual(error, "NO ERROR", "Unexpected error");
}
}
chain().
// remove document
then(putSomeDocuments).
then(removeDocument).
then(removeDocumentTest).
then(checkStorageContent).
// remove document with unavailable storage
then(putSomeDocuments2).
then(removeDocumentWithUnavailableStorage).
then(removeDocumentWithUnavailableStorageTest).
then(checkStorageContent2).
// End of scenario
then(null, unexpectedError).
then(start, start);
});
test("AllDocs", function () {
var shared = {}, i, jio_list, replicate_jio;
// this test can work with at least 2 sub storages
shared.gid_description = {
"type": "gid",
"constraints": {
"default": {
"identifier": "list"
}
},
"sub_storage": null
};
shared.storage_description_list = [];
for (i = 0; i < 2; i += 1) {
shared.storage_description_list[i] = jsonClone(shared.gid_description);
shared.storage_description_list[i].sub_storage = {
"type": "local",
"username": "replicate scenario test for allDocs method - " + (i + 1),
"mode": "memory"
};
}
shared.replicate_storage_description = {
"type": "replicate",
"storage_list": shared.storage_description_list
};
shared.workspace = {};
shared.jio_option = {
"workspace": shared.workspace,
"max_retry": 0
};
jio_list = shared.storage_description_list.map(function (description) {
return jIO.createJIO(description, shared.jio_option);
});
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
test("local document modification", function () {
stop();
shared.modified_date_list = [
new Date("2000"),
new Date("1995"),
null,
new Date("Invalid Date")
];
function postSomeDocuments() {
return all([
jio_list[0].post({
"identifier": "a",
"modified": shared.modified_date_list[0]
}),
jio_list[0].post({
"identifier": "b",
"modified": shared.modified_date_list[1]
}),
jio_list[1].post({
"identifier": "b",
"modified": shared.modified_date_list[0]
})
]);
}
function listDocuments() {
return replicate_jio.allDocs({"include_docs": true});
}
function listDocumentsTest(answer) {
answer.data.rows.sort(orderRowsById);
deepEqual(answer, {
"data": {
"total_rows": 2,
"rows": [
{
"id": "{\"identifier\":[\"a\"]}",
"doc": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"modified": shared.modified_date_list[0].toJSON()
},
"value": {}
},
{
"id": "{\"identifier\":[\"b\"]}",
"doc": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b",
"modified": shared.modified_date_list[1].toJSON()
// there's no winner detection here
},
"value": {}
}
]
},
"method": "allDocs",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Document list should be merged correctly");
}
function setFakeStorage() {
shared.storage_description_list[0].sub_storage = {
"type": "fake",
"id": "replicate scenario test for allDocs method - 1"
};
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
function listDocumentsWithUnavailableStorage() {
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for allDocs method - 1/allDocs"
].error({"status": 0});
}, 100);
return replicate_jio.allDocs({"include_docs": true});
}
function listDocumentsWithUnavailableStorageTest(answer) {
deepEqual(answer, {
"data": {
"total_rows": 1,
"rows": [
{
"id": "{\"identifier\":[\"b\"]}",
"doc": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b",
"modified": shared.modified_date_list[0].toJSON()
},
"value": {}
}
]
},
"method": "allDocs",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Document list with only one available storage");
}
function unexpectedError(error) {
if (error instanceof Error) {
deepEqual([
error.name + ": " + error.message,
error
], "NO ERROR", "Unexpected error");
} else {
deepEqual(error, "NO ERROR", "Unexpected error");
}
}
chain().
// list documents
then(postSomeDocuments).
then(listDocuments).
then(listDocumentsTest).
// set fake storage
then(setFakeStorage).
// list documents with unavailable storage
then(listDocumentsWithUnavailableStorage).
then(listDocumentsWithUnavailableStorageTest).
// End of scenario
then(null, unexpectedError).
then(start);
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.put(id, {"title": "foo2"});
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo2"
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "9819187e39531fdc9bcfd40dbc6a7d3c78fe8dab"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("Repair", function () {
var shared = {}, i, jio_list, replicate_jio;
// this test can work with at least 2 sub storages
shared.gid_description = {
"type": "gid",
"constraints": {
"default": {
"identifier": "list"
}
},
"sub_storage": null
};
shared.storage_description_list = [];
for (i = 0; i < 4; i += 1) {
shared.storage_description_list[i] = jsonClone(shared.gid_description);
shared.storage_description_list[i].sub_storage = {
"type": "local",
"username": "replicate scenario test for repair method - " + (i + 1),
"mode": "memory"
};
}
shared.replicate_storage_description = {
"type": "replicate",
"storage_list": shared.storage_description_list
};
test("remote document modification", function () {
stop();
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.put(
id,
{"title": "foo3"}
);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo3"
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "4b1dde0f80ac38514771a9d25b5278e38f560e0f"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
shared.workspace = {};
shared.jio_option = {
"workspace": shared.workspace,
"max_retry": 0
};
test("local and remote document modifications", function () {
stop();
expect(4);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return RSVP.all([
context.jio.put(id, {"title": "foo4"}),
context.jio.__storage._remote_sub_storage.put(id, {"title": "foo5"})
]);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
ok(false);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Conflict on '" + id + "'");
equal(error.status_code, 409);
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "5ea9013447539ad65de308cbd75b5826a2ae30e5"
});
})
.always(function () {
start();
});
});
jio_list = shared.storage_description_list.map(function (description) {
return jIO.createJIO(description, shared.jio_option);
});
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
test("local and remote document same modifications", function () {
stop();
expect(1);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return RSVP.all([
context.jio.put(id, {"title": "foo99"}),
context.jio.__storage._remote_sub_storage.put(id, {"title": "foo99"})
]);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "8ed3a474128b6e0c0c7d3dd51b1a06ebfbf6722f"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("local document deletion", function () {
stop();
expect(6);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.remove(id);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
ok(true, "Removal correctly synced");
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id)
.then(function () {
ok(false, "Signature should be deleted");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
// equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
});
})
.always(function () {
start();
});
});
shared.modified_date_list = [
new Date("1995"),
new Date("2000"),
null,
new Date("Invalid Date")
];
shared.winner_modified_date = shared.modified_date_list[1];
function setFakeStorage() {
setFakeStorage.original = shared.storage_description_list[0].sub_storage;
shared.storage_description_list[0].sub_storage = {
"type": "fake",
"id": "replicate scenario test for repair method - 1"
};
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
test("remote document deletion", function () {
stop();
expect(6);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.remove(id);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
ok(true, "Removal correctly synced");
})
.then(function () {
return context.jio.get(id);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id)
.then(function () {
ok(false, "Signature should be deleted");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
// equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
});
})
.always(function () {
start();
});
});
function unsetFakeStorage() {
shared.storage_description_list[0].sub_storage = setFakeStorage.original;
jio_list[0] = jIO.createJIO(
shared.storage_description_list[0],
shared.jio_option
);
replicate_jio = jIO.createJIO(
shared.replicate_storage_description,
shared.jio_option
);
}
test("local and remote document deletions", function () {
stop();
expect(8);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return RSVP.all([
context.jio.remove(id),
context.jio.__storage._remote_sub_storage.remove(id)
]);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.get(id)
.then(function () {
ok(false, "Document should be locally deleted");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
});
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id)
.then(function () {
ok(false, "Document should be remotely deleted");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
});
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id)
.then(function () {
ok(false, "Signature should be deleted");
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
// equal(error.message, "Cannot find document: " + id);
equal(error.status_code, 404);
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
function putSimilarDocuments() {
return all(jio_list.map(function (jio) {
return jio.post({
"identifier": "a",
"modified": shared.modified_date_list[0]
test("local deletion and remote modifications", function () {
stop();
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return RSVP.all([
context.jio.remove(id),
context.jio.__storage._remote_sub_storage.put(id, {"title": "foo99"})
]);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo99"
});
}));
}
function repairDocumentNothingToSynchronize() {
return replicate_jio.repair({"_id": "{\"identifier\":[\"a\"]}"});
}
function repairDocumentNothingToSynchronizeTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"a\"]}",
"method": "repair",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Repair document, nothing to synchronize.");
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"a\"]}"});
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"a\"]}",
"identifier": "a",
"modified": shared.modified_date_list[0].toJSON()
},
"id": "{\"identifier\":[\"a\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "8ed3a474128b6e0c0c7d3dd51b1a06ebfbf6722f"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
});
function putDifferentDocuments() {
return all(jio_list.map(function (jio, i) {
return jio.post({
"identifier": "b",
"modified": shared.modified_date_list[i]
test("local modifications and remote deletion", function () {
stop();
expect(2);
var id,
context = this;
context.jio.post({"title": "foo"})
.then(function (result) {
id = result;
return context.jio.repair();
})
.then(function () {
return RSVP.all([
context.jio.put(id, {"title": "foo99"}),
context.jio.__storage._remote_sub_storage.remove(id)
]);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
title: "foo99"
});
}));
}
function repairDocumentWithSynchronization() {
return replicate_jio.repair({"_id": "{\"identifier\":[\"b\"]}"});
}
function repairDocumentWithSynchronizationTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"b\"]}",
"method": "repair",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Repair document, synchronization should be done.");
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"b\"]}"});
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"b\"]}",
"identifier": "b",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"b\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
})
.then(function () {
return context.jio.__storage._signature_sub_storage.get(id);
})
.then(function (result) {
deepEqual(result, {
hash: "8ed3a474128b6e0c0c7d3dd51b1a06ebfbf6722f"
});
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
});
function putOneDocument() {
return jio_list[1].post({
"identifier": "c",
"modified": shared.modified_date_list[1]
test("signature document is not synced", function () {
stop();
expect(6);
var context = this;
// Uses sessionstorage substorage, so that signature are stored
// in the same local sub storage
this.jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "uuid",
sub_storage: {
type: "document",
document_id: "/",
sub_storage: {
type: "local",
sessiononly: true
}
}
},
remote_sub_storage: {
type: "memory"
}
});
context.jio.post({"title": "foo"})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(
context.jio.__storage._signature_hash
);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " +
"_replicate_2be6c0851d60bcd9afe829e7133a136d266c779c");
equal(error.status_code, 404);
})
.then(function () {
return context.jio.repair();
})
.then(function () {
return context.jio.__storage._remote_sub_storage.get(
context.jio.__storage._signature_hash
);
})
.fail(function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.message, "Cannot find document: " +
"_replicate_2be6c0851d60bcd9afe829e7133a136d266c779c");
equal(error.status_code, 404);
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
}
});
function repairDocumentWith404Synchronization() {
return replicate_jio.repair({"_id": "{\"identifier\":[\"c\"]}"});
}
test("substorages are repaired too", function () {
stop();
expect(9);
function repairDocumentWith404SynchronizationTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"c\"]}",
"method": "repair",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Repair document, synchronizing with not found document.");
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"c\"]}"});
})).then(function (answers) {
answers.forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"c\"]}",
"identifier": "c",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"c\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
});
}
var context = this,
first_call = true,
options = {foo: "bar"};
function putDifferentDocuments2() {
return all(jio_list.map(function (jio, i) {
return jio.post({
"identifier": "d",
"modified": shared.modified_date_list[i]
});
}));
function Storage200CheckRepair() {
return this;
}
Storage200CheckRepair.prototype.get = function () {
ok(true, "get 200 check repair called");
return {};
};
Storage200CheckRepair.prototype.hasCapacity = function () {
return true;
};
Storage200CheckRepair.prototype.buildQuery = function () {
ok(true, "buildQuery 200 check repair called");
return [];
};
Storage200CheckRepair.prototype.allAttachments = function () {
ok(true, "allAttachments 200 check repair called");
return {};
};
Storage200CheckRepair.prototype.repair = function (kw) {
if (first_call) {
deepEqual(
this,
context.jio.__storage._local_sub_storage.__storage,
"local substorage repair"
);
first_call = false;
} else {
deepEqual(
this,
context.jio.__storage._remote_sub_storage.__storage,
"remote substorage repair"
);
}
deepEqual(kw, options, "substorage repair parameters provided");
};
function repairDocumentWithUnavailableStorage() {
setFakeStorage();
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for repair method - 1/allDocs"
].error({"status": 0});
}, 250);
setTimeout(function () {
fake_storage.commands[
"replicate scenario test for repair method - 1/allDocs"
].error({"status": 0});
}, 500);
return replicate_jio.repair({"_id": "{\"identifier\":[\"d\"]}"});
}
jIO.addStorage(
'replicatestorage200chechrepair',
Storage200CheckRepair
);
function repairDocumentWithUnavailableStorageTest(answer) {
deepEqual(answer, {
"id": "{\"identifier\":[\"d\"]}",
"method": "repair",
"result": "success",
"status": 204,
"statusText": "No Content"
}, "Repair document, synchronizing with unavailable storage.");
unsetFakeStorage();
// check storage state
return all(jio_list.map(function (jio) {
return jio.get({"_id": "{\"identifier\":[\"d\"]}"});
})).then(function (answers) {
deepEqual(answers[0], {
"data": {
"_id": "{\"identifier\":[\"d\"]}",
"identifier": "d",
"modified": shared.modified_date_list[0].toJSON()
},
"id": "{\"identifier\":[\"d\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
answers.slice(1).forEach(function (answer) {
deepEqual(answer, {
"data": {
"_id": "{\"identifier\":[\"d\"]}",
"identifier": "d",
"modified": shared.winner_modified_date.toJSON()
},
"id": "{\"identifier\":[\"d\"]}",
"method": "get",
"result": "success",
"status": 200,
"statusText": "Ok"
}, "Check storage content");
});
});
}
function unexpectedError(error) {
if (error instanceof Error) {
deepEqual([
error.name + ": " + error.message,
error
], "NO ERROR", "Unexpected error");
} else {
deepEqual(error, "NO ERROR", "Unexpected error");
this.jio = jIO.createJIO({
type: "replicate",
local_sub_storage: {
type: "replicatestorage200chechrepair"
},
remote_sub_storage: {
type: "replicatestorage200chechrepair"
}
}
});
chain().
// get without synchronizing anything
then(putSimilarDocuments).
then(repairDocumentNothingToSynchronize).
then(repairDocumentNothingToSynchronizeTest).
// repair with synchronization
then(putDifferentDocuments).
then(repairDocumentWithSynchronization).
then(repairDocumentWithSynchronizationTest).
// repair with 404 synchronization
then(putOneDocument).
then(repairDocumentWith404Synchronization).
then(repairDocumentWith404SynchronizationTest).
// XXX repair with attachment synchronization
// repair with unavailable storage
then(putDifferentDocuments2).
then(repairDocumentWithUnavailableStorage).
then(repairDocumentWithUnavailableStorageTest).
// End of scenario
then(null, unexpectedError).
then(start, start);
context.jio.repair(options)
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
}));
}(jIO, QUnit));
......@@ -38,6 +38,7 @@
<script src="jio.storage/erp5storage.tests.js"></script>
<script src="jio.storage/indexeddbstorage.tests.js"></script>
<script src="jio.storage/uuidstorage.tests.js"></script>
<script src="jio.storage/replicatestorage.tests.js"></script>
<!--script src="jio.storage/indexstorage.tests.js"></script-->
<!--script src="jio.storage/dropboxstorage.tests.js"></script-->
......@@ -58,11 +59,7 @@
<script src="../test/jio.storage/replicaterevisionstorage.tests.js"></script>
<script src="../src/jio.storage/splitstorage.js"></script>
<script src="../test/jio.storage/splitstorage.tests.js"></script>
<script src="../src/jio.storage/replicatestorage.js"></script>
<script src="../test/jio.storage/replicatestorage.tests.js"></script-->
<script src="../test/jio.storage/splitstorage.tests.js"></script-->
</head>
<body>
<h1 id="qunit-header">jIO Tests</h1>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment