Home Reference Source Test

test/collection_test.js

"use strict";

import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import { EventEmitter } from "events";
import { v4 as uuid4 } from "uuid";

import IDB from "../src/adapters/IDB";
import BaseAdapter from "../src/adapters/base";
import Collection, { SyncResultObject } from "../src/collection";
import Api from "kinto-http";
import KintoClient from "kinto-http";
import KintoClientCollection from "kinto-http/lib/collection.js";
import { recordsEqual } from "../src/collection";
import { updateTitleWithDelay, fakeServerResponse } from "./test_utils";
import { createKeyValueStoreIdSchema } from "../src/collection";

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

const TEST_BUCKET_NAME = "kinto-test";
const TEST_COLLECTION_NAME = "kinto-test";
const FAKE_SERVER_URL = "http://fake-server/v1";
const NULL_SCHEMA = {
  generate() {},
  validate() {
    return true;
  },
};

/** @test {Collection} */
describe("Collection", () => {
  /*eslint-disable */
  let sandbox, events, idSchema, remoteTransformers, hooks, api;
  /*eslint-enable */
  const article = { title: "foo", url: "http://foo" };

  function testCollection(options = {}) {
    events = new EventEmitter();
    const opts = { events, ...options };
    api = new Api(FAKE_SERVER_URL, events);
    return new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, opts);
  }

  function createEncodeTransformer(char, delay) {
    return {
      encode(record) {
        return updateTitleWithDelay(record, char, delay);
      },
      decode(record) {},
    };
  }

  function createIntegerIdSchema() {
    let _next = 0;
    return {
      generate() {
        return _next++;
      },
      validate(id) {
        return id == parseInt(id, 10) && id >= 0;
      },
    };
  }

  function createKeyListIdSchema() {
    return {
      generate(record) {
        return Object.keys(record)
          .sort()
          .join(",");
      },
      validate(id) {
        return id !== "";
      },
    };
  }

  beforeEach(() => {
    sandbox = sinon.createSandbox();
    return testCollection().clear();
  });

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

  describe("Helpers", () => {
    /** @test {recordsEqual} */
    describe("#recordsEqual", () => {
      it("should compare record data without metadata", () => {
        expect(
          recordsEqual(
            { title: "foo", _status: "foo", last_modified: 32 },
            { title: "foo" }
          )
        ).eql(true);
      });

      it("should compare record data without metadata nor local fields", () => {
        expect(
          recordsEqual(
            { title: "foo", _status: "foo", size: 32 },
            { title: "foo" },
            ["size"]
          )
        ).eql(true);
      });
    });
  });

  /** @test {Collection#constructor} */
  describe("#constructor", () => {
    it("should expose a passed events instance", () => {
      const events = new EventEmitter();
      const api = new Api(FAKE_SERVER_URL, { events });
      const collection = new Collection(
        TEST_BUCKET_NAME,
        TEST_COLLECTION_NAME,
        api,
        { events }
      );
      expect(collection.events).to.eql(events);
    });

    it("should propagate its events property to child dependencies", () => {
      const events = new EventEmitter();
      const api = new Api(FAKE_SERVER_URL, { events });
      const collection = new Collection(
        TEST_BUCKET_NAME,
        TEST_COLLECTION_NAME,
        api,
        { events }
      );
      expect(collection.api.events).eql(collection.events);
      expect(collection.api.http.events).eql(collection.events);
    });

    it("should allow providing a prefix for the db name", () => {
      const collection = new Collection(
        TEST_BUCKET_NAME,
        TEST_COLLECTION_NAME,
        api,
        {
          adapterOptions: {
            dbName: "LocalData",
          },
        }
      );
      expect(collection.db.dbName).eql("LocalData");
      expect(collection.db.cid).eql(
        `${TEST_BUCKET_NAME}/${TEST_COLLECTION_NAME}`
      );
    });

    it("should use the default adapter if not any is provided", () => {
      const events = new EventEmitter();
      const api = new Api(FAKE_SERVER_URL, { events });
      const hooks = {};
      const collection = new Collection(
        TEST_BUCKET_NAME,
        TEST_COLLECTION_NAME,
        api,
        { hooks }
      );
      expect(collection.db).to.be.an.instanceof(IDB);
    });

    it("should throw incompatible adapter options", () => {
      const events = new EventEmitter();
      const api = new Api(FAKE_SERVER_URL, { events });
      expect(() => {
        new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {
          adapter: function() {},
        });
      }).to.Throw(Error, /Unsupported adapter/);
    });

    it("should allow providing an adapter option", () => {
      const MyAdapter = class extends BaseAdapter {};
      const collection = new Collection(
        TEST_BUCKET_NAME,
        TEST_COLLECTION_NAME,
        api,
        {
          adapter: MyAdapter,
        }
      );
      expect(collection.db).to.be.an.instanceOf(MyAdapter);
    });

    it("should pass adapterOptions to adapter", () => {
      let myOptions;
      const MyAdapter = class extends BaseAdapter {
        constructor(collectionName, options) {
          super(collectionName);
          myOptions = options;
        }
      };
      new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {
        adapter: MyAdapter,
        adapterOptions: "my options",
      });
      expect(myOptions).eql("my options");
    });

    describe("transformers registration", () => {
      function registerTransformers(transformers) {
        new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {
          remoteTransformers: transformers,
        });
      }

      it("should throw an error on non-array remoteTransformers", () => {
        expect(registerTransformers.bind(null, {})).to.Throw(
          Error,
          /remoteTransformers should be an array/
        );
      });

      it("should throw an error on non-object transformer", () => {
        expect(registerTransformers.bind(null, ["invalid"])).to.Throw(
          Error,
          /transformer must be an object/
        );
      });

      it("should throw an error on encode method missing", () => {
        expect(registerTransformers.bind(null, [{ decode() {} }])).to.Throw(
          Error,
          /transformer must provide an encode function/
        );
      });

      it("should throw an error on decode method missing", () => {
        expect(registerTransformers.bind(null, [{ encode() {} }])).to.Throw(
          Error,
          /transformer must provide a decode function/
        );
      });
    });

    describe("hooks registration", () => {
      function registerHooks(hooks) {
        return new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {
          hooks,
        });
      }

      it("should throw an error on non-object hooks", () => {
        expect(registerHooks.bind(null, function() {})).to.Throw(
          Error,
          /hooks should be an object/
        );
      });

      it("should throw an error on array hooks", () => {
        expect(registerHooks.bind(null, [])).to.Throw(
          Error,
          /hooks should be an object, not an array./
        );
      });

      it("should return a empty object if no hook where specified", () => {
        const collection = registerHooks();
        expect(collection.hooks).to.eql({});
      });

      it("should throw an error on unknown hook", () => {
        expect(
          registerHooks.bind(null, {
            invalid: [],
          })
        ).to.Throw(Error, /The hook should be one of/);
      });

      it("should throw if the hook isn't a list", () => {
        expect(
          registerHooks.bind(null, {
            "incoming-changes": {},
          })
        ).to.Throw(Error, /A hook definition should be an array of functions./);
      });

      it("should throw an error if the hook is not an array of functions", () => {
        expect(
          registerHooks.bind(null, {
            "incoming-changes": ["invalid"],
          })
        ).to.Throw(Error, /A hook definition should be an array of functions./);
      });
    });

    describe("idSchema registration", () => {
      function registerIdSchema(idSchema) {
        new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {
          idSchema: idSchema,
        });
      }

      it("should throw an error on non-object transformer", () => {
        expect(registerIdSchema.bind(null, "invalid")).to.Throw(
          Error,
          /idSchema must be an object/
        );
      });

      it("should throw an error on generate method missing", () => {
        expect(
          registerIdSchema.bind(null, {
            validate() {},
          })
        ).to.Throw(Error, /idSchema must provide a generate function/);
      });

      it("should throw an error on validate method missing", () => {
        expect(
          registerIdSchema.bind(null, {
            generate() {},
          })
        ).to.Throw(Error, /idSchema must provide a validate function/);
      });
    });
  });

  /** @test {SyncResultObject} */
  describe("SyncResultObject", () => {
    it("should create a result object", () => {
      expect(new SyncResultObject()).to.include.keys([
        "lastModified",
        "errors",
        "created",
        "updated",
        "deleted",
        "published",
        "conflicts",
        "skipped",
      ]);
    });

    describe("set lastModified", () => {
      it("should set lastModified", () => {
        const result = new SyncResultObject();

        result.lastModified = 42;

        expect(result.lastModified).eql(42);
      });
    });

    /** @test {SyncResultObject#add} */
    describe("#add", () => {
      it("should add typed entries", () => {
        const result = new SyncResultObject();

        result.add("skipped", [1, 2]);
        expect(result.skipped).eql([1, 2]);
      });

      it("should concat typed entries", () => {
        const result = new SyncResultObject();

        result.add("skipped", [1, 2]);
        expect(result.skipped).eql([1, 2]);

        result.add("skipped", [3]);
        expect(result.skipped).eql([1, 2, 3]);
      });

      it("should overwrite entries with same id", () => {
        const result = new SyncResultObject();

        result.add("skipped", [{ id: 1, name: "a" }]);
        result.add("skipped", [{ id: 2, name: "b" }]);
        result.add("skipped", [{ id: 1, name: "c" }]);
        result.add("skipped", [{ name: "d" }]);
        result.add("skipped", [{ name: "e" }]);
        expect(result.skipped).eql([
          { id: 1, name: "c" },
          { id: 2, name: "b" },
          { name: "d" },
          { name: "e" },
        ]);
      });

      it("should deduplicate added entries with same id", () => {
        const result = new SyncResultObject();

        result.add("created", [{ id: 1, name: "a" }, { id: 1, name: "b" }]);
        expect(result.created).eql([{ id: 1, name: "b" }]);
      });

      it("should update the ok status flag on errors", () => {
        const result = new SyncResultObject();

        result.add("errors", [1]);

        expect(result.ok).eql(false);
      });

      it("should update the ok status flag on conflicts", () => {
        const result = new SyncResultObject();

        result.add("conflicts", [1]);

        expect(result.ok).eql(false);
      });

      it("should alter non-array properties", () => {
        const result = new SyncResultObject();

        result.add("ok", false);

        expect(result.ok).eql(true);
      });

      it("should return the current result object", () => {
        const result = new SyncResultObject();

        expect(result.add("resolved", [])).eql(result);
      });

      it("should support adding single objects", () => {
        const result = new SyncResultObject();

        const e = {
          type: "incoming",
          message: "conflict",
        };
        result.add("errors", e);
        expect(result.errors).eql([e]);
      });
    });

    /** @test {SyncResultObject#reset} */
    describe("#reset", () => {
      it("should reset to array prop to its default value", () => {
        const result = new SyncResultObject()
          .add("resolved", [1, 2, 3])
          .reset("resolved");

        expect(result.resolved).eql([]);
      });

      it("should return the current result object", () => {
        const result = new SyncResultObject();

        expect(result.reset("resolved")).eql(result);
      });
    });
  });

  /** @test {Collection#clear} */
  describe("#clear", () => {
    let articles;

    beforeEach(() => {
      articles = testCollection();
      return Promise.all([
        articles.create({ title: "foo" }),
        articles.create({ title: "bar" }),
        articles.db.saveMetadata({ id: "articles", last_modified: 42 }),
      ]);
    });

    it("should clear collection records", () => {
      return articles
        .clear()
        .then(_ => articles.list())
        .then(res => res.data)
        .should.eventually.have.length.of(0);
    });

    it("should clear collection timestamp", () => {
      return articles.db
        .saveLastModified(42)
        .then(_ => articles.clear())
        .then(_ => articles.db.getLastModified())
        .should.eventually.eql(null);
    });

    it("should clear collection metadata", () => {
      return articles
        .clear()
        .then(_ => articles.metadata())
        .should.eventually.eql(null);
    });
  });

  /** @test {Collection#create} */
  describe("#create", () => {
    let articles;

    beforeEach(() => (articles = testCollection()));

    it("should create a record and return created record data", () => {
      return articles.create(article).should.eventually.have.property("data");
    });

    it("should create a record and return created record perms", () => {
      return articles
        .create(article)
        .should.eventually.have.property("permissions");
    });

    it("should assign an id to the created record", () => {
      return articles
        .create(article)
        .then(result => result.data.id)
        .should.eventually.be.a("string");
    });

    it("should assign an id to the created record (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      return articles
        .create(article)
        .then(result => result.data.id)
        .should.eventually.be.a("number");
    });

    it("should accept a record for the 'generate' function", () => {
      articles = testCollection({ idSchema: createKeyListIdSchema() });

      return articles
        .create(article)
        .then(result => result.data.id)
        .should.eventually.eql("title,url");
    });

    it("should reject when useRecordId is true and record is missing an id", () => {
      return articles
        .create({ title: "foo" }, { useRecordId: true })
        .should.be.rejectedWith(Error, /Missing required Id/);
    });

    it("should reject when synced is true and record is missing an id", () => {
      return articles
        .create({ title: "foo" }, { synced: true })
        .should.be.rejectedWith(Error, /Missing required Id/);
    });

    it("should reject when passed an id and synced and useRecordId are false", () => {
      return articles
        .create({ id: "some-id" }, { synced: false, useRecordId: false })
        .should.be.rejectedWith(Error, /Extraneous Id/);
    });

    it("should not alter original record", () => {
      return articles.create(article).should.eventually.not.eql(article);
    });

    it("should add record status on creation", () => {
      return articles
        .create(article)
        .then(res => res.data._status)
        .should.eventually.eql("created");
    });

    it("should reject if passed argument is not an object", () => {
      return articles
        .create(42)
        .should.eventually.be.rejectedWith(Error, /is not an object/);
    });

    it("should actually persist the record into the collection", () => {
      return articles
        .create(article)
        .then(result => articles.get(result.data.id))
        .then(res => res.data.title)
        .should.become(article.title);
    });

    it("should support the useRecordId option", () => {
      const testId = uuid4();
      return articles
        .create({ id: testId, title: "foo" }, { useRecordId: true })
        .then(result => articles.get(result.data.id))
        .then(res => res.data.id)
        .should.become(testId);
    });

    it("should validate record's Id when provided", () => {
      return articles
        .create({ id: "a/b", title: "foo" }, { useRecordId: true })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should validate record's Id when provided (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      return articles
        .create({ id: "deadbeef", title: "foo" }, { useRecordId: true })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should reject with any encountered transaction error", () => {
      sandbox
        .stub(articles.db, "execute")
        .returns(Promise.reject(new Error("transaction error")));

      return articles
        .create({ title: "foo" })
        .should.be.rejectedWith(Error, /transaction error/);
    });

    it("should reject with a hint if useRecordId has been used", () => {
      return articles
        .create({ id: uuid4() }, { useRecordId: true })
        .then(res => articles.delete(res.data.id))
        .then(res =>
          articles.create({ id: res.data.id }, { useRecordId: true })
        )
        .should.be.rejectedWith(Error, /virtually deleted/);
    });

    it("should throw error when using createKeyValueStoreIdSchema.generate", () => {
      articles = testCollection({ idSchema: createKeyValueStoreIdSchema() });
      expect(() => articles.create(article)).to.throw(
        "createKeyValueStoreIdSchema() does not generate an id"
      );
    });

    it("should return true when using createKeyValueStoreIdSchema.validate", () => {
      articles = testCollection({ idSchema: createKeyValueStoreIdSchema() });
      return articles
        .create({ ...article, id: article.title }, { useRecordId: true })
        .then(result => articles.getAny(result.data.id))
        .then(result => result.data.id)
        .should.become(article.title);
    });
  });

  /** @test {Collection#update} */
  describe("#update", () => {
    let articles;

    beforeEach(() => (articles = testCollection({ localFields: ["read"] })));

    it("should update a record", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => res.data)
        .then(existing => {
          return articles.update({ ...existing, title: "new title" });
        })
        .then(res => articles.get(res.data.id))
        .then(res => res.data.title)
        .should.become("new title");
    });

    it("should return the old data for the record", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => res.data)
        .then(existing => {
          return articles.update({ ...existing, title: "new title" });
        })
        .then(res => res.oldRecord.title)
        .should.become("foo");
    });

    it("should update record status on update", () => {
      return articles
        .create({ id: uuid4() }, { synced: true })
        .then(res => res.data)
        .then(data => articles.update({ ...data, title: "blah" }))
        .then(res => res.data._status)
        .should.eventually.eql("updated");
    });

    it("should not update record status if only local fields are changed", () => {
      return articles
        .create({ id: uuid4() }, { synced: true })
        .then(res => res.data)
        .then(data => articles.update({ ...data, read: true }))
        .then(res => res.data._status)
        .should.eventually.eql("synced");
    });

    it("should reject updates on a non-existent record", () => {
      return articles
        .update({ id: uuid4() })
        .should.be.rejectedWith(Error, /not found/);
    });

    it("should reject updates on a non-object record", () => {
      return articles
        .update("invalid")
        .should.be.rejectedWith(Error, /Record is not an object/);
    });

    it("should reject updates on a record without an id", () => {
      return articles
        .update({ title: "foo" })
        .should.be.rejectedWith(Error, /missing id/);
    });

    it("should validate record's id when provided", () => {
      return articles
        .update({ id: 42 })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should validate record's id when provided (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      return articles
        .update({ id: "deadbeef" })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should update a record from its id (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      return articles
        .create(article)
        .then(result => articles.update({ id: result.data.id, title: "foo" }))
        .then(res => res.data.title)
        .should.eventually.eql("foo");
    });

    it("should patch existing record when patch option is used", () => {
      const id = uuid4();
      return articles
        .create(
          { id, title: "foo", last_modified: 42 },
          { useRecordId: true, synced: true }
        )
        .then(() => articles.update({ id, rank: 99 }, { patch: true }))
        .then(res => res.data)
        .should.eventually.become({
          id,
          title: "foo",
          rank: 99,
          last_modified: 42,
          _status: "updated",
        });
    });

    it("should remove previous record fields", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => {
          return articles.update({ id: res.data.id, title: "new title" });
        })
        .then(res => res.data)
        .should.eventually.not.have.property("url");
    });

    it("should preserve record.last_modified", () => {
      return articles
        .create({
          title: "foo",
          url: "http://foo",
          last_modified: 123456789012,
        })
        .then(res => articles.get(res.data.id))
        .then(res => {
          return articles.update({ id: res.data.id, title: "new title" });
        })
        .then(res => res.data)
        .should.eventually.have.property("last_modified")
        .eql(123456789012);
    });

    it("should optionally mark a record as synced", () => {
      return articles
        .create({ title: "foo" })
        .then(res =>
          articles.update({ ...res.data, title: "bar" }, { synced: true })
        )
        .then(res => res.data)
        .should.eventually.have.property("_status")
        .eql("synced");
    });

    it("should preserve created status if record was never synced", () => {
      return articles
        .create({ title: "foo" })
        .then(res =>
          articles.update(Object.assign({}, res.data, { title: "bar" }))
        )
        .then(res => res.data)
        .should.eventually.have.property("_status")
        .eql("created");
    });
  });

  /** @test {Collection#put} */
  describe("#put", () => {
    let articles;

    beforeEach(() => (articles = testCollection()));

    it("should update a record", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => res.data)
        .then(existing => {
          return articles.upsert({ ...existing, title: "new title" });
        })
        .then(res => articles.get(res.data.id))
        .then(res => res.data.title)
        .should.become("new title");
    });

    it("should change record status to updated", () => {
      return articles
        .create({ id: uuid4() }, { synced: true })
        .then(res => res.data)
        .then(data => articles.upsert({ ...data, title: "blah" }))
        .then(res => res.data._status)
        .should.eventually.eql("updated");
    });

    it("should preserve created status if record was never synced", () => {
      return articles
        .create({ title: "foo" })
        .then(res =>
          articles.upsert(Object.assign({}, res.data, { title: "bar" }))
        )
        .then(res => res.data)
        .should.eventually.have.property("_status")
        .eql("created");
    });

    it("should create a new record if non-existent", () => {
      return articles
        .upsert({ id: uuid4(), title: "new title" })
        .then(res => res.data.title)
        .should.eventually.become("new title");
    });

    it("should set status to created if it created a record", () => {
      return articles
        .upsert({ id: uuid4() })
        .then(res => res.data._status)
        .should.eventually.become("created");
    });

    it("should reject updates on a non-object record", () => {
      return articles
        .upsert("invalid")
        .should.be.rejectedWith(Error, /Record is not an object/);
    });

    it("should reject updates on a record without an id", () => {
      return articles
        .upsert({ title: "foo" })
        .should.be.rejectedWith(Error, /missing id/);
    });

    it("should validate record's id when provided", () => {
      return articles
        .upsert({ id: 42 })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should update deleted records", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => articles.delete(res.data.id))
        .then(res => articles.upsert({ ...res.data, title: "new title" }))
        .then(res => res.data.title)
        .should.eventually.become("new title");
    });

    it("should set status of deleted records to updated", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => articles.delete(res.data.id))
        .then(res => articles.upsert({ ...res.data, title: "new title" }))
        .then(res => res.data._status)
        .should.eventually.become("updated");
    });

    it("should validate record's id when provided (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      return articles
        .upsert({ id: "deadbeef" })
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should remove previous record fields", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => {
          return articles.upsert({ id: res.data.id, title: "new title" });
        })
        .then(res => res.data)
        .should.eventually.not.have.property("url");
    });

    it("should preserve record.last_modified", () => {
      return articles
        .create({
          title: "foo",
          url: "http://foo",
          last_modified: 123456789012,
        })
        .then(res => articles.get(res.data.id))
        .then(res => {
          return articles.upsert({ id: res.data.id, title: "new title" });
        })
        .then(res => res.data)
        .should.eventually.have.property("last_modified")
        .eql(123456789012);
    });

    it("should return the old data for the record", () => {
      return articles
        .create(article)
        .then(res => articles.get(res.data.id))
        .then(res => res.data)
        .then(existing => {
          return articles.upsert({ ...existing, title: "new title" });
        })
        .then(res => res.oldRecord.title)
        .should.become("foo");
    });

    it("should not return the old data for a deleted record", () => {
      let articleId;
      return articles
        .create(article)
        .then(res => {
          articleId = res.data.id;
          return articles.delete(articleId);
        })
        .then(res => articles.upsert({ id: articleId, title: "new title" }))
        .then(res => res.oldRecord)
        .should.become(undefined);
    });

    it("should signal when a record was created by oldRecord=undefined", () => {
      return articles
        .upsert({ id: uuid4() })
        .then(res => res.oldRecord)
        .should.become(undefined);
    });
  });

  /** @test {Collection#cleanLocalFields} */
  describe("#cleanLocalFields", () => {
    it("should remove the local fields", () => {
      const collection = testCollection();
      const record = { id: "1", _status: "synced", last_modified: 42 };
      const cleaned = collection.cleanLocalFields(record);

      expect(cleaned).eql({ id: "1", last_modified: 42 });
    });

    it("should take into account collection local fields", () => {
      const collection = testCollection({ localFields: ["size"] });
      const record = {
        id: "1",
        size: 3.14,
        _status: "synced",
        last_modified: 42,
      };
      const cleaned = collection.cleanLocalFields(record);

      expect(cleaned).eql({ id: "1", last_modified: 42 });
    });
  });

  /** @test {Collection#resolve} */
  describe("#resolve", () => {
    let articles, local, remote, conflict;

    beforeEach(() => {
      articles = testCollection();
      return articles
        .create(
          { id: uuid4(), title: "local title", last_modified: 41 },
          { synced: true }
        )
        .then(res => {
          local = res.data;
          remote = {
            ...local,
            title: "blah",
            last_modified: 42,
          };
          conflict = {
            type: "incoming",
            local: local,
            remote: remote,
          };
        });
    });

    it("should mark a record as updated", () => {
      const resolution = { ...local, title: "resolved" };
      return articles
        .resolve(conflict, resolution)
        .then(res => res.data)
        .should.eventually.become({
          _status: "updated",
          id: local.id,
          title: resolution.title,
          last_modified: remote.last_modified,
        });
    });

    it("should mark a record as synced if resolved with remote", () => {
      const resolution = { ...local, title: remote.title };
      return articles
        .resolve(conflict, resolution)
        .then(res => res.data)
        .should.eventually.become({
          _status: "synced",
          id: local.id,
          title: resolution.title,
          last_modified: remote.last_modified,
        });
    });
  });

  /** @test {Collection#get} */
  describe("#get", () => {
    let articles, id;

    beforeEach(() => {
      articles = testCollection();
      return articles.create(article).then(result => (id = result.data.id));
    });

    it("should isolate records by bucket", () => {
      const otherbucket = new Collection("other", TEST_COLLECTION_NAME, api);
      return otherbucket
        .get(id)
        .then(res => res.data)
        .should.be.rejectedWith(Error, /not found/);
    });

    it("should retrieve a record from its id", () => {
      return articles
        .get(id)
        .then(res => res.data.title)
        .should.eventually.eql(article.title);
    });

    it("should retrieve a record from its id (custom IdSchema)", () => {
      articles = testCollection({ idSchema: createIntegerIdSchema() });

      // First, get rid of the old record with the ID from the other ID schema
      return articles
        .clear()
        .then(() => articles.create(article))
        .then(result => articles.get(result.data.id))
        .then(res => res.data.title)
        .should.eventually.eql(article.title);
    });

    it("should validate passed id", () => {
      return articles.get(42).should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should validate passed id (custom IdSchema)", () => {
      return articles
        .get("dead.beef")
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should have record status info attached", () => {
      return articles
        .get(id)
        .then(res => res.data._status)
        .should.eventually.eql("created");
    });

    it("should reject in case of record not found", () => {
      return articles
        .get(uuid4())
        .then(res => res.data)
        .should.be.rejectedWith(Error, /not found/);
    });

    it("should reject on virtually deleted record", () => {
      return articles
        .delete(id)
        .then(res => articles.get(id))
        .then(res => res.data)
        .should.be.rejectedWith(Error, /not found/);
    });

    it("should retrieve deleted record with includeDeleted", () => {
      return articles
        .delete(id)
        .then(res => articles.get(id, { includeDeleted: true }))
        .then(res => res.data)
        .should.eventually.become({
          _status: "deleted",
          id: id,
          title: "foo",
          url: "http://foo",
        });
    });
  });

  /** @test {Collection#getAny} */
  describe("#getAny", () => {
    let articles, id;

    beforeEach(() => {
      articles = testCollection();
      return articles.create(article).then(result => (id = result.data.id));
    });

    it("should retrieve a record from its id", () => {
      return articles
        .getAny(id)
        .then(res => res.data.title)
        .should.eventually.eql(article.title);
    });

    it("should resolve to undefined if not present", () => {
      return articles
        .getAny(uuid4())
        .then(res => res.data)
        .should.eventually.eql(undefined);
    });

    it("should resolve to virtually deleted record", () => {
      return articles
        .delete(id)
        .then(res => articles.getAny(id))
        .then(res => res.data)
        .should.eventually.become({
          _status: "deleted",
          id: id,
          title: "foo",
          url: "http://foo",
        });
    });
  });

  /** @test {Collection#delete} */
  describe("#delete", () => {
    let articles, id;

    beforeEach(() => {
      articles = testCollection();
      return articles.create(article).then(result => (id = result.data.id));
    });

    it("should validate passed id", () => {
      return articles.delete(42).should.be.rejectedWith(Error, /Invalid Id/);
    });

    it("should validate passed id (custom IdSchema)", () => {
      return articles
        .delete("dead beef")
        .should.be.rejectedWith(Error, /Invalid Id/);
    });

    describe("Virtual", () => {
      it("should virtually delete a record", () => {
        return articles
          .delete(id, { virtual: true })
          .then(res => articles.get(res.data.id, { includeDeleted: true }))
          .then(res => res.data._status)
          .should.eventually.eql("deleted");
      });

      it("should reject on non-existent record", () => {
        return articles
          .delete(uuid4(), { virtual: true })
          .then(res => res.data)
          .should.eventually.be.rejectedWith(Error, /not found/);
      });

      it("should reject on already deleted record", () => {
        return articles
          .delete(id, { virtual: true })
          .then(res => articles.delete(id, { virtual: true }))
          .should.eventually.be.rejectedWith(Error, /not found/);
      });

      it("should return deleted record", () => {
        return articles
          .delete(id, { virtual: true })
          .then(res => res.data)
          .should.eventually.have.property("title")
          .eql("foo");
      });
    });

    describe("Factual", () => {
      it("should factually delete a record", () => {
        return articles
          .delete(id, { virtual: false })
          .then(res => articles.get(res.data.id))
          .should.eventually.be.rejectedWith(Error, /not found/);
      });

      it("should resolve with deletion information", () => {
        return articles
          .delete(id, { virtual: false })
          .then(res => res.data)
          .should.eventually.have.property("id")
          .eql(id);
      });

      it("should reject on non-existent record", () => {
        return articles
          .delete(uuid4(), { virtual: false })
          .then(res => res.data)
          .should.eventually.be.rejectedWith(Error, /not found/);
      });

      it("should delete if already virtually deleted", () => {
        return articles
          .delete(id)
          .then(_ => articles.delete(id, { virtual: false }))
          .then(res => res.data)
          .should.eventually.have.property("id")
          .eql(id);
      });

      it("should return deleted record", () => {
        return articles
          .delete(id, { virtual: false })
          .then(res => res.data)
          .should.eventually.have.property("title")
          .eql("foo");
      });
    });
  });

  /** @test {Collection#deleteAll} */
  describe("#deleteAll", () => {
    let articles;

    beforeEach(() => {
      //Create 5 Records
      articles = testCollection();
      articles.create(article);
      articles.create(article);
      articles.create(article);
      articles.create(article);
      articles.create(article);
      return articles;
    });

    it("should be able to soft delete all articles", () => {
      return articles
        .deleteAll()
        .then(res => articles.list())
        .then(res => res.data)
        .should.eventually.have.length.of(0)
        .then(() => articles.list({}, { includeDeleted: true }))
        .then(res => res.data)
        .should.eventually.have.length.of(5);
    });

    it("should not delete anything when there are no records", () => {
      return articles
        .clear()
        .then(res => articles.deleteAll())
        .then(res => res.data)
        .should.eventually.have.length.of(0);
    });
  });

  /** @test {Collection#deleteAny} */
  describe("#deleteAny", () => {
    let articles, id;

    beforeEach(() => {
      articles = testCollection();
      return articles.create(article).then(result => (id = result.data.id));
    });

    it("should delete an existing record", () => {
      return articles
        .deleteAny(id)
        .then(res => articles.getAny(res.data.id))
        .then(res => res.data._status)
        .should.eventually.eql("deleted");
    });

    it("should resolve on non-existant record", () => {
      const id = uuid4();
      return articles
        .deleteAny(id)
        .then(res => res.data.id)
        .should.eventually.eql(id);
    });

    it("should indicate that it deleted", () => {
      return articles
        .deleteAny(id)
        .then(res => res.deleted)
        .should.eventually.eql(true);
    });

    it("should indicate that it didn't delete when record is gone", () => {
      const id = uuid4();
      return articles
        .deleteAny(id)
        .then(res => res.deleted)
        .should.eventually.eql(false);
    });

    it("should return deleted record", () => {
      return articles
        .deleteAny(id)
        .then(res => res.data)
        .should.eventually.have.property("title")
        .eql("foo");
    });
  });

  /** @test {Collection#list} */
  describe("#list", () => {
    let articles;

    describe("Basic", () => {
      beforeEach(() => {
        articles = testCollection();
        return Promise.all([
          articles.create(article),
          articles.create({ title: "bar", url: "http://bar" }),
        ]);
      });

      it("should retrieve the list of records", () => {
        return articles
          .list()
          .then(res => res.data)
          .should.eventually.have.length.of(2);
      });

      it("shouldn't list virtually deleted records", () => {
        return articles
          .create({ title: "yay" })
          .then(res => articles.delete(res.data.id))
          .then(_ => articles.list())
          .then(res => res.data)
          .should.eventually.have.length.of(2);
      });

      it("should support the includeDeleted option", () => {
        return articles
          .create({ title: "yay" })
          .then(res => articles.delete(res.data.id))
          .then(_ => articles.list({}, { includeDeleted: true }))
          .then(res => res.data)
          .should.eventually.have.length.of(3);
      });
    });

    describe("Ordering", () => {
      const fixtures = [
        { title: "art1", last_modified: 2, unread: false },
        { title: "art2", last_modified: 3, unread: true },
        { title: "art3", last_modified: 1, unread: false },
      ];

      beforeEach(() => {
        articles = testCollection();
        return Promise.all(fixtures.map(r => articles.create(r)));
      });

      it("should order records on last_modified DESC by default", () => {
        return articles
          .list()
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art2", "art1", "art3"]);
      });

      it("should order records on custom field ASC", () => {
        return articles
          .list({ order: "title" })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art1", "art2", "art3"]);
      });

      it("should order records on custom field DESC", () => {
        return articles
          .list({ order: "-title" })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art3", "art2", "art1"]);
      });

      it("should order records on boolean values ASC", () => {
        return articles
          .list({ order: "unread" })
          .then(res => res.data.map(r => r.unread))
          .should.eventually.become([false, false, true]);
      });

      it("should order records on boolean values DESC", () => {
        return articles
          .list({ order: "-unread" })
          .then(res => res.data.map(r => r.unread))
          .should.eventually.become([true, false, false]);
      });
    });

    describe("Filtering", () => {
      const fixtures = [
        { title: "art1", last_modified: 3, unread: true, complete: true },
        { title: "art2", last_modified: 2, unread: false, complete: true },
        {
          id: uuid4(),
          title: "art3",
          last_modified: 1,
          unread: true,
          complete: false,
        },
      ];

      beforeEach(() => {
        articles = testCollection();
        return Promise.all([
          articles.create(fixtures[0]),
          articles.create(fixtures[1]),
          articles.create(fixtures[2], { synced: true }),
        ]);
      });

      it("should filter records on indexed fields", () => {
        return articles
          .list({ filters: { _status: "created" } })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art1", "art2"]);
      });

      it("should filter records on existing field", () => {
        return articles
          .list({ filters: { unread: true } })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art1", "art3"]);
      });

      it("should filter records on missing field", () => {
        return articles
          .list({ filters: { missing: true } })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become([]);
      });

      it("should filter records on multiple fields using 'and'", () => {
        return articles
          .list({ filters: { unread: true, complete: true } })
          .then(res => res.data.map(r => r.title))
          .should.eventually.become(["art1"]);
      });
    });

    describe("SubObject Filtering", () => {
      const fixtures = [
        {
          title: "art1",
          last_modified: 3,
          unread: true,
          complete: true,
          author: {
            name: "John",
            city: "Miami",
            otherBook: {
              title: "book1",
            },
          },
        },
        {
          title: "art2",
          last_modified: 2,
          unread: false,
          complete: true,
          author: {
            name: "Daniel",
            city: "New York",
            otherBook: {
              title: "book2",
            },
          },
        },
        {
          title: "art3",
          last_modified: 1,
          unread: true,
          complete: true,
          author: {
            name: "John",
            city: "Chicago",
            otherBook: {
              title: "book3",
            },
          },
        },
      ];

      beforeEach(() => {
        articles = testCollection();
        return Promise.all(fixtures.map(r => articles.create(r)));
      });

      it("Filters nested objects", () => {
        return articles
          .list({
            filters: {
              "author.name": "John",
              "author.otherBook.title": "book3",
            },
          })
          .then(res => {
            return res.data.map(r => {
              return r.title;
            });
          })
          .should.eventually.become(["art3"]);
      });

      it("should return empty array if missing subObject field", () => {
        return articles
          .list({
            filters: {
              "author.name": "John",
              "author.unknownField": "blahblahblah",
            },
          })
          .then(res => res.data)
          .should.eventually.become([]);
      });
    });

    describe("Ordering & Filtering", () => {
      const fixtures = [
        { title: "art1", last_modified: 3, unread: true, complete: true },
        { title: "art2", last_modified: 2, unread: false, complete: true },
        { title: "art3", last_modified: 1, unread: true, complete: true },
      ];

      beforeEach(() => {
        articles = testCollection();
        return Promise.all(fixtures.map(r => articles.create(r)));
      });

      it("should order and filter records", () => {
        return articles
          .list({ order: "-title", filters: { unread: true, complete: true } })
          .then(res =>
            res.data.map(r => {
              return { title: r.title, unread: r.unread, complete: r.complete };
            })
          )
          .should.eventually.become([
            { title: "art3", unread: true, complete: true },
            { title: "art1", unread: true, complete: true },
          ]);
      });
    });
  });

  /**
   * @deprecated
   * @test {Collection#loadDump}
   */
  describe("Deprecated #loadDump", () => {
    let articles;

    it("should call importBulk", () => {
      articles = testCollection();
      sandbox.stub(articles, "importBulk").returns(Promise.resolve());
      articles
        .loadDump([
          { id: uuid4(), title: "foo", last_modified: 1452347896 },
          { id: uuid4(), title: "bar", last_modified: 1452347985 },
        ])
        .then(_ => sinon.assert.calledOnce(articles.importBulk));
    });
  });

  /** @test {Collection#importBulk} */
  describe("#importBulk", () => {
    let articles;

    beforeEach(() => (articles = testCollection()));

    it("should import records in the collection", () => {
      return articles
        .importBulk([
          { id: uuid4(), title: "foo", last_modified: 1452347896 },
          { id: uuid4(), title: "bar", last_modified: 1452347985 },
        ])
        .should.eventually.have.length(2);
    });

    it("should fail if records is not an array", () => {
      return articles
        .importBulk({ id: "abc", title: "foo" })
        .should.be.rejectedWith(Error, /^Records is not an array./);
    });

    it("should fail if id is invalid", () => {
      return articles
        .importBulk([{ id: "a.b.c", title: "foo" }])
        .should.be.rejectedWith(Error, /^Record has invalid ID./);
    });

    it("should fail if id is missing", () => {
      return articles
        .importBulk([{ title: "foo" }])
        .should.be.rejectedWith(Error, /^Record has invalid ID./);
    });

    it("should fail if last_modified is missing", () => {
      return articles
        .importBulk([{ id: uuid4(), title: "foo" }])
        .should.be.rejectedWith(Error, /^Record has no last_modified value./);
    });

    it("should mark imported records as synced.", () => {
      const testId = uuid4();
      return articles
        .importBulk([{ id: testId, title: "foo", last_modified: 1457896541 }])
        .then(() => {
          return articles.get(testId);
        })
        .then(res => res.data._status)
        .should.eventually.eql("synced");
    });

    it("should ignore already imported records.", () => {
      const record = { id: uuid4(), title: "foo", last_modified: 1457896541 };
      return articles
        .importBulk([record])
        .then(() => articles.importBulk([record]))
        .should.eventually.have.length(0);
    });

    it("should overwrite old records.", () => {
      const record = {
        id: "a-record",
        title: "foo",
        last_modified: 1457896541,
      };
      return articles
        .importBulk([record])
        .then(() => {
          const updated = { ...record, last_modified: 1457896543 };
          return articles.importBulk([updated]);
        })
        .should.eventually.have.length(1);
    });

    it("should not overwrite unsynced records.", () => {
      return articles
        .create({ title: "foo" })
        .then(result => {
          const record = {
            id: result.data.id,
            title: "foo",
            last_modified: 1457896541,
          };
          return articles.importBulk([record]);
        })
        .should.eventually.have.length(0);
    });

    it("should not overwrite records without last modified.", () => {
      return articles
        .create({ id: uuid4(), title: "foo" }, { synced: true })
        .then(result => {
          const record = {
            id: result.data.id,
            title: "foo",
            last_modified: 1457896541,
          };
          return articles.importBulk([record]);
        })
        .should.eventually.have.length(0);
    });
  });

  /** @test {Collection#gatherLocalChanges} */
  describe("#gatherLocalChanges", () => {
    let articles;

    beforeEach(() => {
      articles = testCollection();
      return Promise.all([
        articles.create({ title: "abcdef" }),
        articles.create({ title: "ghijkl" }),
      ]);
    });

    describe("transformers", () => {
      it("should asynchronously encode records", () => {
        articles = testCollection({
          remoteTransformers: [
            createEncodeTransformer("?", 10),
            createEncodeTransformer("!", 5),
          ],
        });

        return articles
          .gatherLocalChanges()
          .then(res => res.map(r => r.title).sort())
          .should.become(["abcdef?!", "ghijkl?!"]);
      });

      it("should encode even deleted records", () => {
        const transformer = {
          called: false,
          encode(record) {
            this.called = true;
            return { ...record, id: "remote-" + record.id };
          },
          decode() {},
        };
        articles = testCollection({
          idSchema: NULL_SCHEMA,
          remoteTransformers: [transformer],
        });
        const id = uuid4();
        return articles
          .create({ id: id, title: "some title" }, { synced: true })
          .then(() => {
            return articles.delete(id);
          })
          .then(() => articles.gatherLocalChanges())
          .then(changes => {
            expect(transformer.called).equal(true);
            expect(
              changes.filter(change => change._status == "deleted")[0]
            ).property("id", "remote-" + id);
          });
      });
    });
  });

  /** @test {Collection#pullChanges} */
  describe("#pullChanges", () => {
    let client, articles, listRecords, result;

    beforeEach(() => {
      articles = testCollection();
      result = new SyncResultObject();
    });

    describe("When no conflicts occured", () => {
      const id_1 = uuid4();
      const id_2 = uuid4();
      const id_3 = uuid4();
      const id_4 = uuid4();
      const id_5 = uuid4();
      const id_6 = uuid4();
      const id_7 = uuid4();
      const id_8 = uuid4();
      const id_9 = uuid4();

      const localData = [
        { id: id_1, title: "art1" },
        { id: id_2, title: "art2" },
        { id: id_4, title: "art4" },
        { id: id_5, title: "art5" },
        { id: id_7, title: "art7-a" },
        { id: id_9, title: "art9" }, // will be deleted in beforeEach().
      ];
      const serverChanges = [
        { id: id_2, title: "art2" }, // existing & untouched, skipped
        { id: id_3, title: "art3" }, // to be created
        { id: id_4, deleted: true }, // to be deleted
        { id: id_6, deleted: true }, // remotely deleted & missing locally, skipped
        { id: id_7, title: "art7-b" }, // remotely conflicting
        { id: id_8, title: "art8" }, // to be created
        { id: id_9, deleted: true }, // remotely deleted & deleted locally, skipped
      ];

      beforeEach(() => {
        listRecords = sandbox
          .stub(KintoClientCollection.prototype, "listRecords")
          .returns(
            Promise.resolve({
              data: serverChanges,
              next: () => {},
              last_modified: "42",
            })
          );
        client = new KintoClient("http://server.com/v1")
          .bucket("bucket")
          .collection("collection");
        return Promise.all(
          localData.map(fixture => {
            return articles.create(fixture, { synced: true });
          })
        ).then(_ => {
          return articles.delete(id_9);
        });
      });

      describe("incoming changes hook", () => {
        it("should be called", () => {
          let hookCalled = false;
          articles = testCollection({
            hooks: {
              "incoming-changes": [
                function(payload) {
                  hookCalled = true;
                  return payload;
                },
              ],
            },
          });

          return articles
            .pullChanges(client, result)
            .then(_ => expect(hookCalled).to.eql(true));
        });

        it("should reject the promise if the hook throws", () => {
          articles = testCollection({
            hooks: {
              "incoming-changes": [
                function(changes) {
                  throw new Error("Invalid collection data");
                },
              ],
            },
          });

          return articles
            .pullChanges(client, result)
            .should.eventually.be.rejectedWith(
              Error,
              /Invalid collection data/
            );
        });

        it("should use the results of the hooks", () => {
          articles = testCollection({
            hooks: {
              "incoming-changes": [
                function(incoming) {
                  const newChanges = incoming.changes.map(r => ({
                    ...r,
                    foo: "bar",
                  }));
                  return { ...incoming, changes: newChanges };
                },
              ],
            },
          });

          return articles.pullChanges(client, result).then(result => {
            expect(result.created.length).to.eql(2);
            result.created.forEach(r => {
              expect(r.foo).to.eql("bar");
            });
            expect(result.updated.length).to.eql(2);
            result.updated.forEach(r => {
              expect(r.new.foo).to.eql("bar");
            });
          });
        });

        it("should be able to chain hooks", () => {
          function hookFactory(fn) {
            return function(incoming) {
              const returnedChanges = incoming;
              const newChanges = returnedChanges.changes.map(fn);
              return { ...incoming, newChanges };
            };
          }
          articles = testCollection({
            hooks: {
              // N.B. This only works because it's mutating serverChanges
              "incoming-changes": [
                hookFactory(r => {
                  r.foo = "bar";
                  return r;
                }),
                hookFactory(r => {
                  r.bar = "baz";
                  return r;
                }),
              ],
            },
          });

          return articles.pullChanges(client, result).then(result => {
            expect(result.created.length).to.eql(2);
            result.created.forEach(r => {
              expect(r.foo).to.eql("bar");
              expect(r.bar).to.eql("baz");
            });
            expect(result.updated.length).to.eql(2);
            result.updated.forEach(r => {
              expect(r.new.foo).to.eql("bar");
              expect(r.new.bar).to.eql("baz");
            });
          });
        });

        it("should pass the collection as the second argument", () => {
          let passedCollection = null;
          articles = testCollection({
            hooks: {
              "incoming-changes": [
                function(payload, collection) {
                  passedCollection = collection;
                  return payload;
                },
              ],
            },
          });

          return articles.pullChanges(client, result).then(_ => {
            expect(passedCollection).to.eql(articles);
          });
        });

        it("should reject if the hook returns something strange", () => {
          articles = testCollection({
            hooks: {
              "incoming-changes": [() => 42],
            },
          });
          return articles
            .pullChanges(client, result)
            .should.eventually.be.rejectedWith(
              Error,
              /Invalid return value for hook: 42 has no 'then\(\)' or 'changes' properties/
            );
        });

        it("should resolve if the hook returns a promise", () => {
          articles = testCollection({
            hooks: {
              "incoming-changes": [
                payload => {
                  const newChanges = payload.changes.map(r => ({
                    ...r,
                    foo: "bar",
                  }));
                  return Promise.resolve({ ...payload, changes: newChanges });
                },
              ],
            },
          });
          return articles.pullChanges(client, result).then(result => {
            expect(result.created.length).to.eql(2);
            result.created.forEach(r => {
              expect(r.foo).to.eql("bar");
            });
          });
        });
      });

      describe("With transformers", () => {
        function createDecodeTransformer(char) {
          return {
            encode() {},
            decode(record) {
              return { ...record, title: record.title + char };
            },
          };
        }

        beforeEach(() => {
          return listRecords.returns(
            Promise.resolve({
              data: [{ id: uuid4(), title: "bar" }],
              next: () => {},
              last_modified: "42",
            })
          );
        });

        it("should decode incoming encoded records using a single transformer", () => {
          articles = testCollection({
            remoteTransformers: [createDecodeTransformer("#")],
          });

          return articles
            .pullChanges(client, result)
            .then(res => res.created[0].title)
            .should.become("bar#");
        });

        it("should decode incoming encoded records using multiple transformers", () => {
          articles = testCollection({
            remoteTransformers: [
              createDecodeTransformer("!"),
              createDecodeTransformer("?"),
            ],
          });

          return articles
            .pullChanges(client, result)
            .then(res => res.created[0].title)
            .should.become("bar?!"); // reversed because we decode in the opposite order
        });

        it("should decode incoming records even when deleted", () => {
          const transformer = {
            called: false,
            encode() {},
            decode(record) {
              this.called = true;
              return { ...record, id: "local-" + record.id };
            },
          };
          articles = testCollection({
            idSchema: NULL_SCHEMA,
            remoteTransformers: [transformer],
          });
          const id = uuid4();
          listRecords.returns(
            Promise.resolve({
              data: [{ id: id, deleted: true }],
              next: () => {},
              last_modified: "42",
            })
          );
          return articles
            .create(
              { id: "local-" + id, title: "some title" },
              { synced: true }
            )
            .then(() => articles.pullChanges(client, result))
            .then(res => {
              expect(transformer.called).equal(true);
              return res.deleted[0];
            })
            .should.eventually.property("id", "local-" + id);
        });
      });

      it("should not fetch remote records if result status isn't ok", () => {
        const withConflicts = new SyncResultObject();
        withConflicts.add("conflicts", [1]);
        return articles
          .pullChanges(client, withConflicts)
          .then(_ => sinon.assert.notCalled(listRecords));
      });

      it("should fetch remote changes from the server", () => {
        return articles.pullChanges(client, result).then(_ => {
          sinon.assert.calledOnce(listRecords);
          sinon.assert.calledWithExactly(listRecords, {
            since: undefined,
            filters: undefined,
            retry: undefined,
            pages: Infinity,
            headers: {},
          });
        });
      });

      it("should use timestamp to fetch remote changes from the server", () => {
        return articles
          .pullChanges(client, result, { lastModified: 42 })
          .then(_ => {
            sinon.assert.calledOnce(listRecords);
            sinon.assert.calledWithExactly(listRecords, {
              since: "42",
              filters: undefined,
              retry: undefined,
              pages: Infinity,
              headers: {},
            });
          });
      });

      it("should pass provided filters when polling changes from server", () => {
        const exclude = [{ id: 1 }, { id: 2 }, { id: 3 }];
        return articles
          .pullChanges(client, result, { lastModified: 42, exclude })
          .then(_ => {
            sinon.assert.calledOnce(listRecords);
            sinon.assert.calledWithExactly(listRecords, {
              since: "42",
              filters: { exclude_id: "1,2,3" },
              retry: undefined,
              pages: Infinity,
              headers: {},
            });
          });
      });

      it("should respect expectedTimestamp when requesting changes", () => {
        return articles
          .pullChanges(client, result, { expectedTimestamp: '"123"' })
          .then(_ => {
            sinon.assert.calledOnce(listRecords);
            sinon.assert.calledWithExactly(listRecords, {
              since: undefined,
              filters: { _expected: '"123"' },
              retry: undefined,
              pages: Infinity,
              headers: {},
            });
          });
      });

      it("should resolve with imported creations", () => {
        return articles
          .pullChanges(client, result)
          .then(res => res.created)
          .should.eventually.become([
            { id: id_3, title: "art3", _status: "synced" },
            { id: id_8, title: "art8", _status: "synced" },
          ]);
      });

      it("should resolve with imported updates", () => {
        return articles
          .pullChanges(client, result)
          .then(res => res.updated)
          .should.eventually.become([
            {
              old: { id: id_7, title: "art7-a", _status: "synced" },
              new: { id: id_7, title: "art7-b", _status: "synced" },
            },
          ]);
      });

      it("should resolve with imported deletions", () => {
        return articles
          .pullChanges(client, result)
          .then(res => res.deleted)
          .should.eventually.become([
            { id: id_4, title: "art4", _status: "synced" },
          ]);
      });

      it("should resolve with no conflicts detected", () => {
        return articles
          .pullChanges(client, result)
          .then(res => res.conflicts)
          .should.eventually.become([]);
      });

      it("should actually import changes into the collection", () => {
        return articles
          .pullChanges(client, result)
          .then(_ => articles.list({ order: "title" }))
          .then(res => res.data)
          .should.eventually.become([
            { id: id_1, title: "art1", _status: "synced" },
            { id: id_2, title: "art2", _status: "synced" },
            { id: id_3, title: "art3", _status: "synced" },
            { id: id_5, title: "art5", _status: "synced" },
            { id: id_7, title: "art7-b", _status: "synced" },
            { id: id_8, title: "art8", _status: "synced" },
          ]);
      });

      it("should skip deleted data missing locally", () => {
        return articles.pullChanges(client, result).then(res => {
          expect(res.skipped).eql([
            { id: id_6, deleted: true },
            { id: id_9, title: "art9", _status: "deleted" },
          ]);
        });
      });

      it("should not list identical records as skipped", () => {
        return articles
          .pullChanges(client, result)
          .then(res => res.skipped)
          .should.eventually.not.contain({
            id: id_2,
            title: "art2",
            _status: "synced",
          });
      });

      describe("Error handling", () => {
        it("should expose any import transaction error", () => {
          const error = new Error("bad");
          sandbox.stub(articles.db, "execute").returns(Promise.reject(error));

          return articles
            .pullChanges(client, result)
            .then(res => res.errors)
            .should.become([
              {
                type: "incoming",
                message: error.message,
                stack: error.stack,
              },
            ]);
        });
      });
    });

    describe("When a conflict occured", () => {
      let createdId, local;

      beforeEach(() => {
        return articles.create({ title: "art2" }).then(res => {
          local = res.data;
          createdId = local.id;
        });
      });

      it("should resolve listing conflicting changes with MANUAL strategy", () => {
        sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
          Promise.resolve({
            data: [
              { id: createdId, title: "art2mod", last_modified: 42 }, // will conflict with unsynced local record
            ],
            next: () => {},
            last_modified: "42",
          })
        );

        return articles.pullChanges(client, result).should.eventually.become({
          ok: false,
          lastModified: 42,
          errors: [],
          created: [],
          updated: [],
          deleted: [],
          skipped: [],
          published: [],
          conflicts: [
            {
              type: "incoming",
              local: {
                _status: "created",
                id: createdId,
                title: "art2",
              },
              remote: {
                id: createdId,
                title: "art2mod",
                last_modified: 42,
              },
            },
          ],
          resolved: [],
        });
      });

      it("should ignore resolved conflicts during sync", () => {
        const remote = { ...local, title: "blah", last_modified: 42 };
        const conflict = { type: "incoming", local: local, remote: remote };
        const resolution = { ...local, title: "resolved" };
        sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
          Promise.resolve({
            data: [remote],
            next: () => {},
            last_modified: "42",
          })
        );
        const syncResult = new SyncResultObject();
        return articles
          .resolve(conflict, resolution)
          .then(() => articles.pullChanges(client, syncResult))
          .should.eventually.become({
            ok: true,
            lastModified: 42,
            errors: [],
            created: [],
            published: [],
            resolved: [],
            skipped: [],
            deleted: [],
            conflicts: [],
            updated: [],
          });
      });
    });

    describe("When a resolvable conflict occured", () => {
      let createdId;

      beforeEach(() => {
        return articles.create({ title: "art2" }).then(res => {
          createdId = res.data.id;
          sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
            Promise.resolve({
              data: [
                { id: createdId, title: "art2" }, // resolvable conflict
              ],
              next: () => {},
              last_modified: "42",
            })
          );
        });
      });

      it("should resolve with solved changes", () => {
        return articles.pullChanges(client, result).should.eventually.become({
          ok: true,
          lastModified: 42,
          errors: [],
          created: [],
          published: [],
          updated: [
            {
              old: { id: createdId, title: "art2", _status: "created" },
              new: { id: createdId, title: "art2", _status: "synced" },
            },
          ],
          skipped: [],
          deleted: [],
          conflicts: [],
          resolved: [],
        });
      });
    });
  });

  /** @test {Collection#importChanges} */
  describe("#importChanges", () => {
    let articles, result;

    beforeEach(() => {
      articles = testCollection();
      result = new SyncResultObject();
    });

    it("should return errors when encountered", () => {
      const error = new Error("unknown error");
      sandbox.stub(articles.db, "execute").returns(Promise.reject(error));

      return articles
        .importChanges(result, [{ title: "bar" }])
        .then(res => res.errors)
        .should.eventually.become([
          {
            type: "incoming",
            message: error.message,
            stack: error.stack,
          },
        ]);
    });

    it("should return typed errors", () => {
      const error = new Error("unknown error");
      sandbox.stub(articles, "get").returns(Promise.reject(error));

      return articles
        .importChanges(result, { changes: [{ title: "bar" }] })
        .then(res => res.errors[0])
        .should.eventually.have.property("type")
        .eql("incoming");
    });

    it("should only retrieve the changed record", () => {
      const id1 = uuid4();
      const id2 = uuid4();
      const execute = sandbox
        .stub(articles.db, "execute")
        .returns(Promise.resolve([]));

      return articles
        .importChanges(result, [
          { id: id1, title: "foo" },
          { id: id2, title: "bar" },
        ])
        .then(() => {
          const preload = execute.lastCall.args[1].preload;
          expect(preload).eql([id1, id2]);
        });
    });

    it("should merge remote with local fields", () => {
      const id1 = uuid4();
      return articles
        .create({ id: id1, title: "bar", size: 12 }, { synced: true })
        .then(() => articles.importChanges(result, [{ id: id1, title: "foo" }]))
        .then(res => {
          expect(res.updated[0].new.title).eql("foo");
          expect(res.updated[0].new.size).eql(12);
        });
    });

    it("should ignore local fields when detecting conflicts", () => {
      const id1 = uuid4();
      articles = testCollection({ localFields: ["size"] });
      // Create record with status not synced.
      return articles
        .create(
          { id: id1, title: "bar", size: 12, last_modified: 42 },
          { useRecordId: true }
        )
        .then(() =>
          articles.importChanges(result, [
            { id: id1, title: "bar", last_modified: 43 },
          ])
        )
        .then(res => {
          // No conflict, local.title == remote.title.
          expect(res.ok).eql(true);
          expect(res.updated[0].new.title).eql("bar");
          // Local field is preserved
          expect(res.updated[0].new.size).eql(12);
          // Timestamp was taken from remote
          expect(res.updated[0].new.last_modified).eql(43);
        });
    });
  });

  /** @test {Collection#pushChanges} */
  describe("#pushChanges", () => {
    let client, articles, result;
    const records = [{ id: uuid4(), title: "foo", _status: "created" }];

    beforeEach(() => {
      client = new KintoClient("http://server.com/v1")
        .bucket("bucket")
        .collection("collection");
      articles = testCollection();
      result = new SyncResultObject();
    });

    it("should publish local changes to the server", () => {
      const batchRequests = sandbox
        .stub(KintoClient.prototype, "_batchRequests")
        .returns(Promise.resolve([{}]));

      return articles.pushChanges(client, records, result).then(_ => {
        const requests = batchRequests.firstCall.args[0];
        const options = batchRequests.firstCall.args[1];
        expect(requests).to.have.length.of(1);
        expect(requests[0].body.data.title).eql("foo");
        expect(options.safe).eql(true);
      });
    });

    it("should not publish local fields to the server", () => {
      const batchRequests = sandbox
        .stub(KintoClient.prototype, "_batchRequests")
        .returns(Promise.resolve([{}]));

      articles = testCollection({ localFields: ["size"] });
      const toSync = [{ ...records[0], title: "ah", size: 3.14 }];
      return articles.pushChanges(client, toSync, result).then(_ => {
        const requests = batchRequests.firstCall.args[0];
        expect(requests[0].body.data.title).eql("ah");
        expect(requests[0].body.data.size).to.not.exist;
      });
    });

    it("should update published records local status", () => {
      sandbox.stub(KintoClientCollection.prototype, "batch").returns(
        Promise.resolve({
          published: [{ data: records[0] }],
          errors: [],
          conflicts: [],
          skipped: [],
        })
      );
      return articles
        .pushChanges(client, records, result)
        .then(res => res.published)
        .should.eventually.become([
          {
            _status: "synced",
            id: records[0].id,
            title: "foo",
          },
        ]);
    });

    it("should not publish records created and deleted locally and never synced", () => {
      const batchRequests = sandbox
        .stub(KintoClient.prototype, "_batchRequests")
        .returns(Promise.resolve([]));

      const toDelete = [{ id: records[0].id, _status: "deleted" }]; // no timestamp.
      return articles.pushChanges(client, toDelete, result).then(_ => {
        const requests = batchRequests.firstCall.args[0];
        expect(requests).eql([]);
      });
    });

    it("should delete unsynced virtually deleted local records", () => {
      const locallyDeletedId = records[0].id;
      sandbox.stub(KintoClientCollection.prototype, "batch").returns(
        Promise.resolve({
          published: [{ data: { id: locallyDeletedId, deleted: true } }],
          errors: [],
          conflicts: [],
          skipped: [],
        })
      );
      return articles
        .delete(locallyDeletedId)
        .then(_ => articles.pushChanges(client, records, result))
        .then(_ => articles.get(locallyDeletedId, { includeDeleted: true }))
        .should.be.eventually.rejectedWith(Error, /not found/);
    });

    it("should delete locally the records deleted remotely", () => {
      sandbox.stub(KintoClientCollection.prototype, "batch").returns(
        Promise.resolve({
          published: [{ data: { id: records[0].id, deleted: true } }],
          errors: [],
          conflicts: [],
          skipped: [],
        })
      );
      return articles
        .pushChanges(client, [], result)
        .then(res => res.published)
        .should.eventually.become([{ id: records[0].id, deleted: true }]);
    });

    it("should delete locally the records already deleted remotely", () => {
      const id = records[0].id;
      sandbox.stub(KintoClientCollection.prototype, "batch").returns(
        Promise.resolve({
          published: [],
          errors: [],
          conflicts: [],
          skipped: [
            {
              id,
              error: { errno: 110, code: 404, error: "Not found" },
            },
          ],
        })
      );
      return articles
        .create({ id, title: "bar" }, { useRecordId: true, synced: true })
        .then(() => articles.pushChanges(client, records, result))
        .then(_ => articles.get(id, { includeDeleted: true }))
        .should.be.eventually.rejectedWith(Error, /not found/);
    });

    describe("Batch requests made", () => {
      let batch, batchSpy, deleteRecord, createRecord, updateRecord;
      beforeEach(() => {
        batch = {
          deleteRecord: function() {},
          createRecord: function() {},
          updateRecord: function() {},
        };
        batchSpy = sandbox.mock(batch);
        deleteRecord = batchSpy.expects("deleteRecord");
        createRecord = batchSpy.expects("createRecord");
        updateRecord = batchSpy.expects("updateRecord");
        sandbox.stub(KintoClientCollection.prototype, "batch").callsFake(f => {
          f(batch);
          return Promise.resolve({
            published: [],
            errors: [],
            conflicts: [],
            skipped: [],
          });
        });
      });

      it("should call delete() for deleted records", () => {
        const myDeletedRecord = {
          id: "deleted-record-id",
          _status: "deleted",
          last_modified: 1234,
        };
        deleteRecord.once();
        createRecord.never();
        updateRecord.never();
        return articles
          .pushChanges(client, [myDeletedRecord], result)
          .then(() => batchSpy.verify())
          .then(() => deleteRecord.firstCall.args)
          .should.eventually.eql([myDeletedRecord]);
      });

      it("should call create() for created records", () => {
        const myCreatedRecord = { id: "created-record-id", _status: "created" };
        deleteRecord.never();
        createRecord.once();
        updateRecord.never();
        return articles
          .pushChanges(client, [myCreatedRecord], result)
          .then(() => batchSpy.verify())
          .then(() => createRecord.firstCall.args)
          .should.eventually.eql([{ id: "created-record-id" }]);
      });

      it("should call update() for updated records", () => {
        const myUpdatedRecord = {
          id: "updated-record-id",
          _status: "updated",
          last_modified: 1234,
        };
        deleteRecord.never();
        createRecord.never();
        updateRecord.once();
        return articles
          .pushChanges(client, [myUpdatedRecord], result)
          .then(() => batchSpy.verify())
          .then(() => updateRecord.firstCall.args)
          .should.eventually.eql([
            { id: "updated-record-id", last_modified: 1234 },
          ]);
      });
    });

    describe("Error handling", () => {
      const error = {
        path: "/buckets/default/collections/test/records/123",
        sent: { data: { id: "123" } },
        error: { errno: 999, message: "Internal error" },
      };

      beforeEach(() => {
        sandbox.stub(KintoClientCollection.prototype, "batch").returns(
          Promise.resolve({
            errors: [error],
            published: [],
            conflicts: [],
            skipped: [],
          })
        );
      });

      it("should report encountered publication errors", () => {
        return articles
          .pushChanges(client, records, result)
          .then(res => res.errors)
          .should.eventually.become([{ ...error, type: "outgoing" }]);
      });

      it("should report typed publication errors", () => {
        return articles
          .pushChanges(client, records, result)
          .then(res => res.errors[0])
          .should.eventually.have.property("type")
          .eql("outgoing");
      });
    });
  });

  /** @test {Collection#resetSyncStatus} */
  describe("#resetSyncStatus", () => {
    const fixtures = [
      { id: uuid4(), last_modified: 42, title: "art1" },
      { id: uuid4(), last_modified: 42, title: "art2" },
      { id: uuid4(), last_modified: 42, title: "art3" },
    ];
    let articles;

    beforeEach(() => {
      articles = testCollection();
      return Promise.all(
        fixtures.map(fixture => {
          return articles.create(fixture, { synced: true });
        })
      ).then(_ => {
        return articles.delete(fixtures[1].id);
      });
    });

    it("should reset the synced status of all local records", () => {
      return articles
        .resetSyncStatus()
        .then(_ => articles.list({ filters: { _status: "synced" } }))
        .should.eventually.have.property("data")
        .to.have.length(0);
    });

    it("should garbage collect the locally deleted records", () => {
      return articles
        .resetSyncStatus()
        .then(_ => {
          return articles.list(
            { filters: { _status: "deleted" } },
            { includeDeleted: true }
          );
        })
        .should.eventually.have.property("data")
        .to.have.length(0);
    });

    it("should clear last modified value of all records", () => {
      return articles
        .resetSyncStatus()
        .then(_ => articles.list())
        .then(res => res.data.some(r => r.last_modified))
        .should.eventually.eql(false);
    });

    it("should clear any previously saved lastModified value", () => {
      return articles
        .resetSyncStatus()
        .then(_ => articles.db.getLastModified())
        .should.become(null);
    });

    it("should resolve with the number of local records processed ", () => {
      return articles.resetSyncStatus().should.become(3);
    });
  });

  /** @test {Collection#sync} */
  describe("#sync", () => {
    const fixtures = [{ title: "art1" }, { title: "art2" }, { title: "art3" }];
    let articles, ids;

    beforeEach(() => {
      articles = testCollection();
      sandbox.stub(api, "batch").get(() => () => ({
        errors: [],
        published: [],
        conflicts: [],
        skipped: [],
      }));
      return Promise.all(
        fixtures.map(fixture => articles.create(fixture))
      ).then(res => (ids = res.map(r => r.data.id)));
    });

    it("should validate the remote option", () => {
      return articles
        .sync({ remote: "http://fake.invalid" })
        .should.be.rejectedWith(Error, /contain the version/);
    });

    it("should use a custom remote option", () => {
      sandbox.stub(articles, "importChanges");
      sandbox.stub(articles, "pushChanges").returns(new SyncResultObject());
      const fetch = sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, { data: [] }, {}));

      return articles.sync({ remote: "http://test/v1" }).then(res => {
        sinon.assert.calledWith(fetch, sinon.match(/http:\/\/test\/v1/));
      });
    });

    it("should revert the custom remote option on success", () => {
      sandbox.stub(articles, "importChanges");
      sandbox.stub(articles, "pushChanges").returns(new SyncResultObject());
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, { data: [] }, {}));

      return articles.sync({ remote: "http://test/v1" }).then(_ => {
        expect(api.remote).eql(FAKE_SERVER_URL);
      });
    });

    it("should revert the custom remote option on failure", () => {
      sandbox.stub(articles, "importChanges");
      sandbox.stub(articles, "pushChanges").returns(Promise.reject("boom"));
      sandbox
        .stub(global, "fetch")
        .returns(fakeServerResponse(200, { data: [] }, {}));

      return articles.sync({ remote: "http://test/v1" }).catch(_ => {
        expect(api.remote).eql(FAKE_SERVER_URL);
      });
    });

    it("should load fixtures", () => {
      return articles
        .list()
        .then(res => res.data)
        .should.eventually.have.length.of(3);
    });

    it("should pullMetadata with options", () => {
      const pullMetadata = sandbox.stub(articles, "pullMetadata");
      sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
        Promise.resolve({
          last_modified: "42",
          next: () => {},
          data: [],
        })
      );
      const options = {
        headers: {
          Authorization: "Basic 123",
        },
      };
      return articles.sync(options).then(res => {
        expect(pullMetadata.callCount).equal(1);
        // First argument is the client, which we don't care too much about
        // Second argument is the options
        expect(pullMetadata.getCall(0).args[1]).include(options);
      });
    });

    it("should fetch latest changes from the server", () => {
      sandbox.stub(articles, "pullMetadata");
      const listRecords = sandbox
        .stub(KintoClientCollection.prototype, "listRecords")
        .returns(
          Promise.resolve({
            last_modified: "42",
            next: () => {},
            data: [],
          })
        );
      return articles.sync().then(res => {
        // Never synced so we fetch all the records from the server
        sinon.assert.calledWithMatch(listRecords, { since: undefined });
      });
    });

    it("should store latest lastModified value when no conflicts", () => {
      sandbox.stub(articles, "pullMetadata");
      sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
        Promise.resolve({
          last_modified: "42",
          next: () => {},
          data: [],
        })
      );
      return articles.sync().then(res => {
        expect(articles.lastModified).eql(42);
      });
    });

    it("shouldn't store latest lastModified on conflicts", () => {
      sandbox.stub(articles, "pullMetadata");
      sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
        Promise.resolve({
          last_modified: "43",
          next: () => {},
          data: [
            {
              id: ids[0],
              title: "art1mod",
              last_modified: 43,
            },
          ],
        })
      );
      return articles.sync().then(res => {
        expect(articles.lastModified).eql(null);
      });
    });

    it("shouldn't store latest lastModified on errors", () => {
      sandbox.stub(articles, "pullMetadata");
      sandbox.stub(KintoClientCollection.prototype, "listRecords").returns(
        Promise.resolve({
          last_modified: "43",
          next: () => {},
          data: [
            {
              id: ids[0],
              title: "art1mod",
            },
          ],
        })
      );
      sandbox
        .stub(articles.db, "execute")
        .returns(Promise.reject(new Error("error")));
      return articles.sync().then(res => {
        expect(articles.lastModified).eql(null);
      });
    });

    it("should not execute a last pull on push failure", () => {
      sandbox.stub(articles, "pullMetadata");
      const pullChanges = sandbox.stub(articles, "pullChanges");
      sandbox
        .stub(articles, "pushChanges")
        .callsFake((client, changes, result) => {
          result.add("conflicts", [1]);
        });
      return articles.sync().then(() => sinon.assert.calledOnce(pullChanges));
    });

    it("should not execute a last pull if nothing to push", () => {
      sandbox.stub(articles, "pullMetadata");
      sandbox.stub(articles, "gatherLocalChanges").returns(Promise.resolve([]));
      const pullChanges = sandbox
        .stub(articles, "pullChanges")
        .returns(Promise.resolve(new SyncResultObject()));
      return articles.sync().then(res => {
        sinon.assert.calledOnce(pullChanges);
      });
    });

    it("should not redownload pushed changes", () => {
      const record1 = { id: uuid4(), title: "blog" };
      const record2 = { id: uuid4(), title: "post" };
      sandbox.stub(articles, "pullMetadata");
      sandbox.stub(articles, "pullChanges");
      sandbox
        .stub(articles, "pushChanges")
        .callsFake((client, changes, result) => {
          result.add("published", record1);
          result.add("published", record2);
        });
      return articles.sync().then(res => {
        expect(res.published).to.have.length(2);
        expect(articles.pullChanges.lastCall.args[2].exclude).eql([
          record1,
          record2,
        ]);
      });
    });

    it("should store collection metadata", () => {
      sandbox.stub(articles, "pullChanges");
      const metadata = { id: "articles", last_modified: 42 };
      sandbox
        .stub(KintoClientCollection.prototype, "getData")
        .returns(Promise.resolve(metadata));
      return articles.sync().then(async () => {
        const stored = await articles.metadata();
        expect(stored, metadata);
      });
    });

    describe("Options", () => {
      let pullChanges;

      beforeEach(() => {
        sandbox.stub(articles, "pullMetadata");
        pullChanges = sandbox
          .stub(articles, "pullChanges")
          .returns(Promise.resolve(new SyncResultObject()));
      });

      it("should transfer the headers option", () => {
        return articles.sync({ headers: { Foo: "Bar" } }).then(() => {
          expect(pullChanges.firstCall.args[2])
            .to.have.property("headers")
            .eql({ Foo: "Bar" });
        });
      });

      it("should transfer the strategy option", () => {
        return articles
          .sync({ strategy: Collection.strategy.SERVER_WINS })
          .then(() => {
            expect(pullChanges.firstCall.args[2])
              .to.have.property("strategy")
              .eql(Collection.strategy.SERVER_WINS);
          });
      });

      it("should transfer the retry option", () => {
        return articles.sync({ retry: 3 }).then(() => {
          expect(pullChanges.firstCall.args[2])
            .to.have.property("retry")
            .eql(3);
        });
      });

      it("should transfer the expectedTimestamp option", () => {
        return articles.sync({ expectedTimestamp: '"123"' }).then(() => {
          expect(pullChanges.firstCall.args[2])
            .to.have.property("expectedTimestamp")
            .eql('"123"');
        });
      });
    });

    describe("Server backoff", () => {
      it("should reject on server backoff by default", () => {
        articles.api = { backoff: 30000 };
        return articles
          .sync()
          .should.be.rejectedWith(Error, /back off; retry in 30s/);
      });

      it("should perform sync on server backoff when ignoreBackoff is true", () => {
        sandbox
          .stub(articles.db, "getLastModified")
          .returns(Promise.resolve({}));
        sandbox.stub(articles, "pullMetadata");
        const pullChanges = sandbox.stub(articles, "pullChanges");
        sandbox.stub(articles, "pushChanges");
        articles.api.events.emit("backoff", new Date().getTime() + 30000);

        return articles
          .sync({ ignoreBackoff: true })
          .then(_ => sinon.assert.calledOnce(pullChanges));
      });
    });

    describe("Retry", () => {
      let fetch;

      beforeEach(() => {
        // Disable stubbing of kinto-http of upper tests.
        sandbox.restore();
        // Stub low-level fetch instead.
        fetch = sandbox.stub(global, "fetch");
        // Pull metadata
        fetch.onCall(0).returns(fakeServerResponse(200, { data: {} }, {}));
        // Pull records
        fetch.onCall(1).returns(fakeServerResponse(200, { data: [] }, {}));
        // Push
        fetch.onCall(2).returns(fakeServerResponse(200, { settings: {} }, {}));
        fetch
          .onCall(3)
          .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
        fetch.onCall(4).returns(
          fakeServerResponse(
            200,
            {
              responses: [
                { status: 201, body: { data: { id: 1, last_modified: 41 } } },
                { status: 201, body: { data: { id: 2, last_modified: 42 } } },
                { status: 201, body: { data: { id: 3, last_modified: 43 } } },
              ],
            },
            { ETag: '"123"' }
          )
        );
        // Last pull
        fetch.onCall(5).returns(fakeServerResponse(200, { data: [] }, {}));
        // Avoid actually waiting real time between retries in test suites.
        sandbox.stub(global, "setTimeout").callsFake(fn => setImmediate(fn));
      });

      it("should retry if specified", () => {
        return articles.sync({ retry: 3 }).then(result => {
          //console.log(fetch.getCalls());
          expect(result.ok).eql(true);
        });
      });
    });

    describe("Events", () => {
      let onsuccess;
      let onerror;

      beforeEach(() => {
        onsuccess = sinon.spy();
        onerror = sinon.spy();
        articles.events.on("sync:success", onsuccess);
        articles.events.on("sync:error", onerror);

        sandbox
          .stub(articles.db, "getLastModified")
          .returns(Promise.resolve({}));
        sandbox.stub(articles, "pullMetadata");
        sandbox.stub(articles, "pullChanges");
        sandbox.stub(articles, "pushChanges");
      });

      it("should send a success event", () => {
        return articles.sync().then(() => {
          expect(onsuccess.called).eql(true);
          expect(onerror.called).eql(false);
        });
      });

      it("should send an error event", () => {
        articles.pushChanges.throws(new Error("boom"));
        return articles.sync().catch(() => {
          expect(onsuccess.called).eql(false);
          expect(onerror.called).eql(true);
        });
      });

      it("should send an error event", () => {
        articles.pushChanges.throws(new Error("boom"));
        return articles.sync().catch(() => {
          expect(onsuccess.called).eql(false);
          expect(onerror.called).eql(true);
        });
      });

      it("should provide success details about sync", () => {
        return articles.sync().then(() => {
          const data = onsuccess.firstCall.args[0];
          expect(data).to.have.property("result");
          expect(data).to.have.property("remote");
          expect(data).to.have.property("bucket");
          expect(data).to.have.property("collection");
          expect(data).to.have.property("headers");
        });
      });

      it("should provide error details about sync", () => {
        articles.pushChanges.throws(new Error("boom"));
        return articles.sync().catch(() => {
          const data = onerror.firstCall.args[0];
          expect(data).to.have.property("error");
          expect(data).to.have.property("remote");
          expect(data).to.have.property("bucket");
          expect(data).to.have.property("collection");
          expect(data).to.have.property("headers");
        });
      });
    });
  });

  /** @test {Collection#execute} */
  describe("#execute", () => {
    let articles;
    beforeEach(() => {
      articles = testCollection();
    });

    it("should support get", () => {
      return articles
        .create(article)
        .then(result => {
          const id = result.data.id;
          return articles.execute(txn => txn.get(id), { preloadIds: [id] });
        })
        .then(result => expect(result.data.title).eql("foo"));
    });

    it("should support getAny", () => {
      return articles
        .create(article)
        .then(result => {
          const id = result.data.id;
          return articles.execute(txn => txn.getAny(id), { preloadIds: [id] });
        })
        .then(result => expect(result.data.title).eql("foo"));
    });

    it("should support delete", () => {
      let id;
      return articles
        .create(article)
        .then(result => {
          id = result.data.id;
          return articles.execute(txn => txn.delete(id), { preloadIds: [id] });
        })
        .then(result => articles.getAny(id))
        .then(result => expect(result.data._status).eql("deleted"));
    });

    it("should support deleteAll", () => {
      let id;
      return articles
        .create(article)
        .then(result => {
          id = result.data.id;
          return articles.execute(txn => txn.deleteAll([id]), {
            preloadIds: [id],
          });
        })
        .then(result => articles.getAny(id))
        .then(result => expect(result.data._status).eql("deleted"));
    });

    it("should support deleteAny", () => {
      let id;
      return articles
        .create(article)
        .then(result => {
          id = result.data.id;
          return articles.execute(txn => txn.deleteAny(id), {
            preloadIds: [id],
          });
        })
        .then(result => articles.getAny(id))
        .then(result => expect(result.data._status).eql("deleted"));
    });

    it("should support create", () => {
      const id = uuid4();
      return articles
        .execute(txn => txn.create({ id, ...article }), { preloadIds: [id] })
        .then(result => expect(result.data.title).eql("foo"));
    });

    it("should support update", () => {
      let id;
      return articles
        .create(article)
        .then(result => {
          id = result.data.id;
          return articles.execute(
            txn => txn.update({ id, title: "new title" }),
            { preloadIds: [id] }
          );
        })
        .then(result => articles.get(id))
        .then(result => expect(result.data.title).eql("new title"));
    });

    it("should support upsert", () => {
      const id = uuid4();
      return articles
        .upsert({ id, ...article })
        .then(result => result.data.id)
        .then(result => articles.get(id))
        .then(result => expect(result.data.title).eql("foo"));
    });

    it("should roll back operations if there's a failure", () => {
      let id;
      return articles
        .create(article)
        .then(result => {
          id = result.data.id;
          return articles.execute(
            txn => {
              txn.deleteAny(id);
              txn.delete(uuid4()); // this should fail
            },
            { preloadIds: [id] }
          );
        })
        .catch(() => null)
        .then(result => articles.getAny(id))
        .then(result => expect(result.data._status).eql("created"));
    });

    it("should perform all operations if there's no failure", () => {
      let id1, id2;
      return articles
        .create(article)
        .then(result => {
          id1 = result.data.id;
          return articles.create({ title: "foo2", url: "http://foo2" });
        })
        .then(result => {
          id2 = result.data.id;
          return articles.execute(
            txn => {
              txn.deleteAny(id1);
              txn.deleteAny(id2);
            },
            { preloadIds: [id1, id2] }
          );
        })
        .then(result => articles.getAny(id1))
        .then(result => expect(result.data._status).eql("deleted"))
        .then(result => articles.getAny(id2))
        .then(result => expect(result.data._status).eql("deleted"));
    });

    it("should resolve to the return value of the transaction", () => {
      return articles
        .create(article)
        .then(() => {
          return articles.execute(txn => {
            return "hello";
          });
        })
        .then(result => expect(result).eql("hello"));
    });

    it("has operations that are synchronous", () => {
      let createdArticle;
      return articles
        .create(article)
        .then(result => {
          return articles.execute(
            txn => {
              createdArticle = txn.get(result.data.id).data;
            },
            { preloadIds: [result.data.id] }
          );
        })
        .then(result => expect(createdArticle.title).eql("foo"));
    });
  });

  /** @test {Collection#pullMetadata} */
  describe("#pullMetadata", () => {
    let articles;

    beforeEach(() => (articles = testCollection()));

    it("passes headers to underlying client", () => {
      const headers = {
        Authorization: "Basic 123",
      };

      let client = {
        getData: sandbox.stub(),
      };
      return articles.pullMetadata(client, { headers }).then(_ => {
        sinon.assert.calledWithExactly(client.getData, {
          headers,
        });
      });
    });
  });

  describe("Events", () => {
    let articles, article;

    beforeEach(() => {
      articles = testCollection();
      return articles
        .create({ title: "foo" })
        .then(({ data }) => (article = data));
    });

    it("should emit an event on create", done => {
      articles.events.on("create", () => done());
      articles.create({ title: "win" });
    });

    it("should emit an event on update", done => {
      articles.events.on("update", () => done());
      articles.update({ ...article, title: "changed" });
    });

    it("should emit an event on delete", done => {
      articles.events.on("delete", () => done());
      articles.delete(article.id);
    });

    it("should emit a 'delete' event when calling deleteAll", done => {
      articles.events.on("delete", () => done());
      articles.deleteAll();
    });

    it("should emit a 'deleteAll' event when calling deleteAll", done => {
      articles.events.on("deleteAll", () => done());
      articles.deleteAll();
    });

    it("should emit an event on deleteAny", done => {
      articles.events.on("delete", () => done());
      articles.deleteAny(article.id);
    });

    it("should not emit if deleteAny fails", done => {
      articles.events.on("delete", () => done(new Error("fail")));
      articles.deleteAny(uuid4()).then(() => done());
    });

    it("should emit a create event on upsert", done => {
      articles.events.on("create", () => done());
      articles.upsert({ id: uuid4(), create: "new" });
    });

    it("should emit a update event on upsert", done => {
      articles.events.on("update", () => done());
      articles.upsert({ update: "existing", ...article });
    });

    it("should provide created record in data", done => {
      articles.events.on("create", event => {
        expect(event)
          .to.have.property("data")
          .to.have.property("title")
          .eql("win");
        done();
      });
      articles.create({ title: "win" });
    });

    it("should provide new record in data and old record", done => {
      articles.events.on("update", event => {
        const { data, oldRecord } = event;
        expect(data)
          .to.have.property("title")
          .eql("changed");
        expect(oldRecord)
          .to.have.property("title")
          .eql("foo");
        done();
      });
      articles.update({ ...article, title: "changed" });
    });

    it("should not provide oldRecord on creation with upsert", done => {
      articles.events.on("create", event => {
        expect(event).not.to.have.property("oldRecord");
        done();
      });
      articles.upsert({ id: uuid4(), some: "new" });
    });

    it("should provide old record", done => {
      articles.events.on("delete", event => {
        expect(event)
          .to.have.property("data")
          .to.have.property("title")
          .eql("foo");
        done();
      });
      articles.delete(article.id);
    });

    describe("Transactions", () => {
      it("should send every events of a transaction", () => {
        const callback = sinon.spy();
        articles.events.on("create", callback);

        return articles
          .execute(txn => {
            txn.create({ id: uuid4(), title: "foo" });
            txn.create({ id: uuid4(), title: "bar" });
          })
          .then(() => expect(callback.callCount, 2));
      });

      it("should not send any event if the transaction fails", () => {
        const callback = sinon.spy();
        articles.events.on("create", callback);

        return articles
          .execute(txn => {
            txn.create({ id: uuid4(), title: "foo" });
            throw new Error("Fail!");
          })
          .catch(() => {})
          .then(() => expect(callback.callCount).eql(0));
      });

      it("should not send any change event if nothing happens in transaction", () => {
        const callback = sinon.spy();
        articles.events.on("change", callback);

        return articles
          .execute(txn => {
            txn.deleteAny({ id: uuid4() });
          })
          .then(() => expect(callback.callCount).eql(0));
      });

      it("should send a single changed event for the whole transaction", () => {
        const callback = sinon.spy();
        const id = uuid4();
        const id2 = uuid4();

        return articles
          .create({ id, title: "foo" }, { useRecordId: true })
          .then(() => {
            articles.events.on("change", callback);
            return articles.execute(
              txn => {
                txn.create({ id: id2, title: "bar" });
                txn.update({ id, size: 42 });
                txn.delete(id);
              },
              { preloadIds: [id] }
            );
          })
          .then(() => {
            expect(callback.callCount).eql(1);
            const payload = callback.lastCall.args[0];
            const { targets } = payload;
            expect(targets.length).eql(3);
            expect(targets[0]).eql({
              action: "create",
              data: { id: id2, title: "bar" },
            });
            expect(targets[1]).eql({
              action: "update",
              data: { _status: "created", id, size: 42 }, // never synced.
              oldRecord: { _status: "created", id, title: "foo" },
            });
            expect(targets[2]).eql({
              action: "delete",
              data: { _status: "created", id, title: "foo" },
            });
          });
      });
    });
  });
});