test/integration_test.js
"use strict";
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import Api from "../src";
import { checkVersion } from "../src/utils";
import { EventEmitter } from "events";
import KintoServer from "kinto-node-test-server";
import { delayedPromise } from "./test_utils";
chai.use(chaiAsPromised);
chai.should();
chai.config.includeStack = true;
const skipLocalServer = !!process.env.TEST_KINTO_SERVER;
const TEST_KINTO_SERVER =
process.env.TEST_KINTO_SERVER || "http://0.0.0.0:8888/v1";
function startServer(server, options) {
return !skipLocalServer && server.start(options);
}
function stopServer(server) {
return !skipLocalServer && server.stop();
}
describe("Integration tests", function() {
let sandbox, server, api;
// Disabling test timeouts until pserve gets decent startup time.
this.timeout(0);
before(() => {
if (skipLocalServer) {
return;
}
let kintoConfigPath = __dirname + "/kinto.ini";
if (process.env.SERVER && process.env.SERVER !== "master") {
kintoConfigPath = `${__dirname}/kinto-${process.env.SERVER}.ini`;
}
server = new KintoServer(TEST_KINTO_SERVER, {
maxAttempts: 200,
kintoConfigPath,
});
});
after(() => {
if (skipLocalServer) {
return;
}
const logLines = server.logs.toString().split("\n");
const serverDidCrash = logLines.some(l => l.startsWith("Traceback"));
if (serverDidCrash) {
// Server errors have been encountered, raise to break the build
const trace = logLines.join("\n");
throw new Error(
`Kinto server crashed while running the test suite.\n\n${trace}`
);
}
return server.killAll();
});
function createClient(options = {}) {
return new Api(TEST_KINTO_SERVER, options);
}
beforeEach(function() {
this.timeout(12500);
sandbox = sinon.sandbox.create();
const events = new EventEmitter();
api = createClient({
events,
headers: { Authorization: "Basic " + btoa("user:pass") },
});
});
afterEach(() => sandbox.restore());
describe("Default server configuration", () => {
before(() => {
return startServer(server);
});
after(() => {
return stopServer(server);
});
beforeEach(() => server.flush());
// XXX move this to batch tests
describe("new batch", () => {
it("should support root batch", () => {
return api
.batch(batch => {
const bucket = batch.bucket("default");
bucket.createCollection("posts");
const coll = bucket.collection("posts");
coll.createRecord({ a: 1 });
coll.createRecord({ a: 2 });
})
.then(_ =>
api
.bucket("default")
.collection("posts")
.listRecords()
)
.then(res => res.data)
.should.eventually.have.length.of(2);
});
it("should support bucket batch", () => {
return api
.bucket("default")
.batch(batch => {
batch.createCollection("posts");
const coll = batch.collection("posts");
coll.createRecord({ a: 1 });
coll.createRecord({ a: 2 });
})
.then(_ =>
api
.bucket("default")
.collection("posts")
.listRecords()
)
.then(res => res.data)
.should.eventually.have.length.of(2);
});
});
describe("Server properties", () => {
it("should retrieve server settings", () => {
return api
.fetchServerSettings()
.should.eventually.have.property("batch_max_requests")
.eql(25);
});
it("should retrieve server capabilities", () => {
return api.fetchServerCapabilities().then(capabilities => {
expect(capabilities).to.be.an("object");
// Kinto protocol 1.4 exposes capability descriptions
Object.keys(capabilities).forEach(capability => {
const capabilityObj = capabilities[capability];
expect(capabilityObj).to.include.keys("url", "description");
});
});
});
it("should retrieve user information", () => {
return api.fetchUser().then(user => {
expect(user.id).to.match(/^basicauth:/);
expect(user.bucket).to.have.length.of(36);
});
});
it("should retrieve current API version", () => {
return api.fetchHTTPApiVersion().should.eventually.match(/^\d\.\d+$/);
});
});
describe("#createBucket", () => {
let result;
describe("Default options", () => {
describe("Autogenerated id", () => {
beforeEach(() => {
return api.createBucket(null).then(res => (result = res));
});
it("should create a bucket", () => {
expect(result)
.to.have.property("data")
.to.have.property("id")
.to.be.a("string");
});
});
describe("Custom id", () => {
beforeEach(() => {
return api.createBucket("foo").then(res => (result = res));
});
it("should create a bucket with the passed id", () => {
expect(result)
.to.have.property("data")
.to.have.property("id")
.eql("foo");
});
it("should create a bucket having a list of write permissions", () => {
expect(result)
.to.have.property("permissions")
.to.have.property("write")
.to.be.a("array");
});
describe("data option", () => {
it("should create bucket data", () => {
return api
.createBucket("foo", { data: { a: 1 } })
.should.eventually.have.property("data")
.to.have.property("a")
.eql(1);
});
});
describe("Safe option", () => {
it("should not override existing bucket", () => {
return api
.createBucket("foo", { safe: true })
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
});
describe("permissions option", () => {
beforeEach(() => {
return api
.createBucket("foo", { permissions: { read: ["github:n1k0"] } })
.then(res => (result = res));
});
it("should create a bucket having a list of write permissions", () => {
expect(result)
.to.have.property("permissions")
.to.have.property("read")
.to.eql(["github:n1k0"]);
});
});
});
describe("#deleteBucket()", () => {
let last_modified;
beforeEach(() => {
return api
.createBucket("foo")
.then(({ data }) => (last_modified = data.last_modified));
});
it("should delete a bucket", () => {
return api
.deleteBucket("foo")
.then(_ => api.listBuckets())
.then(({ data }) => data.map(bucket => bucket.id))
.should.eventually.not.include("foo");
});
describe("Safe option", () => {
it("should raise a conflict error when resource has changed", () => {
return api
.deleteBucket("foo", {
last_modified: last_modified - 1000,
safe: true,
})
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe("#deleteBuckets()", () => {
before(function() {
try {
checkVersion(server.http_api_version, "1.4", "2.0");
} catch (err) {
this.skip();
}
});
beforeEach(() => {
return api.batch(batch => {
batch.createBucket("b1");
batch.createBucket("b2");
});
});
it("should delete all buckets", () => {
return (
api
.deleteBuckets()
// Note: Server tends to take a lot of time to perform this operation,
// so we're delaying check a little.
.then(_ => delayedPromise(50))
.then(_ => api.listBuckets())
.then(({ data }) => data)
.should.become([])
);
});
});
describe("#listPermissions", () => {
describe("Single page of permissions", () => {
beforeEach(() => {
return api.batch(batch => {
batch.createBucket("b1");
batch.bucket("b1").createCollection("c1");
});
});
it("should retrieve the list of permissions", () => {
return api.listPermissions().then(({ data }) => {
expect(data).to.have.length.of(2);
expect(data.map(p => p.id).sort()).eql(["b1", "c1"]);
});
});
});
describe("Paginated list of permissions", () => {
beforeEach(() => {
return api.batch(batch => {
for (let i = 1; i <= 15; i++) {
batch.createBucket("b" + i);
}
});
});
it("should retrieve the list of permissions", () => {
return api.listPermissions({ pages: Infinity }).then(results => {
expect(results.data).to.have.length.of(15);
expect(results.totalRecords).eql(15);
});
});
});
});
describe("#listBuckets", () => {
beforeEach(() => {
return api.batch(batch => {
batch.createBucket("b1", { data: { size: 24 } });
batch.createBucket("b2", { data: { size: 13 } });
batch.createBucket("b3", { data: { size: 38 } });
batch.createBucket("b4", { data: { size: -4 } });
});
});
it("should retrieve the list of buckets", () => {
return api
.listBuckets()
.then(({ data }) => data.map(bucket => bucket.id).sort())
.should.become(["b1", "b2", "b3", "b4"]);
});
it("should order buckets by field", () => {
return api
.listBuckets({ sort: "-size" })
.then(({ data }) => data.map(bucket => bucket.id))
.should.eventually.become(["b3", "b1", "b2", "b4"]);
});
describe("Filtering", () => {
it("should filter buckets", () => {
return api
.listBuckets({ sort: "size", filters: { min_size: 20 } })
.then(({ data }) => data.map(bucket => bucket.id))
.should.become(["b1", "b3"]);
});
it("should resolve with buckets last_modified value", () => {
return api
.listBuckets()
.should.eventually.have.property("last_modified")
.to.be.a("string");
});
it("should retrieve only buckets after provided timestamp", () => {
let timestamp;
return api
.listBuckets()
.then(({ last_modified }) => {
timestamp = last_modified;
return api.createBucket("b5");
})
.then(() => api.listBuckets({ since: timestamp }))
.should.eventually.have.property("data")
.to.have.length.of(1);
});
});
describe("Pagination", () => {
it("should not paginate by default", () => {
return api
.listBuckets()
.then(({ data }) => data.map(bucket => bucket.id))
.should.become(["b4", "b3", "b2", "b1"]);
});
it("should paginate by chunks", () => {
return api
.listBuckets({ limit: 2 })
.then(({ data }) => data.map(bucket => bucket.id))
.should.become(["b4", "b3"]);
});
it("should expose a hasNextPage boolean prop", () => {
return api
.listBuckets({ limit: 2 })
.should.eventually.have.property("hasNextPage")
.eql(true);
});
it("should provide a next method to load next page", () => {
return api
.listBuckets({ limit: 2 })
.then(res => res.next())
.then(({ data }) => data.map(bucket => bucket.id))
.should.become(["b2", "b1"]);
});
});
});
describe("#batch", () => {
describe("No chunked requests", () => {
it("should allow batching operations", () => {
return api
.batch(batch => {
batch.createBucket("custom");
const bucket = batch.bucket("custom");
bucket.createCollection("blog");
const coll = bucket.collection("blog");
coll.createRecord({ title: "art1" });
coll.createRecord({ title: "art2" });
})
.then(_ =>
api
.bucket("custom")
.collection("blog")
.listRecords()
)
.then(({ data }) => data.map(record => record.title))
.should.become(["art2", "art1"]);
});
});
describe("Chunked requests", () => {
it("should allow batching by chunks", () => {
// Note: kinto server configuration has kinto.paginated_by set to 10.
return api
.batch(batch => {
batch.createBucket("custom");
const bucket = batch.bucket("custom");
bucket.createCollection("blog");
const coll = bucket.collection("blog");
for (let i = 1; i <= 27; i++) {
coll.createRecord({ title: "art" + i });
}
})
.then(_ =>
api
.bucket("custom")
.collection("blog")
.listRecords()
)
.should.eventually.have.property("data")
.to.have.length.of(10);
});
});
describe("aggregate option", () => {
describe("Succesful publication", () => {
describe("No chunking", () => {
let results;
beforeEach(() => {
return api
.batch(
batch => {
batch.createBucket("custom");
const bucket = batch.bucket("custom");
bucket.createCollection("blog");
const coll = bucket.collection("blog");
coll.createRecord({ title: "art1" });
coll.createRecord({ title: "art2" });
},
{ aggregate: true }
)
.then(_results => (results = _results));
});
it("should return an aggregated result object", () => {
expect(results).to.include.keys([
"errors",
"conflicts",
"published",
"skipped",
]);
});
it("should contain the list of succesful publications", () => {
expect(
results.published.map(body => body.data)
).to.have.length.of(4);
});
});
describe("Chunked response", () => {
let results;
beforeEach(() => {
return api
.bucket("default")
.collection("blog")
.batch(
batch => {
for (let i = 1; i <= 26; i++) {
batch.createRecord({ title: "art" + i });
}
},
{ aggregate: true }
)
.then(_results => (results = _results));
});
it("should return an aggregated result object", () => {
expect(results).to.include.keys([
"errors",
"conflicts",
"published",
"skipped",
]);
});
it("should contain the list of succesful publications", () => {
expect(results.published).to.have.length.of(26);
});
});
});
});
});
});
describe("Backed off server", () => {
const backoffSeconds = 10;
before(() => {
return startServer(server, { KINTO_BACKOFF: backoffSeconds });
});
after(() => stopServer(server));
beforeEach(() => server.flush());
it("should appropriately populate the backoff property", () => {
// Issuing a first api call to retrieve backoff information
return api
.listBuckets()
.then(() => expect(Math.round(api.backoff / 1000)).eql(backoffSeconds));
});
});
describe("Deprecated protocol version", () => {
beforeEach(() => server.flush());
describe("Soft EOL", () => {
before(() => {
const tomorrow = new Date(new Date().getTime() + 86400000)
.toJSON()
.slice(0, 10);
return startServer(server, {
KINTO_EOS: `"${tomorrow}"`,
KINTO_EOS_URL: "http://www.perdu.com",
KINTO_EOS_MESSAGE: "Boom",
});
});
after(() => stopServer(server));
beforeEach(() => sandbox.stub(console, "warn"));
it("should warn when the server sends a deprecation Alert header", () => {
return api.fetchServerSettings().then(_ => {
sinon.assert.calledWithExactly(
console.warn,
"Boom",
"http://www.perdu.com"
);
});
});
});
describe("Hard EOL", () => {
before(() => {
const lastWeek = new Date(new Date().getTime() - 7 * 86400000)
.toJSON()
.slice(0, 10);
return startServer(server, {
KINTO_EOS: `"${lastWeek}"`,
KINTO_EOS_URL: "http://www.perdu.com",
KINTO_EOS_MESSAGE: "Boom",
});
});
after(() => stopServer(server));
beforeEach(() => sandbox.stub(console, "warn"));
it("should reject with a 410 Gone when hard EOL is received", () => {
return api
.fetchServerSettings()
.should.be.rejectedWith(Error, /HTTP 410 Gone: Service deprecated/);
});
});
});
describe("Limited pagination", () => {
before(() => {
return startServer(server, { KINTO_PAGINATE_BY: 1 });
});
after(() => stopServer(server));
beforeEach(() => server.flush());
describe("Limited configured server pagination", () => {
let collection;
beforeEach(() => {
collection = api.bucket("default").collection("posts");
return collection.batch(batch => {
batch.createRecord({ n: 1 });
batch.createRecord({ n: 2 });
});
});
it("should fetch one results page", () => {
return collection
.listRecords()
.then(({ data }) => data.map(record => record.n))
.should.eventually.have.length.of(1);
});
it("should fetch all available pages", () => {
return collection
.listRecords({ pages: Infinity })
.then(({ data }) => data.map(record => record.n))
.should.eventually.have.length.of(2);
});
});
});
describe("Chainable API", () => {
before(() => startServer(server));
after(() => stopServer(server));
beforeEach(() => server.flush());
describe(".bucket()", () => {
let bucket;
beforeEach(() => {
bucket = api.bucket("custom");
return api.createBucket("custom").then(_ =>
bucket.batch(batch => {
batch.createCollection("c1", { data: { size: 24 } });
batch.createCollection("c2", { data: { size: 13 } });
batch.createCollection("c3", { data: { size: 38 } });
batch.createCollection("c4", { data: { size: -4 } });
batch.createGroup("g1", [], { data: { size: 24 } });
batch.createGroup("g2", [], { data: { size: 13 } });
batch.createGroup("g3", [], { data: { size: 38 } });
batch.createGroup("g4", [], { data: { size: -4 } });
})
);
});
describe(".getData()", () => {
let result;
beforeEach(() => {
return bucket.getData().then(res => (result = res));
});
it("should retrieve the bucket identifier", () => {
expect(result)
.to.have.property("id")
.eql("custom");
});
it("should retrieve bucket last_modified value", () => {
expect(result)
.to.have.property("last_modified")
.to.be.gt(1);
});
});
describe(".setData()", () => {
beforeEach(() => {
return bucket.setPermissions({ read: ["github:jon"] });
});
it("should post data to the bucket", () => {
return bucket.setData({ a: 1 }).then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read).to.include("github:jon");
});
});
it("should patch existing data for the bucket", () => {
return bucket
.setData({ a: 1 })
.then(() => bucket.setData({ b: 2 }, { patch: true }))
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(data.b).eql(2);
expect(permissions.read).to.include("github:jon");
});
});
it("should post data to the default bucket", () => {
return api
.bucket("default")
.setData({ a: 1 })
.then(({ data }) => data)
.should.eventually.have.property("a")
.eql(1);
});
});
describe(".getPermissions()", () => {
it("should retrieve bucket permissions", () => {
return bucket
.getPermissions()
.should.eventually.have.property("write")
.to.have.length.of(1);
});
});
describe(".setPermissions()", () => {
beforeEach(() => {
return bucket.setData({ a: 1 });
});
it("should set bucket permissions", () => {
return bucket
.setPermissions({ read: ["github:n1k0"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read).eql(["github:n1k0"]);
});
});
describe("Safe option", () => {
it("should check for concurrency", () => {
return bucket
.setPermissions(
{ read: ["github:n1k0"] },
{ safe: true, last_modified: 1 }
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".addPermissions()", () => {
beforeEach(() => {
return bucket
.setPermissions({ read: ["github:n1k0"] })
.then(() => bucket.setData({ a: 1 }));
});
it("should append bucket permissions", () => {
return bucket
.addPermissions({ read: ["accounts:gabi"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read.sort()).eql([
"accounts:gabi",
"github:n1k0",
]);
});
});
});
describe(".removePermissions()", () => {
beforeEach(() => {
return bucket
.setPermissions({ read: ["github:n1k0"] })
.then(() => bucket.setData({ a: 1 }));
});
it("should pop bucket permissions", () => {
return bucket
.removePermissions({ read: ["github:n1k0"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read).eql(undefined);
});
});
});
describe(".listHistory()", () => {
it("should retrieve the list of history entries", () => {
return bucket
.listHistory()
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.become([
"g4",
"g3",
"g2",
"g1",
"c4",
"c3",
"c2",
"c1",
"custom",
]);
});
it("should order entries by field", () => {
return bucket
.listHistory({ sort: "date" })
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.eventually.become([
"custom",
"c1",
"c2",
"c3",
"c4",
"g1",
"g2",
"g3",
"g4",
]);
});
describe("Filtering", () => {
it("should filter entries by top-level attributes", () => {
return bucket
.listHistory({ filters: { resource_name: "bucket" } })
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.become(["custom"]);
});
it("should filter entries by target attributes", () => {
return bucket
.listHistory({ filters: { "target.data.id": "custom" } })
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.become(["custom"]);
});
it("should resolve with entries last_modified value", () => {
return bucket
.listHistory()
.should.eventually.have.property("last_modified")
.to.be.a("string");
});
it("should retrieve only entries after provided timestamp", () => {
let timestamp;
return bucket
.listHistory()
.then(({ last_modified }) => {
timestamp = last_modified;
return bucket.createCollection("c5");
})
.then(() => bucket.listHistory({ since: timestamp }))
.should.eventually.have.property("data")
.to.have.length.of(1);
});
});
describe("Pagination", () => {
it("should not paginate by default", () => {
return bucket
.listHistory()
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.eventually.have.length.of(9);
});
it("should paginate by chunks", () => {
return bucket
.listHistory({ limit: 2 })
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.become(["g4", "g3"]);
});
it("should provide a next method to load next page", () => {
return bucket
.listHistory({ limit: 2 })
.then(res => res.next())
.then(({ data }) => data.map(entry => entry.target.data.id))
.should.become(["g2", "g1"]);
});
});
});
describe(".listCollections()", () => {
it("should retrieve the list of collections", () => {
return bucket
.listCollections()
.then(({ data }) => data.map(collection => collection.id).sort())
.should.become(["c1", "c2", "c3", "c4"]);
});
it("should order collections by field", () => {
return bucket
.listCollections({ sort: "-size" })
.then(({ data }) => data.map(collection => collection.id))
.should.eventually.become(["c3", "c1", "c2", "c4"]);
});
it("should work in a batch", () => {
return api
.batch(batch => batch.bucket("custom").listCollections())
.then(([{ body }]) => body.data.map(coll => coll.id))
.should.eventually.become(["c4", "c3", "c2", "c1"]);
});
describe("Filtering", () => {
it("should filter collections", () => {
return bucket
.listCollections({ sort: "size", filters: { min_size: 20 } })
.then(({ data }) => data.map(collection => collection.id))
.should.become(["c1", "c3"]);
});
it("should resolve with collections last_modified value", () => {
return bucket
.listCollections()
.should.eventually.have.property("last_modified")
.to.be.a("string");
});
it("should retrieve only collections after provided timestamp", () => {
let timestamp;
return bucket
.listCollections()
.then(({ last_modified }) => {
timestamp = last_modified;
return bucket.createCollection("c5");
})
.then(() => bucket.listCollections({ since: timestamp }))
.should.eventually.have.property("data")
.to.have.length.of(1);
});
});
describe("Pagination", () => {
it("should not paginate by default", () => {
return bucket
.listCollections()
.then(({ data }) => data.map(collection => collection.id))
.should.become(["c4", "c3", "c2", "c1"]);
});
it("should paginate by chunks", () => {
return bucket
.listCollections({ limit: 2 })
.then(({ data }) => data.map(collection => collection.id))
.should.become(["c4", "c3"]);
});
it("should provide a next method to load next page", () => {
return bucket
.listCollections({ limit: 2 })
.then(res => res.next())
.then(({ data }) => data.map(collection => collection.id))
.should.become(["c2", "c1"]);
});
});
});
describe(".createCollection()", () => {
it("should create a named collection", () => {
return bucket
.createCollection("foo")
.then(_ => bucket.listCollections())
.then(({ data }) => data.map(coll => coll.id))
.should.eventually.include("foo");
});
it("should create an automatically named collection", () => {
let generated;
return bucket
.createCollection()
.then(res => (generated = res.data.id))
.then(_ => bucket.listCollections())
.then(({ data }) =>
expect(data.some(x => x.id === generated)).eql(true)
);
});
describe("Safe option", () => {
it("should not override existing collection", () => {
return bucket
.createCollection("posts")
.then(_ => bucket.createCollection("posts", { safe: true }))
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
describe("Permissions option", () => {
let result;
beforeEach(() => {
return bucket
.createCollection("posts", {
permissions: {
read: ["github:n1k0"],
},
})
.then(res => (result = res));
});
it("should create a collection having a list of write permissions", () => {
expect(result)
.to.have.property("permissions")
.to.have.property("read")
.to.eql(["github:n1k0"]);
});
});
describe("Data option", () => {
let result;
beforeEach(() => {
return bucket
.createCollection("posts", { data: { foo: "bar" } })
.then(res => (result = res));
});
it("should create a collection having the expected data attached", () => {
expect(result)
.to.have.property("data")
.to.have.property("foo")
.eql("bar");
});
});
});
describe(".deleteCollection()", () => {
it("should delete a collection", () => {
return bucket
.createCollection("foo")
.then(_ => bucket.deleteCollection("foo"))
.then(_ => bucket.listCollections())
.then(({ data }) => data.map(coll => coll.id))
.should.eventually.not.include("foo");
});
describe("Safe option", () => {
it("should check for concurrency", () => {
return bucket
.createCollection("posts")
.then(({ data }) =>
bucket.deleteCollection("posts", {
safe: true,
last_modified: data.last_modified - 1000,
})
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".listGroups()", () => {
it("should retrieve the list of groups", () => {
return bucket
.listGroups()
.then(({ data }) => data.map(group => group.id).sort())
.should.become(["g1", "g2", "g3", "g4"]);
});
it("should order groups by field", () => {
return bucket
.listGroups({ sort: "-size" })
.then(({ data }) => data.map(group => group.id))
.should.eventually.become(["g3", "g1", "g2", "g4"]);
});
describe("Filtering", () => {
it("should filter groups", () => {
return bucket
.listGroups({ sort: "size", filters: { min_size: 20 } })
.then(({ data }) => data.map(group => group.id))
.should.become(["g1", "g3"]);
});
it("should resolve with groups last_modified value", () => {
return bucket
.listGroups()
.should.eventually.have.property("last_modified")
.to.be.a("string");
});
it("should retrieve only groups after provided timestamp", () => {
let timestamp;
return bucket
.listGroups()
.then(({ last_modified }) => {
timestamp = last_modified;
return bucket.createGroup("g5", []);
})
.then(() => bucket.listGroups({ since: timestamp }))
.should.eventually.have.property("data")
.to.have.length.of(1);
});
});
describe("Pagination", () => {
it("should not paginate by default", () => {
return bucket
.listGroups()
.then(({ data }) => data.map(group => group.id))
.should.become(["g4", "g3", "g2", "g1"]);
});
it("should paginate by chunks", () => {
return bucket
.listGroups({ limit: 2 })
.then(({ data }) => data.map(group => group.id))
.should.become(["g4", "g3"]);
});
it("should provide a next method to load next page", () => {
return bucket
.listGroups({ limit: 2 })
.then(res => res.next())
.then(({ data }) => data.map(group => group.id))
.should.become(["g2", "g1"]);
});
});
});
describe(".createGroup()", () => {
it("should create a named group", () => {
return bucket
.createGroup("foo")
.then(_ => bucket.listGroups())
.then(({ data }) => data.map(group => group.id))
.should.eventually.include("foo");
});
it("should create an automatically named group", () => {
let generated;
return bucket
.createGroup()
.then(res => (generated = res.data.id))
.then(_ => bucket.listGroups())
.then(({ data }) =>
expect(data.some(x => x.id === generated)).eql(true)
);
});
describe("Safe option", () => {
it("should not override existing group", () => {
return bucket
.createGroup("admins")
.then(_ => bucket.createGroup("admins", [], { safe: true }))
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
describe("Permissions option", () => {
let result;
beforeEach(() => {
return bucket
.createGroup("admins", ["twitter:leplatrem"], {
permissions: {
read: ["github:n1k0"],
},
})
.then(res => (result = res));
});
it("should create a collection having a list of write permissions", () => {
expect(result)
.to.have.property("permissions")
.to.have.property("read")
.to.eql(["github:n1k0"]);
expect(result.data.members).to.include("twitter:leplatrem");
});
});
describe("Data option", () => {
let result;
beforeEach(() => {
return bucket
.createGroup("admins", ["twitter:leplatrem"], {
data: { foo: "bar" },
})
.then(res => (result = res));
});
it("should create a collection having the expected data attached", () => {
expect(result)
.to.have.property("data")
.to.have.property("foo")
.eql("bar");
expect(result.data.members).to.include("twitter:leplatrem");
});
});
});
describe(".getGroup()", () => {
it("should get a group", () => {
return bucket
.createGroup("foo")
.then(_ => bucket.getGroup("foo"))
.then(({ data, permissions }) => {
expect(data.id).eql("foo");
expect(data.members).eql([]);
expect(permissions.write).to.have.length.of(1);
});
});
});
describe(".updateGroup()", () => {
it("should update a group", () => {
return bucket
.createGroup("foo")
.then(({ data }) => bucket.updateGroup({ ...data, title: "mod" }))
.then(_ => bucket.listGroups())
.then(({ data }) => data[0].title)
.should.become("mod");
});
it("should patch a group", () => {
return bucket
.createGroup("foo", ["github:me"], {
data: { title: "foo", blah: 42 },
})
.then(({ data }) =>
bucket.updateGroup({ id: data.id, blah: 43 }, { patch: true })
)
.then(_ => bucket.listGroups())
.then(({ data }) => {
expect(data[0].title).eql("foo");
expect(data[0].members).eql(["github:me"]);
expect(data[0].blah).eql(43);
});
});
describe("Safe option", () => {
const id = "2dcd0e65-468c-4655-8015-30c8b3a1c8f8";
it("should perform concurrency checks with last_modified", () => {
return bucket
.createGroup("foo")
.then(({ data }) =>
bucket.updateGroup(
{
id: data.id,
members: ["github:me"],
title: "foo",
last_modified: 1,
},
{ safe: true }
)
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
it("should create a non-existent resource when safe is true", () => {
return bucket
.updateGroup({ id, members: ["all"] }, { safe: true })
.should.eventually.have.property("data")
.to.have.property("members")
.eql(["all"]);
});
it("should not override existing data with no last_modified", () => {
return bucket
.createGroup("foo")
.then(({ data }) =>
bucket.updateGroup(
{
id: data.id,
members: [],
title: "foo",
},
{ safe: true }
)
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".deleteGroup()", () => {
it("should delete a group", () => {
return bucket
.createGroup("foo")
.then(_ => bucket.deleteGroup("foo"))
.then(_ => bucket.listGroups())
.then(({ data }) => data.map(coll => coll.id))
.should.eventually.not.include("foo");
});
describe("Safe option", () => {
it("should check for concurrency", () => {
return bucket
.createGroup("posts")
.then(({ data }) =>
bucket.deleteGroup("posts", {
safe: true,
last_modified: data.last_modified - 1000,
})
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".batch()", () => {
it("should allow batching operations for current bucket", () => {
return bucket
.batch(batch => {
batch.createCollection("comments");
const coll = batch.collection("comments");
coll.createRecord({ content: "plop" });
coll.createRecord({ content: "yo" });
})
.then(_ => bucket.collection("comments").listRecords())
.then(({ data }) => data.map(comment => comment.content).sort())
.should.become(["plop", "yo"]);
});
describe("Safe option", () => {
it("should allow batching operations for current bucket", () => {
return bucket
.batch(
batch => {
batch.createCollection("comments");
batch.createCollection("comments");
},
{ safe: true, aggregate: true }
)
.should.eventually.have.property("conflicts")
.to.have.length.of(1);
});
});
});
});
describe(".collection()", () => {
function runSuite(label, collPromise) {
describe(label, () => {
let coll;
beforeEach(() => {
return collPromise().then(_coll => (coll = _coll));
});
describe(".getTotalRecords()", () => {
it("should retrieve the initial total number of records", () => {
return coll.getTotalRecords().should.become(0);
});
it("should retrieve the updated total number of records", () => {
return coll
.batch(batch => {
batch.createRecord({ a: 1 });
batch.createRecord({ a: 2 });
})
.then(() => coll.getTotalRecords())
.should.become(2);
});
});
describe(".getPermissions()", () => {
it("should retrieve permissions", () => {
return coll
.getPermissions()
.should.eventually.have.property("write")
.to.have.length.of(1);
});
});
describe(".setPermissions()", () => {
beforeEach(() => {
return coll.setData({ a: 1 });
});
it("should set typed permissions", () => {
return coll
.setPermissions({ read: ["github:n1k0"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read).eql(["github:n1k0"]);
});
});
describe("Safe option", () => {
it("should perform concurrency checks", () => {
return coll
.setPermissions(
{ read: ["github:n1k0"] },
{ safe: true, last_modified: 1 }
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".addPermissions()", () => {
beforeEach(() => {
return coll
.setPermissions({ read: ["github:n1k0"] })
.then(() => coll.setData({ a: 1 }));
});
it("should append collection permissions", () => {
return coll
.addPermissions({ read: ["accounts:gabi"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read.sort()).eql([
"accounts:gabi",
"github:n1k0",
]);
});
});
});
describe(".removePermissions()", () => {
beforeEach(() => {
return coll
.setPermissions({ read: ["github:n1k0"] })
.then(() => coll.setData({ a: 1 }));
});
it("should pop collection permissions", () => {
return coll
.removePermissions({ read: ["github:n1k0"] })
.then(({ data, permissions }) => {
expect(data.a).eql(1);
expect(permissions.read).eql(undefined);
});
});
});
describe(".getData()", () => {
it("should retrieve collection data", () => {
return coll
.setData({ signed: true })
.then(_ => coll.getData())
.should.eventually.have.property("signed")
.eql(true);
});
});
describe(".setData()", () => {
beforeEach(() => {
return coll.setPermissions({ read: ["github:n1k0"] });
});
it("should set collection data", () => {
return coll
.setData({ signed: true })
.then(({ data, permissions }) => {
expect(data.signed).eql(true);
expect(permissions.read).to.include("github:n1k0");
});
});
describe("Safe option", () => {
it("should perform concurrency checks", () => {
return coll
.setData({ signed: true }, { safe: true, last_modified: 1 })
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".createRecord()", () => {
describe("No record id provided", () => {
it("should create a record", () => {
return coll
.createRecord({ title: "foo" })
.should.eventually.have.property("data")
.to.have.property("title")
.eql("foo");
});
describe("Safe option", () => {
it("should check for existing record", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) =>
coll.createRecord(
{
id: data.id,
title: "foo",
},
{ safe: true }
)
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe("Record id provided", () => {
const record = {
id: "37f727ed-c8c4-461b-80ac-de874992165c",
title: "foo",
};
it("should create a record", () => {
return coll
.createRecord(record)
.should.eventually.have.property("data")
.to.have.property("title")
.eql("foo");
});
});
});
describe(".updateRecord()", () => {
it("should update a record", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) =>
coll.updateRecord({ ...data, title: "mod" })
)
.then(_ => coll.listRecords())
.then(({ data }) => data[0].title)
.should.become("mod");
});
it("should patch a record", () => {
return coll
.createRecord({ title: "foo", blah: 42 })
.then(({ data }) =>
coll.updateRecord({ id: data.id, blah: 43 }, { patch: true })
)
.then(_ => coll.listRecords())
.then(({ data }) => {
expect(data[0].title).eql("foo");
expect(data[0].blah).eql(43);
});
});
it("should create the record if it doesn't exist yet", () => {
const id = "2dcd0e65-468c-4655-8015-30c8b3a1c8f8";
return coll
.updateRecord({ id, title: "blah" })
.then(res => coll.getRecord(res.data.id))
.should.eventually.have.property("data")
.to.have.property("title")
.eql("blah");
});
describe("Safe option", () => {
const id = "2dcd0e65-468c-4655-8015-30c8b3a1c8f8";
it("should perform concurrency checks with last_modified", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) =>
coll.updateRecord(
{
id: data.id,
title: "foo",
last_modified: 1,
},
{ safe: true }
)
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
it("should create a non-existent resource when safe is true", () => {
return coll
.updateRecord({ id, title: "foo" }, { safe: true })
.should.eventually.have.property("data")
.to.have.property("title")
.eql("foo");
});
it("should not override existing data with no last_modified", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) =>
coll.updateRecord(
{
id: data.id,
title: "foo",
},
{ safe: true }
)
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".deleteRecord()", () => {
it("should delete a record", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) => coll.deleteRecord(data.id))
.then(_ => coll.listRecords())
.should.eventually.have.property("data")
.eql([]);
});
describe("Safe option", () => {
it("should perform concurrency checks", () => {
return coll
.createRecord({ title: "foo" })
.then(({ data }) =>
coll.deleteRecord(data.id, {
last_modified: 1,
safe: true,
})
)
.should.be.rejectedWith(Error, /412 Precondition Failed/);
});
});
});
describe(".addAttachment()", () => {
describe("With filename", () => {
const input = "test";
const dataURL =
"data:text/plain;name=test.txt;base64," + btoa(input);
let result;
beforeEach(() => {
return coll
.addAttachment(
dataURL,
{ foo: "bar" },
{ permissions: { write: ["github:n1k0"] } }
)
.then(res => (result = res));
});
it("should create a record with an attachment", () => {
expect(result)
.to.have.property("data")
.to.have.property("attachment")
.to.have.property("size")
.eql(input.length);
});
it("should create a record with provided record data", () => {
expect(result)
.to.have.property("data")
.to.have.property("foo")
.eql("bar");
});
it("should create a record with provided permissions", () => {
expect(result)
.to.have.property("permissions")
.to.have.property("write")
.contains("github:n1k0");
});
});
describe("Without filename", () => {
const dataURL = "data:text/plain;base64," + btoa("blah");
it("should default filename to 'untitled' if not specified", () => {
return coll
.addAttachment(dataURL)
.should.eventually.have.property("data")
.have.property("attachment")
.have.property("filename")
.eql("untitled");
});
it("should allow to specify safe in options", () => {
return coll
.addAttachment(dataURL, undefined, { safe: true })
.should.eventually.to.have.property("data")
.to.have.property("attachment")
.to.have.property("size")
.eql(4);
});
it("should allow to specify a filename in options", () => {
return coll
.addAttachment(dataURL, undefined, { filename: "MYFILE.DAT" })
.should.eventually.have.property("data")
.have.property("attachment")
.have.property("filename")
.eql("MYFILE.DAT");
});
});
});
describe(".removeAttachment()", () => {
const input = "test";
const dataURL =
"data:text/plain;name=test.txt;base64," + btoa(input);
let recordId;
beforeEach(() => {
return coll
.addAttachment(dataURL)
.then(res => (recordId = res.data.id));
});
it("should remove an attachment from a record", () => {
return coll
.removeAttachment(recordId)
.then(() => coll.getRecord(recordId))
.should.eventually.have.property("data")
.to.have.property("attachment")
.eql(null);
});
});
describe(".getRecord()", () => {
it("should retrieve a record by its id", () => {
return coll
.createRecord({ title: "blah" })
.then(res => coll.getRecord(res.data.id))
.should.eventually.have.property("data")
.to.have.property("title")
.eql("blah");
});
});
describe(".listRecords()", () => {
it("should list records", () => {
return coll
.createRecord({ title: "foo" })
.then(_ => coll.listRecords())
.then(({ data }) => data.map(record => record.title))
.should.become(["foo"]);
});
it("should expose the total number of records", () => {
return coll
.createRecord({ a: 1 })
.then(() => coll.createRecord({ a: 2 }))
.then(() => coll.listRecords())
.should.eventually.have.property("totalRecords")
.eql(2);
});
it("should order records by field", () => {
return Promise.all(
["art3", "art1", "art2"].map(title => {
return coll.createRecord({ title });
})
)
.then(_ => coll.listRecords({ sort: "title" }))
.then(({ data }) => data.map(record => record.title))
.should.eventually.become(["art1", "art2", "art3"]);
});
describe("Filtering", () => {
beforeEach(() => {
return coll.batch(batch => {
batch.createRecord({ name: "paul", age: 28 });
batch.createRecord({ name: "jess", age: 54 });
batch.createRecord({ name: "john", age: 33 });
batch.createRecord({ name: "rené", age: 24 });
});
});
it("should filter records", () => {
return coll
.listRecords({ sort: "age", filters: { min_age: 30 } })
.then(({ data }) => data.map(record => record.name))
.should.become(["john", "jess"]);
});
it("should properly escape unicode filters", () => {
return coll
.listRecords({ filters: { name: "rené" } })
.then(({ data }) => data.map(record => record.name))
.should.become(["rené"]);
});
it("should resolve with collection last_modified value", () => {
return coll
.listRecords()
.should.eventually.have.property("last_modified")
.to.be.a("string");
});
});
describe("since", () => {
let ts1, ts2;
beforeEach(() => {
return coll
.listRecords()
.then(({ last_modified }) => (ts1 = last_modified))
.then(_ => coll.createRecord({ n: 1 }))
.then(_ => coll.listRecords())
.then(({ last_modified }) => (ts2 = last_modified))
.then(_ => coll.createRecord({ n: 2 }));
});
it("should retrieve all records modified since provided timestamp", () => {
return coll
.listRecords({ since: ts1 })
.should.eventually.have.property("data")
.to.have.length.of(2);
});
it("should only list changes made after the provided timestamp", () => {
return coll
.listRecords({ since: ts2 })
.should.eventually.have.property("data")
.to.have.length.of(1);
});
});
describe("'at' retrieves a snapshot at a given timestamp", () => {
let rec1, rec2, rec3;
beforeEach(() => {
return coll
.createRecord({ n: 1 })
.then(({ data }) => {
rec1 = data;
return coll.createRecord({ n: 2 });
})
.then(({ data }) => {
rec2 = data;
return coll.createRecord({ n: 3 });
})
.then(({ data }) => (rec3 = data));
});
it("should resolve with a regular list result object", () => {
return coll
.listRecords({ at: rec3.last_modified })
.then(result => {
const expectedSnapshot = [rec3, rec2, rec1];
expect(result.data).to.eql(expectedSnapshot);
expect(result.last_modified).eql(
String(rec3.last_modified)
);
expect(result.hasNextPage).eql(false);
expect(result.totalRecords).eql(expectedSnapshot.length);
expect(() => result.next()).to.Throw(Error, /pagination/);
});
});
it("should handle creations", () => {
return coll
.listRecords({ at: rec1.last_modified })
.should.eventually.have.property("data")
.eql([rec1]);
});
it("should handle updates", () => {
let updatedRec2;
return coll
.updateRecord({ ...rec2, n: 42 })
.then(({ data }) => {
updatedRec2 = data;
return coll.listRecords({ at: updatedRec2.last_modified });
})
.then(({ data }) => {
expect(data).eql([updatedRec2, rec3, rec1]);
});
});
it("should handle deletions", () => {
return coll
.deleteRecord(rec1.id)
.then(({ data: { last_modified } }) => {
return coll.listRecords({ at: last_modified });
})
.then(({ data }) => {
expect(data).eql([rec3, rec2]);
});
});
it("should handle long list of changes", () => {
return coll
.batch(batch => {
for (let n = 4; n <= 100; n++) {
batch.createRecord({ n });
}
})
.then(res => {
const at = res[50].body.data.last_modified;
return coll.listRecords({ at });
})
.should.eventually.have.property("data")
.to.length.of(54);
});
describe("Mixed CRUD operations", () => {
let rec4 = {};
let s1 = [],
s2 = [],
s3 = [],
s4 = [];
let rec1up = 0;
beforeEach(() => {
return coll
.batch(batch => {
batch.deleteRecord(rec2.id);
batch.updateRecord({ ...rec1, foo: "bar" });
batch.createRecord({ n: 4 });
})
.then(responses => {
rec1up = responses[1].body.data;
rec4 = responses[responses.length - 1].body.data;
return Promise.all([
coll.listRecords({ at: rec1.last_modified }),
coll.listRecords({ at: rec2.last_modified }),
coll.listRecords({ at: rec3.last_modified }),
coll.listRecords({ at: rec4.last_modified }),
]);
})
.then(results => {
const snapshots = results.map(({ data }) => data);
s1 = snapshots[0];
s2 = snapshots[1];
s3 = snapshots[2];
s4 = snapshots[3];
});
});
it("should compute snapshot1 as expected", () => {
expect(s1).eql([rec1]);
});
it("should compute snapshot2 as expected", () => {
expect(s2).eql([rec2, rec1]);
});
it("should compute snapshot3 as expected", () => {
expect(s3).eql([rec3, rec2, rec1]);
});
it("should compute snapshot4 as expected", () => {
expect(s4).eql([rec4, rec1up, rec3]);
});
});
});
describe("Pagination", () => {
beforeEach(() => {
return coll.batch(batch => {
for (let i = 1; i <= 3; i++) {
batch.createRecord({ n: i });
}
});
});
it("should not paginate by default", () => {
return coll
.listRecords()
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2, 1]);
});
it("should paginate by chunks", () => {
return coll
.listRecords({ limit: 2 })
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2]);
});
it("should provide a next method to load next page", () => {
return coll
.listRecords({ limit: 2 })
.then(res => res.next())
.then(({ data }) => data.map(record => record.n))
.should.become([1]);
});
it("should resolve with an empty array on exhausted pagination", () => {
return coll
.listRecords({ limit: 2 }) // 1st page of 2 records
.then(res => res.next()) // 2nd page of 1 record
.then(res => res.next()) // No next page
.should.be.rejectedWith(Error, /Pagination exhausted./);
});
it("should retrieve all pages", () => {
// Note: Server has no limit by default, so here we get all the
// records.
return coll
.listRecords()
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2, 1]);
});
it("should retrieve specified number of pages", () => {
return coll
.listRecords({ limit: 1, pages: 2 })
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2]);
});
it("should allow fetching next page after last page if any", () => {
return coll
.listRecords({ limit: 1, pages: 1 }) // 1 record
.then(({ data, next }) => next()) // 2 records
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2]);
});
it("should should retrieve all existing pages", () => {
return coll
.listRecords({ limit: 1, pages: Infinity })
.then(({ data }) => data.map(record => record.n))
.should.become([3, 2, 1]);
});
});
});
describe(".batch()", () => {
it("should allow batching operations in the current collection", () => {
return coll
.batch(batch => {
batch.createRecord({ title: "a" });
batch.createRecord({ title: "b" });
})
.then(_ => coll.listRecords({ sort: "title" }))
.then(({ data }) => data.map(record => record.title))
.should.become(["a", "b"]);
});
});
});
}
runSuite("default bucket", () => {
return api
.bucket("default")
.createCollection("plop")
.then(_ => api.bucket("default").collection("plop"));
});
runSuite("custom bucket", () => {
return api
.createBucket("custom")
.then(_ => api.bucket("custom").createCollection("plop"))
.then(_ => api.bucket("custom").collection("plop"));
});
});
});
});