Home Reference Source Test

test/api_test.js

"use strict";

import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import { EventEmitter } from "events";
import { fakeServerResponse } from "./test_utils.js";
import KintoClient from "../src";
import { SUPPORTED_PROTOCOL_VERSION as SPV } from "../src/base";
import * as requests from "../src/requests";
import Bucket from "../src/bucket";

chai.use(chaiAsPromised);
chai.should();
chai.config.includeStack = true;

const FAKE_SERVER_URL = "http://fake-server/v1";

/** @test {KintoClient} */
describe("KintoClient", () => {
  let sandbox, api, events;

  beforeEach(() => {
    sandbox = sinon.sandbox.create();
    events = new EventEmitter();
    api = new KintoClient(FAKE_SERVER_URL, { events });
  });

  afterEach(() => {
    sandbox.restore();
  });

  /** @test {KintoClient#constructor} */
  describe("#constructor", () => {
    const sampleRemote = `http://test/${SPV}`;

    it("should check that `remote` is a string", () => {
      expect(
        () =>
          new KintoClient(42, {
            events,
          })
      ).to.Throw(Error, /Invalid remote URL/);
    });

    it("should validate `remote` arg value", () => {
      expect(() => new KintoClient("http://nope")).to.Throw(
        Error,
        /The remote URL must contain the version/
      );
    });

    it("should strip any trailing slash", () => {
      expect(new KintoClient(sampleRemote).remote).eql(sampleRemote);
    });

    it("should expose a passed events instance option", () => {
      expect(new KintoClient(sampleRemote, { events }).events).to.eql(events);
    });

    it("should propagate its events property to child dependencies", () => {
      const api = new KintoClient(sampleRemote, { events });
      expect(api.http.events).eql(api.events);
    });

    it("should assign version value", () => {
      expect(new KintoClient(sampleRemote).version).eql(SPV);
      expect(new KintoClient(sampleRemote).version).eql(SPV);
    });

    it("should accept a headers option", () => {
      expect(
        new KintoClient(sampleRemote, {
          headers: { Foo: "Bar" },
        })._headers
      ).eql({ Foo: "Bar" });
    });

    it("should validate protocol version", () => {
      expect(() => new KintoClient("http://test/v999")).to.Throw(
        Error,
        /^Unsupported protocol version/
      );
    });

    it("should propagate the requestMode option to the child HTTP instance", () => {
      const requestMode = "no-cors";
      expect(
        new KintoClient(sampleRemote, {
          requestMode,
        }).http.requestMode
      ).eql(requestMode);
    });

    it("should keep the default timeout in the child HTTP instance", () => {
      expect(new KintoClient(sampleRemote).http.timeout).eql(null);
    });

    it("should propagate the timeout option to the child HTTP instance", () => {
      const timeout = 1000;
      expect(
        new KintoClient(sampleRemote, {
          timeout,
        }).http.timeout
      ).eql(timeout);
    });

    it("should create an event emitter if none is provided", () => {
      expect(new KintoClient(sampleRemote).events).to.be.an.instanceOf(
        EventEmitter
      );
    });

    it("should expose provided event emitter as a property", () => {
      const events = new EventEmitter();
      expect(new KintoClient(sampleRemote, { events }).events).eql(events);
    });

    it("should accept a safe option", () => {
      const api = new KintoClient(sampleRemote, { safe: true });
      expect(api._safe).eql(true);
    });
  });

  /** @test {KintoClient#setHeaders} */
  describe("#setHeaders", () => {
    let client;

    beforeEach(() => {
      client = new KintoClient(FAKE_SERVER_URL, {
        headers: { Foo: "Bar", Authorization: "Biz" },
      });
    });

    it("should override constructor headers", () => {
      client.setHeaders({
        Authorization: "Baz",
      });
      expect(client._headers).eql({ Foo: "Bar", Authorization: "Baz" });
    });
  });

  /** @test {KintoClient#backoff} */
  describe("get backoff()", () => {
    it("should provide the remaining backoff time in ms if any", () => {
      // Make Date#getTime always returning 1000000, for predictability
      sandbox.stub(Date.prototype, "getTime").returns(1000 * 1000);
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, {}, { Backoff: "1000" }));

      return api.listBuckets().then(_ => expect(api.backoff).eql(1000000));
    });

    it("should provide no remaining backoff time when none is set", () => {
      sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {}));

      return api.listBuckets().then(_ => expect(api.backoff).eql(0));
    });
  });

  /** @test {KintoClient#bucket} */
  describe("#bucket()", () => {
    it("should return a Bucket instance", () => {
      expect(api.bucket("foo")).to.be.an.instanceOf(Bucket);
    });

    it("should propagate default req options to bucket instance", () => {
      const options = {
        safe: true,
        retry: 0,
        headers: { Foo: "Bar" },
        batch: false,
      };

      const bucket = api.bucket("foo", options);
      expect(bucket).property("_safe", options.safe);
      expect(bucket).property("_retry", options.retry);
      expect(bucket)
        .property("_headers")
        .eql(options.headers);
      expect(bucket).property("_isBatch", options.batch);
    });
  });

  /** @test {KintoClient#fetchServerInfo} */
  describe("#fetchServerInfo", () => {
    const fakeServerInfo = { fake: true };

    it("should retrieve server settings on first request made", () => {
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api.fetchServerInfo().should.eventually.become(fakeServerInfo);
    });

    it("should store server settings into the serverSettings property", () => {
      api.serverSettings = { a: 1 };
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api.fetchServerInfo().then(_ => {
        expect(api)
          .property("serverInfo")
          .deep.equal(fakeServerInfo);
      });
    });

    it("should not fetch server settings if they're cached already", () => {
      api.serverInfo = fakeServerInfo;
      sandbox.stub(global, "fetch");

      api.fetchServerInfo();
      sinon.assert.notCalled(fetch);
    });

    it("should refresh server info if headers were changed", () => {
      api.serverInfo = fakeServerInfo;
      api.setHeaders({
        Authorization: "Baz",
      });
      expect(api.serverInfo).eql(null);
    });
  });

  /** @test {KintoClient#fetchServerSettings} */
  describe("#fetchServerSettings()", () => {
    const fakeServerInfo = { settings: { fake: true } };

    it("should retrieve server settings", () => {
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api
        .fetchServerSettings()
        .should.eventually.have.property("fake")
        .eql(true);
    });
  });

  /** @test {KintoClient#fetchServerCapabilities} */
  describe("#fetchServerCapabilities()", () => {
    const fakeServerInfo = { capabilities: { fake: true } };

    it("should retrieve server capabilities", () => {
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api
        .fetchServerCapabilities()
        .should.eventually.have.property("fake")
        .eql(true);
    });
  });

  /** @test {KintoClient#fetchUser} */
  describe("#fetchUser()", () => {
    const fakeServerInfo = { user: { fake: true } };

    it("should retrieve user information", () => {
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api
        .fetchUser()
        .should.eventually.have.property("fake")
        .eql(true);
    });
  });

  /** @test {KintoClient#fetchHTTPApiVersion} */
  describe("#fetchHTTPApiVersion()", () => {
    const fakeServerInfo = { http_api_version: { fake: true } };

    it("should retrieve current API version", () => {
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, fakeServerInfo));

      return api
        .fetchHTTPApiVersion()
        .should.eventually.have.property("fake")
        .eql(true);
    });
  });

  /** @test {KintoClient#batch} */
  describe("#batch", () => {
    beforeEach(() => {
      const fetchServerSettings = sandbox.stub().returns(
        Promise.resolve({
          batch_max_requests: 3,
        })
      );
      sandbox.stub(api, "fetchServerSettings").get(() => fetchServerSettings);
    });

    function executeBatch(fixtures, options) {
      return api
        .bucket("default")
        .collection("blog")
        .batch(batch => {
          for (const article of fixtures) {
            batch.createRecord(article);
          }
        }, options);
    }

    describe("Batch client setup", () => {
      it("should skip registering HTTP events", () => {
        const on = sandbox.spy();
        const api = new KintoClient(FAKE_SERVER_URL, { events: { on } });

        return api.batch(() => {}).then(() => sinon.assert.calledOnce(on));
      });
    });

    describe("server request", () => {
      let requestBody, requestHeaders, fetch;

      beforeEach(() => {
        fetch = sandbox.stub(global, "fetch");
        fetch.returns(fakeServerResponse(200, { responses: [] }));
      });

      it("should ensure server settings are fetched", () => {
        return api
          .batch(batch => batch.createBucket("blog"))
          .then(_ => sinon.assert.called(api.fetchServerSettings));
      });

      describe("empty request list", () => {
        it("should not perform request on empty operation list", () => {
          api.batch(batch => {});

          sinon.assert.notCalled(fetch);
        });
      });

      describe("non-empty request list", () => {
        const fixtures = [
          { title: "art1" },
          { title: "art2" },
          { title: "art3" },
        ];

        beforeEach(() => {
          api._headers = { Authorization: "Basic plop" };
          return api
            .bucket("default")
            .collection("blog")
            .batch(
              batch => {
                for (const article of fixtures) {
                  batch.createRecord(article);
                }
              },
              { headers: { Foo: "Bar" } }
            )
            .then(_ => {
              const request = fetch.firstCall.args[1];
              requestHeaders = request.headers;
              requestBody = JSON.parse(request.body);
            });
        });

        it("should call the batch endpoint", () => {
          sinon.assert.calledWithMatch(fetch, `/${SPV}/batch`);
        });

        it("should define main batch request default headers", () => {
          expect(requestBody.defaults.headers).eql({
            Authorization: "Basic plop",
            Foo: "Bar",
          });
        });

        it("should attach all batch request headers", () => {
          expect(requestHeaders.Authorization).eql("Basic plop");
        });

        it("should batch the expected number of requests", () => {
          expect(requestBody.requests.length).eql(3);
        });
      });

      describe("Safe mode", () => {
        const fixtures = [{ title: "art1" }, { title: "art2" }];

        it("should forward the safe option to resulting requests", () => {
          return api
            .bucket("default")
            .collection("blog")
            .batch(
              batch => {
                for (const article of fixtures) {
                  batch.createRecord(article);
                }
              },
              { safe: true }
            )
            .then(_ => {
              const { requests } = JSON.parse(fetch.firstCall.args[1].body);
              expect(requests.map(r => r.headers)).eql([
                { "If-None-Match": "*" },
                { "If-None-Match": "*" },
              ]);
            });
        });
      });

      describe("Retry", () => {
        const response = {
          status: 201,
          path: `/${SPV}/buckets/blog/collections/articles/records`,
          body: { data: { id: 1, title: "art" } },
        };

        beforeEach(() => {
          sandbox.stub(global, "setTimeout").callsFake(setImmediate);

          fetch
            .onCall(0)
            .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          fetch.onCall(1).returns(
            fakeServerResponse(200, {
              responses: [response],
            })
          );
        });

        it("should retry the request if option is specified", () => {
          return api
            .bucket("default")
            .collection("blog")
            .batch(batch => batch.createRecord({}), { retry: 1 })
            .then(r => expect(r[0]).eql(response));
        });
      });
    });

    describe("server response", () => {
      const fixtures = [{ id: 1, title: "art1" }, { id: 2, title: "art2" }];

      it("should reject on HTTP 400", () => {
        sandbox.stub(global, "fetch").returns(
          fakeServerResponse(400, {
            error: true,
            errno: 117,
            message: "http 400",
          })
        );

        return executeBatch(fixtures).should.eventually.be.rejectedWith(
          Error,
          /HTTP 400/
        );
      });

      it("should reject on HTTP error status code", () => {
        sandbox.stub(global, "fetch").returns(
          fakeServerResponse(500, {
            error: true,
            message: "http 500",
          })
        );

        return executeBatch(fixtures).should.eventually.be.rejectedWith(
          Error,
          /HTTP 500/
        );
      });

      it("should expose succesful subrequest responses", () => {
        const responses = [
          {
            status: 201,
            path: `/${SPV}/buckets/blog/collections/articles/records`,
            body: { data: fixtures[0] },
          },
          {
            status: 201,
            path: `/${SPV}/buckets/blog/collections/articles/records`,
            body: { data: fixtures[1] },
          },
        ];
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { responses }));

        return executeBatch(fixtures).should.eventually.become(responses);
      });

      it("should expose failing subrequest responses", () => {
        const missingRemotely = fixtures[0];
        const responses = [
          {
            status: 404,
            path: `/${SPV}/buckets/blog/collections/articles/records/1`,
            body: missingRemotely,
          },
        ];
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { responses }));

        return executeBatch(fixtures).should.eventually.become(responses);
      });

      it("should resolve with encountered HTTP 500", () => {
        const responses = [
          {
            status: 500,
            path: `/${SPV}/buckets/blog/collections/articles/records/1`,
            body: { 500: true },
          },
        ];
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { responses }));

        return executeBatch(fixtures).should.eventually.become(responses);
      });

      it("should expose encountered HTTP 412", () => {
        const responses = [
          {
            status: 412,
            path: `/${SPV}/buckets/blog/collections/articles/records/1`,
            body: { details: { existing: { title: "foo" } } },
          },
        ];
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { responses }));

        return executeBatch(fixtures).should.eventually.become(responses);
      });
    });

    describe("Chunked requests", () => {
      // 4 operations, one more than the test limit which is 3
      const fixtures = [
        { id: 1, title: "foo" },
        { id: 2, title: "bar" },
        { id: 3, title: "baz" },
        { id: 4, title: "qux" },
      ];

      it("should chunk batch requests", () => {
        sandbox
          .stub(global, "fetch")
          .onFirstCall()
          .returns(
            fakeServerResponse(200, {
              responses: [
                { status: 200, body: { data: 1 } },
                { status: 200, body: { data: 2 } },
                { status: 200, body: { data: 3 } },
              ],
            })
          )
          .onSecondCall()
          .returns(
            fakeServerResponse(200, {
              responses: [{ status: 200, body: { data: 4 } }],
            })
          );
        return executeBatch(fixtures)
          .then(res => res.map(response => response.body.data))
          .should.become([1, 2, 3, 4]);
      });

      it("should not chunk batch requests if setting is falsy", () => {
        api.fetchServerSettings.returns(
          Promise.resolve({
            batch_max_requests: null,
          })
        );
        sandbox.stub(global, "fetch").returns(
          fakeServerResponse(200, {
            responses: [],
          })
        );
        return executeBatch(fixtures).then(_ => sinon.assert.calledOnce(fetch));
      });

      it("should map initial records to conflict objects", () => {
        sandbox
          .stub(global, "fetch")
          .onFirstCall()
          .returns(
            fakeServerResponse(200, {
              responses: [
                { status: 412, body: { details: { existing: { id: 1 } } } },
                { status: 412, body: { details: { existing: { id: 2 } } } },
                { status: 412, body: {} },
              ],
            })
          )
          .onSecondCall()
          .returns(
            fakeServerResponse(200, {
              responses: [
                { status: 412, body: { details: { existing: { id: 4 } } } },
              ],
            })
          );
        return executeBatch(fixtures)
          .then(res => res.map(response => response.status))
          .should.become([412, 412, 412, 412]);
      });

      it("should chunk batch requests concurrently", () => {
        sandbox
          .stub(global, "fetch")
          .onFirstCall()
          .returns(
            new Promise(resolve => {
              function onTimeout() {
                resolve(
                  fakeServerResponse(200, {
                    responses: [
                      { status: 200, body: { data: 1 } },
                      { status: 200, body: { data: 2 } },
                      { status: 200, body: { data: 3 } },
                    ],
                  })
                );
              }
              setTimeout(onTimeout, 100);
            })
          )
          .onSecondCall()
          .returns(
            new Promise(resolve => {
              function onTimeout() {
                resolve(
                  fakeServerResponse(200, {
                    responses: [{ status: 200, body: { data: 4 } }],
                  })
                );
              }
              setTimeout(onTimeout, 5);
            })
          );
        return executeBatch(fixtures)
          .then(res => res.map(response => response.body.data))
          .should.become([1, 2, 3, 4]);
      });
    });

    describe("Aggregate mode", () => {
      const fixtures = [
        { title: "art1" },
        { title: "art2" },
        { title: "art3" },
        { title: "art4" },
      ];

      it("should resolve with an aggregated result object", () => {
        const responses = [];
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { responses }));
        const batchModule = require("../src/batch");
        const aggregate = sandbox.stub(batchModule, "aggregate");

        return executeBatch(fixtures, { aggregate: true }).then(_ => {
          sinon.assert.calledWith(aggregate, responses);
        });
      });
    });
  });

  /** @test {KintoClient#execute} */
  describe("#execute()", () => {
    it("should ensure passing defined allowed defined request options", () => {
      sinon.stub(api, "fetchServerInfo").returns(Promise.resolve({}));
      const request = sinon
        .stub(api.http, "request")
        .returns(Promise.resolve({}));

      return api.execute({ path: "/foo", garbage: true }).then(() => {
        sinon.assert.calledWith(
          request,
          "http://fake-server/v1/foo",
          {},
          { retry: 0 }
        );
      });
    });
  });

  /** @test {KintoClient#paginatedList} */
  describe("#paginatedList()", () => {
    const ETag = '"42"';
    const totalRecords = 1337;
    const path = "/some/path";

    describe("No pagination", () => {
      beforeEach(() => {
        // Since listRecords use `raw: true`, stub with full response:
        sandbox.stub(api, "execute").returns(
          Promise.resolve({
            json: { data: [{ a: 1 }] },
            headers: {
              get: name => {
                if (name === "ETag") {
                  return ETag;
                } else if (name === "Total-Records") {
                  return String(totalRecords);
                }
              },
            },
          })
        );
      });

      it("should execute expected request", () => {
        api.paginatedList(path);

        sinon.assert.calledWithMatch(
          api.execute,
          { path: `${path}?_sort=-last_modified` },
          { raw: true }
        );
      });

      it("should sort records", () => {
        api.paginatedList(path, { sort: "title" });

        sinon.assert.calledWithMatch(
          api.execute,
          { path: `${path}?_sort=title` },
          { raw: true }
        );
      });

      it("should resolve with records list", () => {
        return api
          .paginatedList(path)
          .should.eventually.have.property("data")
          .eql([{ a: 1 }]);
      });

      it("should resolve with number of total records", () => {
        return api
          .paginatedList(path)
          .should.eventually.have.property("totalRecords")
          .eql(1337);
      });

      it("should resolve with a next() function", () => {
        return api
          .paginatedList(path)
          .should.eventually.have.property("next")
          .to.be.a("function");
      });

      it("should support the since option", () => {
        api.paginatedList(path, { since: ETag });

        const qs = "_sort=-last_modified&_since=%2242%22";
        sinon.assert.calledWithMatch(api.execute, { path: `${path}?${qs}` });
      });

      it("should throw if the since option is invalid", () => {
        return api
          .paginatedList(path, { since: 123 })
          .should.be.rejectedWith(
            Error,
            /Invalid value for since \(123\), should be ETag value/
          );
      });

      it("should resolve with the collection last_modified without quotes", () => {
        return api
          .paginatedList(path)
          .should.eventually.have.property("last_modified")
          .eql("42");
      });

      it("should resolve with the hasNextPage being set to false", () => {
        return api
          .paginatedList(path)
          .should.eventually.have.property("hasNextPage")
          .eql(false);
      });
    });

    describe("Filtering", () => {
      beforeEach(() => {
        sandbox.stub(api, "execute").returns(
          Promise.resolve({
            json: { data: [] },
            headers: { get: () => {} },
          })
        );
      });

      it("should generate the expected filtering query string", () => {
        api.paginatedList(path, { sort: "x", filters: { min_y: 2, not_z: 3 } });

        const expectedQS = "min_y=2&not_z=3&_sort=x";
        sinon.assert.calledWithMatch(
          api.execute,
          { path: `${path}?${expectedQS}` },
          { raw: true }
        );
      });

      it("shouldn't need an explicit sort parameter", () => {
        api.paginatedList(path, { filters: { min_y: 2, not_z: 3 } });

        const expectedQS = "min_y=2&not_z=3&_sort=-last_modified";
        sinon.assert.calledWithMatch(
          api.execute,
          { path: `${path}?${expectedQS}` },
          { raw: true }
        );
      });
    });

    describe("Pagination", () => {
      let headersgetSpy;
      it("should issue a request with the specified limit applied", () => {
        sandbox.stub(api, "execute").returns(
          Promise.resolve({
            json: { data: [] },
            headers: { get: headersgetSpy },
          })
        );

        api.paginatedList(path, { limit: 2 });

        const expectedQS = "_sort=-last_modified&_limit=2";
        sinon.assert.calledWithMatch(
          api.execute,
          { path: `${path}?${expectedQS}` },
          { raw: true }
        );
      });

      it("should query for next page", () => {
        const { http } = api;
        headersgetSpy = sandbox.stub().returns("http://next-page/");
        sandbox.stub(api, "execute").returns(
          Promise.resolve({
            json: { data: [] },
            headers: { get: headersgetSpy },
          })
        );
        sandbox.stub(http, "request").returns(
          Promise.resolve({
            headers: { get: () => {} },
            json: { data: [] },
          })
        );

        return api.paginatedList(path, { limit: 2, pages: 2 }).then(_ => {
          sinon.assert.calledWith(http.request, "http://next-page/");
        });
      });

      it("should aggregate paginated results", () => {
        const { http } = api;
        sandbox
          .stub(http, "request")
          // first page
          .onFirstCall()
          .returns(
            Promise.resolve({
              headers: { get: () => "http://next-page/" },
              json: { data: [1, 2] },
            })
          )
          // second page
          .onSecondCall()
          .returns(
            Promise.resolve({
              headers: { get: () => {} },
              json: { data: [3] },
            })
          );

        return api
          .paginatedList(path, { limit: 2, pages: 2 })
          .should.eventually.have.property("data")
          .eql([1, 2, 3]);
      });

      it("should resolve with the hasNextPage being set to true", () => {
        const { http } = api;
        sandbox
          .stub(http, "request")
          // first page
          .onFirstCall()
          .returns(
            Promise.resolve({
              headers: { get: () => "http://next-page/" },
              json: { data: [1, 2] },
            })
          );

        return api
          .paginatedList(path)
          .should.eventually.have.property("hasNextPage")
          .eql(true);
      });

      it("should resolve with number of total records", () => {
        const { http } = api;
        sandbox
          .stub(http, "request")
          // first page
          .onFirstCall()
          .returns(
            Promise.resolve({
              headers: { get: () => "1337" },
              json: { data: [1, 2] },
            })
          );

        return api
          .paginatedList(path)
          .should.eventually.have.property("totalRecords")
          .eql(1337);
      });
    });

    describe("Batch mode", () => {
      it("should not attempt at consumming response headers ", () => {
        // Emulate an ongoing batch operation
        api._isBatch = true;

        return api.paginatedList(path).should.not.be.rejected;
      });
    });
  });

  /** @test {KintoClient#listPermissions} */
  describe("#listPermissions()", () => {
    const data = [{ id: "a" }, { id: "b" }];

    describe("Capability available", () => {
      beforeEach(() => {
        api.serverInfo = { capabilities: { permissions_endpoint: {} } };
        sandbox.stub(api, "paginatedList").returns(Promise.resolve({ data }));
      });

      it("should execute expected request", () => {
        api.listPermissions().then(() => {
          sinon.assert.calledWithMatch(api.execute, { path: "/permissions" });
        });
      });

      it("should support passing custom headers", () => {
        api._headers = { Foo: "Bar" };
        api.listPermissions({ headers: { Baz: "Qux" } }).then(() => {
          sinon.assert.calledWithMatch(api.execute, {
            headers: { Foo: "Bar", Baz: "Qux" },
          });
        });
      });

      it("should resolve with a result object", () => {
        return api
          .listPermissions()
          .should.eventually.have.property("data")
          .eql(data);
      });
    });

    describe("Capability unavailable", () => {
      it("should reject with an error when the capability is not available", () => {
        api.serverInfo = { capabilities: {} };

        return api
          .listPermissions()
          .should.be.rejectedWith(Error, /permissions_endpoint/);
      });
    });
  });

  /** @test {KintoClient#listBuckets} */
  describe("#listBuckets()", () => {
    const data = [{ id: "a" }, { id: "b" }];

    beforeEach(() => {
      sandbox.stub(api, "paginatedList").returns(Promise.resolve({ data }));
    });

    it("should execute expected request", () => {
      api.listBuckets({ _since: "42" });

      sinon.assert.calledWithMatch(
        api.paginatedList,
        "/buckets",
        { _since: "42" },
        { headers: {} }
      );
    });

    it("should support passing custom headers", () => {
      api._headers = { Foo: "Bar" };
      api.listBuckets({ headers: { Baz: "Qux" } });

      sinon.assert.calledWithMatch(
        api.paginatedList,
        "/buckets",
        {},
        { headers: { Foo: "Bar", Baz: "Qux" } }
      );
    });

    it("should resolve with a result object", () => {
      return api
        .listBuckets()
        .should.eventually.have.property("data")
        .eql(data);
    });
  });

  /** @test {KintoClient#createBucket} */
  describe("#createBucket", () => {
    beforeEach(() => {
      sandbox.stub(requests, "createRequest");
      sandbox.stub(api, "execute").returns(Promise.resolve());
    });

    it("should execute expected request", () => {
      api.createBucket("foo");

      sinon.assert.calledWithMatch(
        requests.createRequest,
        "/buckets/foo",
        { data: { id: "foo" }, permissions: undefined },
        { headers: {}, safe: false }
      );
    });

    it("should accept a data option", () => {
      api.createBucket("foo", { data: { a: 1 } });

      sinon.assert.calledWithMatch(
        requests.createRequest,
        "/buckets/foo",
        { data: { id: "foo", a: 1 }, permissions: undefined },
        { headers: {}, safe: false }
      );
    });

    it("should accept a safe option", () => {
      api.createBucket("foo", { safe: true });

      sinon.assert.calledWithMatch(
        requests.createRequest,
        "/buckets/foo",
        { data: { id: "foo" }, permissions: undefined },
        { headers: {}, safe: true }
      );
    });

    it("should extend request headers with optional ones", () => {
      api._headers = { Foo: "Bar" };

      api.createBucket("foo", { headers: { Baz: "Qux" } });

      sinon.assert.calledWithMatch(
        requests.createRequest,
        "/buckets/foo",
        { data: { id: "foo" }, permissions: undefined },
        { headers: { Foo: "Bar", Baz: "Qux" }, safe: false }
      );
    });
  });

  /** @test {KintoClient#deleteBucket} */
  describe("#deleteBucket()", () => {
    beforeEach(() => {
      sandbox.stub(requests, "deleteRequest");
      sandbox.stub(api, "execute").returns(Promise.resolve());
    });

    it("should execute expected request", () => {
      api.deleteBucket("plop");

      sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets/plop", {
        headers: {},
        safe: false,
      });
    });

    it("should accept a bucket object", () => {
      api.deleteBucket({ id: "plop" });

      sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets/plop", {
        headers: {},
        safe: false,
      });
    });

    it("should accept a safe option", () => {
      api.deleteBucket("plop", { safe: true });

      sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets/plop", {
        safe: true,
      });
    });

    it("should extend request headers with optional ones", () => {
      api._headers = { Foo: "Bar" };

      api.deleteBucket("plop", { headers: { Baz: "Qux" } });

      sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets/plop", {
        headers: { Foo: "Bar", Baz: "Qux" },
      });
    });
  });

  /** @test {KintoClient#deleteBuckets} */
  describe("#deleteBuckets()", () => {
    beforeEach(() => {
      api.serverInfo = { http_api_version: "1.4" };
      sandbox.stub(requests, "deleteRequest");
      sandbox.stub(api, "execute").returns(Promise.resolve({}));
    });

    it("should execute expected request", () => {
      return api.deleteBuckets().then(_ => {
        sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets", {
          headers: {},
          safe: false,
        });
      });
    });

    it("should accept a safe option", () => {
      return api.deleteBuckets({ safe: true }).then(_ => {
        sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets", {
          safe: true,
        });
      });
    });

    it("should extend request headers with optional ones", () => {
      api._headers = { Foo: "Bar" };

      return api.deleteBuckets({ headers: { Baz: "Qux" } }).then(_ => {
        sinon.assert.calledWithMatch(requests.deleteRequest, "/buckets", {
          headers: { Foo: "Bar", Baz: "Qux" },
        });
      });
    });

    it("should reject if http_api_version mismatches", () => {
      api.serverInfo = { http_api_version: "1.3" };

      return api.deleteBuckets().should.be.rejectedWith(Error, /Version/);
    });
  });
});