Home Reference Source Test

test/integration_test.js

"use strict";

import { spawn } from "child_process";
import { v4 as uuid4 } from "uuid";
import btoa from "btoa";
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import KintoServer from "kinto-node-test-server";
import Kinto from "../src";
import { recordsEqual, ServerWasFlushedError } from "../src/collection";

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

const TEST_KINTO_SERVER = "http://0.0.0.0:8888/v1";

const appendTransformer = function(s) {
  return {
    encode(record) {
      return Promise.resolve({ ...record, title: (record.title || "") + s });
    },
    decode(record) {
      if (record.title) {
        let newTitle = record.title;
        if (record.title.slice(-s.length) === s) {
          newTitle = record.title.slice(0, -1);
        }
        return Promise.resolve({ ...record, title: newTitle });
      }
      return Promise.resolve(record);
    },
  };
};

/**
 * Verify that syncing again is a no-op.
 */
function futureSyncsOK(getCollection, getLastSyncResult) {
  describe("On next MANUAL sync", () => {
    let nextSyncResult;

    beforeEach(() => {
      return getCollection()
        .sync()
        .then(result => {
          nextSyncResult = result;
        });
    });

    it("should have an ok status", () => {
      expect(nextSyncResult.ok).eql(true);
    });

    it("should contain no errors", () => {
      expect(nextSyncResult.errors).to.have.length.of(0);
    });

    it("should have the same lastModified value", () => {
      expect(nextSyncResult.lastModified).eql(getLastSyncResult().lastModified);
    });

    it("should not contain conflicts anymore", () => {
      expect(nextSyncResult.conflicts).to.have.length.of(0);
    });

    it("should not skip anything", () => {
      expect(nextSyncResult.skipped).to.have.length.of(0);
    });

    it("should not import anything", () => {
      expect(nextSyncResult.created).to.have.length.of(0);
    });

    it("should not publish anything", () => {
      expect(nextSyncResult.published).to.have.length.of(0);
    });

    it("should not update anything", () => {
      expect(nextSyncResult.updated).to.have.length.of(0);
    });
  });
}

describe("Integration tests", function() {
  let sandbox, server, kinto, tasks, tasksTransformed;

  // Disabling test timeouts until pserve gets decent startup time.
  this.timeout(0);

  before(() => {
    let kintoConfigPath = __dirname + "/kinto.ini";
    if (process.env.SERVER && process.env.SERVER !== "master") {
      kintoConfigPath = `${__dirname}/kinto-${process.env.SERVER}.ini`;
    }
    server = new KintoServer(TEST_KINTO_SERVER, { kintoConfigPath });
  });

  after(() => {
    const logLines = server.logs.toString().split("\n");
    const serverDidCrash = logLines.some(l => l.includes("Traceback"));
    if (serverDidCrash) {
      // Server errors have been encountered, raise to break the build
      const trace = logLines.join("\n");
      throw new Error(
        `Kinto server crashed while running the test suite.\n\n${trace}`
      );
    }
    return server.killAll();
  });

  after(done => {
    // Ensure no pserve process remains after tests having been executed.
    spawn("killall", ["pserve"]).on("close", () => done());
  });

  beforeEach(function() {
    this.timeout(12500);

    sandbox = sinon.createSandbox();

    kinto = new Kinto({
      remote: TEST_KINTO_SERVER,
      headers: { Authorization: "Basic " + btoa("user:pass") },
    });
    tasks = kinto.collection("tasks");
    tasksTransformed = kinto.collection("tasks-transformer", {
      remoteTransformers: [appendTransformer("!")],
    });
  });

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

  describe("Default server configuration", () => {
    before(() => server.start());

    after(() => server.stop());

    beforeEach(() => {
      return tasks
        .clear()
        .then(_ => tasksTransformed.clear())
        .then(_ => server.flush());
    });

    describe("Synchronization", () => {
      function testSync(data, options = {}) {
        return collectionTestSync(tasks, data, options);
      }

      function collectionTestSync(collection, data, options) {
        return Promise.all(
          [].concat(
            // Create local unsynced records
            data.localUnsynced.map(record =>
              collection.create(record, { useRecordId: true })
            ),
            // Create local synced records
            data.localSynced.map(record =>
              collection.create(record, { synced: true })
            ),
            // Create remote records
            collection.api
              .bucket("default")
              .collection(collection._name)
              .batch(
                batch => {
                  data.server.forEach(r => batch.createRecord(r));
                  data.localSynced.forEach(r => batch.createRecord(r));
                },
                { safe: true }
              )
          )
        ).then(_ => {
          return collection.sync(options);
        });
      }

      function getRemoteList(collection = "tasks") {
        return fetch(
          `${TEST_KINTO_SERVER}/buckets/default/collections/${collection}/records?_sort=title`,
          {
            headers: { Authorization: "Basic " + btoa("user:pass") },
          }
        )
          .then(res => res.json())
          .then(json =>
            json.data.map(record => ({
              title: record.title,
              done: record.done,
            }))
          );
      }

      describe("No change", () => {
        const testData = {
          localSynced: [],
          localUnsynced: [],
          server: [{ id: uuid4(), title: "task1", done: true }],
        };
        let syncResult1;
        let syncResult2;

        beforeEach(() => {
          // Sync twice.
          return testSync(testData)
            .then(res => {
              syncResult1 = res;
              return tasks.sync();
            })
            .then(res => (syncResult2 = res));
        });

        it("should have an ok status", () => {
          expect(syncResult2.ok).eql(true);
        });

        it("should not contain conflicts", () => {
          expect(syncResult2.conflicts).to.have.length.of(0);
        });

        it("should have same lastModified value", () => {
          expect(syncResult1.lastModified).to.eql(syncResult2.lastModified);
        });
      });

      describe("No conflict", () => {
        const testData = {
          localSynced: [
            { id: uuid4(), title: "task2", done: false },
            { id: uuid4(), title: "task3", done: true },
          ],
          localUnsynced: [{ id: uuid4(), title: "task4", done: false }],
          server: [{ id: uuid4(), title: "task1", done: true }],
        };
        let syncResult;

        beforeEach(() => {
          return testSync(testData).then(res => (syncResult = res));
        });

        it("should have an ok status", () => {
          expect(syncResult.ok).eql(true);
        });

        it("should contain no errors", () => {
          expect(syncResult.errors).to.have.length.of(0);
        });

        it("should have a valid lastModified value", () => {
          expect(syncResult.lastModified).to.be.a("number");
        });

        it("should not contain conflicts", () => {
          expect(syncResult.conflicts).to.have.length.of(0);
        });

        it("should not skip records", () => {
          expect(syncResult.skipped).to.have.length.of(0);
        });

        it("should import server data", () => {
          expect(syncResult.created).to.have.length.of(1);
          expect(syncResult.created[0])
            .to.have.property("title")
            .eql(testData.server[0].title);

          expect(syncResult.created[0])
            .to.have.property("done")
            .eql(testData.server[0].done);
        });

        it("should publish local unsynced records", () => {
          expect(syncResult.published).to.have.length.of(1);
          expect(
            recordsEqual(syncResult.published[0], testData.localUnsynced[0])
          ).eql(true);
        });

        it("should publish deletion of locally deleted records", () => {
          const locallyDeletedId = testData.localSynced[0].id;
          return tasks
            .delete(locallyDeletedId)
            .then(_ => tasks.sync())
            .then(_ => getRemoteList())
            .should.eventually.become([
              { title: "task1", done: true },
              { title: "task3", done: true },
              { title: "task4", done: false },
            ]);
        });

        it("should not update anything", () => {
          expect(syncResult.updated).to.have.length.of(0);
        });

        it("should put local database in the expected state", () => {
          return tasks
            .list({ order: "title" })
            .then(res =>
              res.data.map(record => ({
                title: record.title,
                done: record.done,
                _status: record._status,
              }))
            )
            .should.become([
              { title: "task1", _status: "synced", done: true },
              { title: "task2", _status: "synced", done: false },
              { title: "task3", _status: "synced", done: true },
              { title: "task4", _status: "synced", done: false },
            ]);
        });

        it("should put remote test server data in the expected state", () => {
          return getRemoteList().should.become([
            // task1, task2, task3 were prexisting.
            { title: "task1", done: true },
            { title: "task2", done: false },
            { title: "task3", done: true },
            { title: "task4", done: false }, // published via sync.
          ]);
        });

        it("should fetch every server page", () => {
          return collectionTestSync(tasks, {
            localUnsynced: [],
            localSynced: [],
            server: Array(10)
              .fill()
              .map((e, i) => ({ id: uuid4(), title: `task${i}`, done: true })),
          })
            .then(() => tasks.list())
            .then(res => res.data)
            .should.eventually.have.length(10 + 4);
        });

        futureSyncsOK(() => tasks, () => syncResult);
      });

      describe("Incoming conflict", () => {
        const conflictingId = uuid4();
        const testData = {
          localSynced: [
            { id: uuid4(), title: "task1", done: true },
            { id: uuid4(), title: "task2", done: false },
            { id: uuid4(), title: "task3", done: true },
          ],
          localUnsynced: [
            { id: conflictingId, title: "task4-local", done: false },
          ],
          server: [{ id: conflictingId, title: "task4-remote", done: true }],
        };
        let syncResult;

        describe("MANUAL strategy (default)", () => {
          beforeEach(() => {
            return testSync(testData).then(res => (syncResult = res));
          });

          it("should not have an ok status", () => {
            expect(syncResult.ok).eql(false);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should have the incoming conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(1);
            expect(syncResult.conflicts[0].type).eql("incoming");
            expect(
              recordsEqual(syncResult.conflicts[0].local, {
                id: conflictingId,
                title: "task4-local",
                done: false,
              })
            ).eql(true);
            expect(
              recordsEqual(syncResult.conflicts[0].remote, {
                id: conflictingId,
                title: "task4-remote",
                done: true,
              })
            ).eql(true);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should not merge anything", () => {
            expect(syncResult.resolved).to.have.length.of(0);
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  done: record.done,
                  _status: record._status,
                }))
              )
              .should.become([
                { title: "task1", _status: "synced", done: true },
                { title: "task2", _status: "synced", done: false },
                { title: "task3", _status: "synced", done: true },
                // For MANUAL strategy, local conficting record is left intact
                { title: "task4-local", _status: "created", done: false },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              // task1, task2, task3 were prexisting.
              { title: "task1", done: true },
              { title: "task2", done: false },
              { title: "task3", done: true },
              // Remote record should have been left intact.
              { title: "task4-remote", done: true },
            ]);
          });

          describe("On next MANUAL sync", () => {
            let nextSyncResult;

            beforeEach(() => {
              return tasks.sync().then(result => {
                nextSyncResult = result;
              });
            });

            it("should not have an ok status", () => {
              expect(nextSyncResult.ok).eql(false);
            });

            it("should contain no errors", () => {
              expect(nextSyncResult.errors).to.have.length.of(0);
            });

            it("should not have bumped the lastModified value", () => {
              expect(nextSyncResult.lastModified).eql(syncResult.lastModified);
            });

            it("should preserve unresolved conflicts", () => {
              expect(nextSyncResult.conflicts).to.have.length.of(1);
            });

            it("should not skip anything", () => {
              expect(nextSyncResult.skipped).to.have.length.of(0);
            });

            it("should not import anything", () => {
              expect(nextSyncResult.created).to.have.length.of(0);
            });

            it("should not publish anything", () => {
              expect(nextSyncResult.published).to.have.length.of(0);
            });

            it("should not update anything", () => {
              expect(nextSyncResult.updated).to.have.length.of(0);
            });
          });
        });

        describe("CLIENT_WINS strategy", () => {
          beforeEach(() => {
            return testSync(testData, {
              strategy: Kinto.syncStrategy.CLIENT_WINS,
            }).then(res => (syncResult = res));
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should have updated lastModified", () => {
            expect(tasks.lastModified).to.equal(syncResult.lastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              syncResult.lastModified
            );
          });

          it("should have no incoming conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should publish resolved conflict using local version", () => {
            expect(syncResult.published).to.have.length.of(1);
            expect(
              recordsEqual(syncResult.published[0], {
                id: conflictingId,
                title: "task4-local",
                done: false,
              })
            ).eql(true);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(
              recordsEqual(syncResult.resolved[0].accepted, {
                id: conflictingId,
                title: "task4-local",
                done: false,
              })
            ).eql(true);
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  done: record.done,
                  _status: record._status,
                }))
              )
              .should.become([
                { title: "task1", _status: "synced", done: true },
                { title: "task2", _status: "synced", done: false },
                { title: "task3", _status: "synced", done: true },
                // For CLIENT_WINS strategy, local record is marked as synced
                { title: "task4-local", _status: "synced", done: false },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              // local task4 should have been published to the server.
              { title: "task1", done: true },
              { title: "task2", done: false },
              { title: "task3", done: true },
              { title: "task4-local", done: false },
            ]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("CLIENT_WINS strategy with transformers", () => {
          beforeEach(() => {
            return collectionTestSync(tasksTransformed, testData, {
              strategy: Kinto.syncStrategy.CLIENT_WINS,
            }).then(res => (syncResult = res));
          });

          it("should publish resolved conflict using local version", () => {
            expect(syncResult.published).to.have.length.of(1);
            expect(
              recordsEqual(syncResult.published[0], {
                id: conflictingId,
                title: "task4-local",
                done: false,
              })
            ).eql(true);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(
              recordsEqual(syncResult.resolved[0].accepted, {
                id: conflictingId,
                title: "task4-local",
                done: false,
              })
            ).eql(true);
          });

          it("should put local database in the expected state", () => {
            return tasksTransformed
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  done: record.done,
                  _status: record._status,
                }))
              )
              .should.become([
                { title: "task1", _status: "synced", done: true },
                { title: "task2", _status: "synced", done: false },
                { title: "task3", _status: "synced", done: true },
                // For CLIENT_WINS strategy, local record is marked as synced
                { title: "task4-local", _status: "synced", done: false },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList(tasksTransformed._name).should.become([
              // local task4 should have been published to the server.
              { title: "task1", done: true },
              { title: "task2", done: false },
              { title: "task3", done: true },
              { title: "task4-local!", done: false },
            ]);
          });
        });

        describe("SERVER_WINS strategy", () => {
          beforeEach(() => {
            return testSync(testData, {
              strategy: Kinto.syncStrategy.SERVER_WINS,
            }).then(res => (syncResult = res));
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should have updated lastModified", () => {
            expect(tasks.lastModified).to.equal(syncResult.lastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              syncResult.lastModified
            );
          });

          it("should have no incoming conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish resolved conflict using remote version", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(
              recordsEqual(syncResult.resolved[0].accepted, {
                id: conflictingId,
                title: "task4-remote",
                done: true,
              })
            ).eql(true);
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  done: record.done,
                  _status: record._status,
                }))
              )
              .should.become([
                { title: "task1", _status: "synced", done: true },
                { title: "task2", _status: "synced", done: false },
                { title: "task3", _status: "synced", done: true },
                // For SERVER_WINS strategy, remote record is marked as synced
                { title: "task4-remote", _status: "synced", done: true },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              { title: "task1", done: true },
              { title: "task2", done: false },
              { title: "task3", done: true },
              // remote task4 should have been published to the server.
              { title: "task4-remote", done: true },
            ]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("Resolving conflicts doesn't interfere with sync", () => {
          const conflictingId = uuid4();
          const testData = {
            localSynced: [
              { id: conflictingId, title: "conflicting task", done: false },
            ],
            localUnsynced: [],
            server: [],
          };
          let rawCollection;

          beforeEach(() => {
            rawCollection = tasks.api.bucket("default").collection("tasks");
            return testSync(testData);
          });

          it("should sync over resolved records", () => {
            return tasks
              .update(
                { id: conflictingId, title: "locally changed title" },
                { patch: true }
              )
              .then(({ data: newRecord }) => {
                expect(newRecord.last_modified).to.exist;
                // Change the record remotely to introduce a comment
                return rawCollection.updateRecord(
                  { id: conflictingId, title: "remotely changed title" },
                  { patch: true }
                );
              })
              .then(() => tasks.sync())
              .then(syncResult => {
                expect(syncResult.ok).eql(false);
                expect(syncResult.conflicts).to.have.length.of(1);
                // Always pick our version.
                // #resolve will copy the remote last_modified.
                return tasks.resolve(
                  syncResult.conflicts[0],
                  syncResult.conflicts[0].local
                );
              })
              .then(() => tasks.sync())
              .then(syncResult => {
                expect(syncResult.ok).eql(true);
                expect(syncResult.conflicts).to.have.length.of(0);
                expect(syncResult.updated).to.have.length.of(0);
                expect(syncResult.published).to.have.length.of(1);
              })
              .then(() => tasks.get(conflictingId))
              .then(({ data: record }) => {
                expect(record.title).eql("locally changed title");
                expect(record._status).eql("synced");
              });
          });

          it("should not skip other conflicts", () => {
            const conflictingId2 = uuid4();
            return tasks
              .create(
                { id: conflictingId2, title: "second title" },
                { useRecordId: true }
              )
              .then(() => tasks.sync())
              .then(() =>
                rawCollection.updateRecord(
                  { id: conflictingId, title: "remotely changed title" },
                  { patch: true }
                )
              )
              .then(() =>
                rawCollection.updateRecord(
                  { id: conflictingId2, title: "remotely changed title2" },
                  { patch: true }
                )
              )
              .then(() =>
                tasks.update(
                  { id: conflictingId, title: "locally changed title" },
                  { patch: true }
                )
              )
              .then(() =>
                tasks.update(
                  { id: conflictingId2, title: "local title2" },
                  { patch: true }
                )
              )
              .then(() => tasks.sync())
              .then(syncResult => {
                expect(syncResult.ok).eql(false);
                expect(syncResult.conflicts).to.have.length.of(2);
                // resolve just one conflict and ensure that the other
                // one continues preventing the sync, even though it
                // happened "after" the first conflict
                return tasks.resolve(
                  syncResult.conflicts[1],
                  syncResult.conflicts[1].local
                );
              })
              .then(() => tasks.sync())
              .then(syncResult => {
                expect(syncResult.ok).eql(false);
                expect(syncResult.conflicts).to.have.length.of(1);
                expect(syncResult.updated).to.have.length.of(0);
              });
          });
        });
      });

      describe("Outgoing conflicting local deletion", () => {
        describe("With remote update", () => {
          let id, conflicts;

          beforeEach(() => {
            return tasks
              .create({ title: "initial" })
              .then(({ data }) => {
                id = data.id;
                return tasks.sync();
              })
              .then(() => {
                return tasks.delete(id);
              })
              .then(() => {
                return tasks.api
                  .bucket("default")
                  .collection("tasks")
                  .updateRecord({ id, title: "server-updated" });
              })
              .then(() => {
                return tasks.sync();
              })
              .then(res => {
                conflicts = res.conflicts;
              });
          });

          it("should properly list the encountered conflict", () => {
            expect(conflicts).to.have.length.of(1);
          });

          it("should list the proper type of conflict", () => {
            expect(conflicts[0].type).eql("outgoing");
          });

          it("should have the expected conflicting local version", () => {
            expect(conflicts[0].local).eql({ id });
          });

          it("should have the expected conflicting remote version", () => {
            expect(conflicts[0].remote)
              .to.have.property("id")
              .eql(id);
            expect(conflicts[0].remote)
              .to.have.property("title")
              .eql("server-updated");
          });
        });

        describe("With remote deletion", () => {
          let id, result;

          beforeEach(() => {
            return tasks
              .create({ title: "initial" })
              .then(({ data }) => {
                id = data.id;
                return tasks.sync();
              })
              .then(() => {
                return tasks.delete(id);
              })
              .then(() => {
                return tasks.api
                  .bucket("default")
                  .collection("tasks")
                  .deleteRecord(id);
              })
              .then(() => {
                return tasks.sync();
              })
              .then(res => (result = res));
          });

          it("should properly list the encountered conflict", () => {
            expect(result.skipped).to.have.length.of(1);
          });

          it("should provide the record", () => {
            expect(result.skipped[0])
              .to.have.property("id")
              .eql(id);
          });
        });
      });

      describe("Outgoing conflict", () => {
        let syncResult;

        function setupConflict(collection) {
          let recordId;
          const record = { title: "task1-remote", done: true };
          // Ensure that the remote record looks like something that's
          // been transformed
          return collection
            ._encodeRecord("remote", record)
            .then(record => {
              return collection.api
                .bucket("default")
                .collection(collection._name)
                .createRecord(record);
            })
            .then(_ => collection.sync())
            .then(res => {
              recordId = res.created[0].id;
              return collection.delete(recordId, { virtual: false });
            })
            .then(_ =>
              collection.create(
                {
                  id: recordId,
                  title: "task1-local",
                  done: false,
                },
                { useRecordId: true }
              )
            );
        }

        beforeEach(() => {
          return setupConflict(tasks).then(() =>
            setupConflict(tasksTransformed)
          );
        });

        describe("MANUAL strategy (default)", () => {
          let oldLastModified;
          beforeEach(() => {
            oldLastModified = tasks.lastModified;
            return tasks.sync().then(res => {
              syncResult = res;
            });
          });

          it("should not have an ok status", () => {
            expect(syncResult.ok).eql(false);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should not have updated lastModified", () => {
            // lastModified hasn't changed because we haven't synced
            // anything since lastModified
            expect(tasks.lastModified).to.equal(oldLastModified);
            expect(tasks.lastModified).to.equal(syncResult.lastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              syncResult.lastModified
            );
          });

          it("should have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(1);
            expect(syncResult.conflicts[0].type).eql("outgoing");
            expect(syncResult.conflicts[0].local.title).eql("task1-local");
            expect(syncResult.conflicts[0].remote.title).eql("task1-remote");
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should not merge anything", () => {
            expect(syncResult.resolved).to.have.length.of(0);
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For MANUAL strategy, local conficting record is left intact
                { title: "task1-local", _status: "created" },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              // local version should have been published to the server.
              { title: "task1-remote", done: true },
            ]);
          });

          describe("On next MANUAL sync", () => {
            let nextSyncResult;

            beforeEach(() => {
              return tasks.sync().then(result => {
                nextSyncResult = result;
              });
            });

            it("should not have an ok status", () => {
              expect(nextSyncResult.ok).eql(false);
            });

            it("should contain no errors", () => {
              expect(nextSyncResult.errors).to.have.length.of(0);
            });

            it("should not have bumped the lastModified value", () => {
              expect(nextSyncResult.lastModified).eql(syncResult.lastModified);
            });

            it("should preserve unresolved conflicts", () => {
              expect(nextSyncResult.conflicts).to.have.length.of(1);
            });

            it("should not skip anything", () => {
              expect(nextSyncResult.skipped).to.have.length.of(0);
            });

            it("should not import anything", () => {
              expect(nextSyncResult.created).to.have.length.of(0);
            });

            it("should not publish anything", () => {
              expect(nextSyncResult.published).to.have.length.of(0);
            });

            it("should not update anything", () => {
              expect(nextSyncResult.updated).to.have.length.of(0);
            });
          });
        });

        describe("CLIENT_WINS strategy", () => {
          let oldLastModified;
          beforeEach(() => {
            oldLastModified = tasks.lastModified;
            return tasks
              .sync({ strategy: Kinto.syncStrategy.CLIENT_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should have updated lastModified", () => {
            // At the end of the sync, we will have pushed our record
            // remotely, which won't have caused a conflict, which
            // will update the remote lastModified, and this is the
            // lastModified our collection will have.
            expect(tasks.lastModified).above(oldLastModified);
            expect(tasks.lastModified).to.equal(syncResult.lastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              syncResult.lastModified
            );
          });

          it("should have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should publish resolved conflicts to the server", () => {
            expect(syncResult.published).to.have.length.of(1);
            expect(syncResult.published[0].title).eql("task1-local");
            expect(syncResult.published[0].done).eql(false);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].accepted.title).eql("task1-local");
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For CLIENT_WINS strategy, local version is marked as synced
                { title: "task1-local", _status: "synced" },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              { title: "task1-local", done: false },
            ]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("CLIENT_WINS strategy with transformers", () => {
          beforeEach(() => {
            return tasksTransformed
              .sync({ strategy: Kinto.syncStrategy.CLIENT_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should put local database in the expected state", () => {
            return tasksTransformed
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For CLIENT_WINS strategy, local version is marked as synced
                { title: "task1-local", _status: "synced" },
              ]);
          });

          it("should put the remote database in the expected state", () => {
            return getRemoteList(tasksTransformed._name).should.become([
              // local task4 should have been published to the server.
              { title: "task1-local!", done: false },
            ]);
          });
        });

        describe("SERVER_WINS strategy", () => {
          let oldLastModified;
          beforeEach(() => {
            oldLastModified = tasks.lastModified;
            return tasks
              .sync({ strategy: Kinto.syncStrategy.SERVER_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should not have updated lastModified", () => {
            // Although we updated the last modified from the server,
            // the server's lastModified is the same as the one we
            // used to have, since the last modification that took
            // place was when we synced the record (before we forgot
            // about it).
            expect(tasks.lastModified).to.equal(oldLastModified);
            expect(tasks.lastModified).to.equal(syncResult.lastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              syncResult.lastModified
            );
          });

          it("should not have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].accepted.title).eql("task1-remote");
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For SERVER_WINS strategy, local version is marked as synced
                { title: "task1-remote", _status: "synced" },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              { title: "task1-remote", done: true },
            ]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("SERVER_WINS strategy with transformers", () => {
          beforeEach(() => {
            return tasksTransformed
              .sync({ strategy: Kinto.syncStrategy.SERVER_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].accepted.title).eql("task1-remote");
          });

          it("should put local database in the expected state", () => {
            return tasksTransformed
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For SERVER_WINS strategy, local version is marked as synced
                { title: "task1-remote", _status: "synced" },
              ]);
          });
        });
      });

      describe("Outgoing conflict (remote deleted)", () => {
        let syncResult;

        function setupConflict(collection) {
          let recordId;
          const record = { title: "task1-remote", done: true };
          // Ensure that the remote record looks like something that's
          // been transformed
          return collection
            ._encodeRecord("remote", record)
            .then(record => {
              return collection.api
                .bucket("default")
                .collection(collection._name)
                .createRecord(record);
            })
            .then(_ => collection.sync())
            .then(res => {
              recordId = res.created[0].id;
              return collection.api.deleteBucket("default");
            })
            .then(_ => {
              // Hack to make it seem like the delete happened "at the
              // same time" as our sync (pull won't see records, but
              // push will fail with conflict)
              return collection.api
                .bucket("default")
                .collection(collection._name)
                .listRecords();
            })
            .then(res => {
              const lastModified = parseInt(res.last_modified, 10);
              collection._lastModified = lastModified;
              return collection.db.saveLastModified(lastModified);
            })
            .then(_ =>
              collection.update(
                {
                  id: recordId,
                  title: "task1-local",
                  done: false,
                },
                { useRecordId: true }
              )
            );
        }

        beforeEach(() => {
          return setupConflict(tasks).then(() =>
            setupConflict(tasksTransformed)
          );
        });

        describe("MANUAL strategy (default)", () => {
          let oldLastModified;

          beforeEach(() => {
            oldLastModified = tasks.lastModified;
            return tasks.sync().then(res => {
              syncResult = res;
            });
          });

          it("should not have an ok status", () => {
            expect(syncResult.ok).eql(false);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should not have updated lastModified", () => {
            // Nothing to update it to; we explicitly copied it from
            // the server before syncing.
            expect(tasks.lastModified).to.equal(oldLastModified);
            expect(tasks.db.getLastModified()).eventually.equal(
              oldLastModified
            );
            expect(tasks.lastModified).equal(syncResult.lastModified);
          });

          it("should have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(1);
            expect(syncResult.conflicts[0].type).eql("outgoing");
            expect(syncResult.conflicts[0].local.title).eql("task1-local");
            expect(syncResult.conflicts[0].remote).eql(null);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should not merge anything", () => {
            expect(syncResult.resolved).to.have.length.of(0);
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For MANUAL strategy, local conficting record is left intact
                { title: "task1-local", _status: "updated" },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([]);
          });

          describe("On next MANUAL sync", () => {
            let nextSyncResult;

            beforeEach(() => {
              return tasks.sync().then(result => {
                nextSyncResult = result;
              });
            });

            it("should not have an ok status", () => {
              expect(nextSyncResult.ok).eql(false);
            });

            it("should contain no errors", () => {
              expect(nextSyncResult.errors).to.have.length.of(0);
            });

            it("should not have bumped the lastModified value", () => {
              expect(nextSyncResult.lastModified).eql(syncResult.lastModified);
            });

            it("should preserve unresolved conflicts", () => {
              expect(nextSyncResult.conflicts).to.have.length.of(1);
            });

            it("should not skip anything", () => {
              expect(nextSyncResult.skipped).to.have.length.of(0);
            });

            it("should not import anything", () => {
              expect(nextSyncResult.created).to.have.length.of(0);
            });

            it("should not publish anything", () => {
              expect(nextSyncResult.published).to.have.length.of(0);
            });

            it("should not update anything", () => {
              expect(nextSyncResult.updated).to.have.length.of(0);
            });
          });
        });

        describe("CLIENT_WINS strategy", () => {
          beforeEach(() => {
            return tasks
              .sync({ strategy: Kinto.syncStrategy.CLIENT_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should not have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should publish resolved conflicts to the server", () => {
            expect(syncResult.published).to.have.length.of(1);
            expect(syncResult.published[0].title).eql("task1-local");
            expect(syncResult.published[0].done).eql(false);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].rejected).eql(null);
            expect(syncResult.resolved[0].id).eql(
              syncResult.resolved[0].accepted.id
            );
            expect(syncResult.resolved[0].accepted.title).eql("task1-local");
          });

          it("should put local database in the expected state", () => {
            return tasks
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For CLIENT_WINS strategy, local version is marked as synced
                { title: "task1-local", _status: "synced" },
              ]);
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([
              { title: "task1-local", done: false },
            ]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("CLIENT_WINS strategy with transformers", () => {
          beforeEach(() => {
            return tasksTransformed
              .sync({ strategy: Kinto.syncStrategy.CLIENT_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should put local database in the expected state", () => {
            return tasksTransformed
              .list({ order: "title" })
              .then(res =>
                res.data.map(record => ({
                  title: record.title,
                  _status: record._status,
                }))
              )
              .should.become([
                // For CLIENT_WINS strategy, local version is marked as synced
                { title: "task1-local", _status: "synced" },
              ]);
          });

          it("should put the remote database in the expected state", () => {
            return getRemoteList(tasksTransformed._name).should.become([
              // local task4 should have been published to the server.
              { title: "task1-local!", done: false },
            ]);
          });
        });

        describe("SERVER_WINS strategy", () => {
          beforeEach(() => {
            return tasks
              .sync({ strategy: Kinto.syncStrategy.SERVER_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should have an ok status", () => {
            expect(syncResult.ok).eql(true);
          });

          it("should contain no errors", () => {
            expect(syncResult.errors).to.have.length.of(0);
          });

          it("should have a valid lastModified value", () => {
            expect(syncResult.lastModified).to.be.a("number");
          });

          it("should have the outgoing conflict listed", () => {
            expect(syncResult.conflicts).to.have.length.of(0);
          });

          it("should not skip records", () => {
            expect(syncResult.skipped).to.have.length.of(0);
          });

          it("should not import anything", () => {
            expect(syncResult.created).to.have.length.of(0);
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].accepted).eql(null);
            expect(syncResult.resolved[0]).property("_status", "synced");
            expect(syncResult.resolved[0].rejected.title).eql("task1-local");
          });

          it("should put local database in the expected state", () => {
            return (
              tasks
                .list({ order: "title" })
                .then(res => res.data)
                // For SERVER_WINS strategy, local version is deleted
                .should.become([])
            );
          });

          it("should put remote test server data in the expected state", () => {
            return getRemoteList().should.become([]);
          });

          futureSyncsOK(() => tasks, () => syncResult);
        });

        describe("SERVER_WINS strategy with transformers", () => {
          beforeEach(() => {
            return tasksTransformed
              .sync({ strategy: Kinto.syncStrategy.SERVER_WINS })
              .then(res => {
                syncResult = res;
              });
          });

          it("should not publish anything", () => {
            expect(syncResult.published).to.have.length.of(0);
          });

          it("should not update anything", () => {
            expect(syncResult.updated).to.have.length.of(0);
          });

          it("should list resolved records", () => {
            expect(syncResult.resolved).to.have.length.of(1);
            expect(syncResult.resolved[0].id).eql(
              syncResult.resolved[0].rejected.id
            );
            expect(syncResult.resolved[0].rejected.title).eql("task1-local");
          });

          it("should put local database in the expected state", () => {
            return (
              tasksTransformed
                .list({ order: "title" })
                .then(res => res.data)
                // For SERVER_WINS strategy, local version is deleted
                .should.become([])
            );
          });
        });
      });

      describe("Load dump", () => {
        beforeEach(() => {
          const id1 = uuid4();
          const id2 = uuid4();
          const tasksRemote = tasks.api.bucket("default").collection("tasks");
          const dump = [
            { id: uuid4(), last_modified: 123456, title: "task1", done: false },
            { id: id1, last_modified: 123457, title: "task2", done: false },
            { id: id2, last_modified: 123458, title: "task3", done: false },
            { id: uuid4(), last_modified: 123459, title: "task4", done: false },
          ];
          return Promise.all(dump.map(r => tasksRemote.createRecord(r)))
            .then(() => tasks.importBulk(dump))
            .then(() =>
              tasksRemote.updateRecord({ id: id1, title: "task22", done: true })
            )
            .then(() =>
              tasksRemote.updateRecord({ id: id2, title: "task33", done: true })
            );
        });

        it("should sync changes on loaded data", () => {
          return tasks.sync().then(res => {
            expect(res.ok).eql(true);
            expect(res.updated.length).eql(2);
          });
        });
      });

      describe("Batch request chunking", () => {
        let nbFixtures;

        function loadFixtures() {
          return tasks.api.fetchServerSettings().then(serverSettings => {
            nbFixtures = serverSettings["batch_max_requests"] + 10;
            const fixtures = [];
            for (let i = 0; i < nbFixtures; i++) {
              fixtures.push({ title: "title" + i, position: i });
            }
            return Promise.all(fixtures.map(f => tasks.create(f)));
          });
        }

        beforeEach(() => {
          return loadFixtures().then(_ => tasks.sync());
        });

        it("should create the expected number of records", () => {
          return tasks.list({ order: "-position" }).then(res => {
            expect(res.data.length).eql(nbFixtures);
            expect(res.data[0].position).eql(nbFixtures - 1);
          });
        });
      });
    });

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

      describe("IdSchema", () => {
        beforeEach(() => {
          tasks = kinto.collection("tasks", {
            idSchema: createIntegerIdSchema(),
          });
        });

        it("should generate id's using the IdSchema", () => {
          return tasks
            .create({ foo: "bar" })
            .then(record => {
              return record.data.id;
            })
            .should.become(0);
        });
      });
    });

    describe("Transformers", () => {
      function createTransformer(char) {
        return {
          encode(record) {
            return { ...record, title: record.title + char };
          },
          decode(record) {
            return { ...record, title: record.title.slice(0, -1) };
          },
        };
      }

      beforeEach(() => {
        tasks = kinto.collection("tasks", {
          remoteTransformers: [createTransformer("!"), createTransformer("?")],
        });

        return Promise.all([
          tasks.create({ id: uuid4(), title: "abc" }, { useRecordId: true }),
          tasks.create({ id: uuid4(), title: "def" }, { useRecordId: true }),
        ]);
      });

      it("should list published records unencoded", () => {
        return tasks
          .sync()
          .then(res => res.published.map(x => x.title).sort())
          .should.become(["abc", "def"]);
      });

      it("should store encoded data remotely", () => {
        return tasks
          .sync()
          .then(_ => {
            return fetch(
              `${TEST_KINTO_SERVER}/buckets/default/collections/tasks/records`,
              {
                headers: { Authorization: "Basic " + btoa("user:pass") },
              }
            );
          })
          .then(res => res.json())
          .then(res => res.data.map(x => x.title).sort())
          .should.become(["abc!?", "def!?"]);
      });

      it("should keep local data decoded", () => {
        return tasks
          .sync()
          .then(_ => tasks.list())
          .then(res => res.data.map(x => x.title).sort())
          .should.become(["abc", "def"]);
      });
    });

    describe("Transforming local deletes", () => {
      function localDeleteTransformer() {
        // Turns local records that were deleted, but had
        // "preserve-on-send", into remote "updates".
        // Local records with "preserve-on-send" but weren't deleted
        // don't need to be "preserved", so ignore them.
        return {
          encode(record) {
            if (record._status == "deleted") {
              if (record.title.includes("preserve-on-send")) {
                if (record.last_modified) {
                  return { ...record, _status: "updated", wasDeleted: true };
                }
                return { ...record, _status: "created", wasDeleted: true };
              }
            }
            return record;
          },
          decode(record) {
            // Records that were deleted locally get pushed to the
            // server with `wasDeleted` so that we know they're
            // supposed to be deleted on the client.
            if (record.wasDeleted) {
              return { ...record, deleted: true };
            }
            return record;
          },
        };
      }

      let tasksRemote;
      const preserveOnSendNew = { id: uuid4(), title: "preserve-on-send new" };
      const preserveOnSendOld = {
        id: uuid4(),
        title: "preserve-on-send old",
        last_modified: 1234,
      };
      const deleteOnReceiveRemote = {
        id: uuid4(),
        title: "delete-on-receive",
        wasDeleted: true,
      };
      const deletedByOtherClientRemote = {
        id: uuid4(),
        title: "deleted-by-other-client",
      };
      beforeEach(() => {
        tasks = kinto.collection("tasks", {
          remoteTransformers: [localDeleteTransformer()],
        });
        tasksRemote = tasks.api.bucket("default").collection("tasks");
        return Promise.all([
          tasks.create(preserveOnSendNew, { useRecordId: true }),
          tasks.create(preserveOnSendOld, { useRecordId: true }),
          tasksRemote.createRecord(deletedByOtherClientRemote),
        ])
          .then(() => tasks.sync())
          .then(() => tasks.delete(preserveOnSendNew.id))
          .then(() => tasks.delete(preserveOnSendOld.id))
          .then(() => tasksRemote.createRecord(deleteOnReceiveRemote));
      });

      it("should have sent preserve-on-send new remotely", () => {
        return tasks
          .sync()
          .then(() => tasksRemote.getRecord(preserveOnSendNew.id))
          .then(res => res.data)
          .should.eventually.property("title", "preserve-on-send new");
      });

      it("should have sent preserve-on-send old remotely", () => {
        return tasks
          .sync()
          .then(() => tasksRemote.getRecord(preserveOnSendOld.id))
          .then(res => res.data)
          .should.eventually.property("title", "preserve-on-send old");
      });

      it("should have locally deleted preserve-on-send new", () => {
        return tasks
          .sync()
          .then(() => tasks.getAny(preserveOnSendNew.id))
          .then(res => res.data)
          .should.eventually.eql(undefined);
      });

      it("should have locally deleted preserve-on-send old", () => {
        return tasks
          .sync()
          .then(() => tasks.getAny(preserveOnSendOld.id))
          .then(res => res.data)
          .should.eventually.eql(undefined);
      });

      it("should have deleted delete-on-receive", () => {
        return tasks
          .sync()
          .then(() => tasks.getAny(deleteOnReceiveRemote.id))
          .then(res => res.data)
          .should.eventually.eql(undefined);
      });

      it("should have deleted deleted-by-other-client", () => {
        return tasks
          .getAny(deletedByOtherClientRemote.id)
          .then(res => expect(res.data.title).to.eql("deleted-by-other-client"))
          .then(() =>
            tasksRemote.createRecord({
              ...deletedByOtherClientRemote,
              wasDeleted: true,
            })
          )
          .then(() => tasks.sync())
          .then(() => tasks.getAny(deletedByOtherClientRemote.id))
          .then(res => res.data)
          .should.eventually.eql(undefined);
      });
    });
  });

  describe("Flushed server", function() {
    before(() => server.start());

    after(() => server.stop());

    beforeEach(() => {
      return tasks
        .clear()
        .then(_ => {
          return Promise.all([
            tasks.create({ name: "foo" }),
            tasks.create({ name: "bar" }),
          ]);
        })
        .then(_ => tasks.sync())
        .then(_ => server.flush());
    });

    it("should reject a call to sync() with appropriate message", () => {
      return tasks
        .sync()
        .should.be.rejectedWith(
          ServerWasFlushedError,
          /^Server has been flushed. Client Side Timestamp: \d+ Server Side Timestamp: \d+$/
        );
    });

    it("should allow republishing local collection to flushed server", () => {
      return tasks
        .sync()
        .catch(_ => tasks.resetSyncStatus())
        .then(_ => tasks.sync())
        .should.eventually.have.property("published")
        .to.have.length.of(2);
    });
  });

  describe("Backed off server", () => {
    before(() => server.start({ KINTO_BACKOFF: 10 }));

    after(() => server.stop());

    beforeEach(() => {
      return tasks.clear().then(_ => server.flush());
    });

    it("should reject sync when the server sends a Backoff header", () => {
      // Note: first call receive the Backoff header, second actually rejects.
      return tasks
        .sync()
        .then(_ => tasks.sync())
        .should.be.rejectedWith(
          Error,
          /Server is asking clients to back off; retry in 10s/
        );
    });
  });

  describe("Deprecated protocol version", () => {
    beforeEach(() => {
      return tasks.clear().then(_ => server.flush());
    });

    describe("Soft EOL", () => {
      before(() => {
        const tomorrow = new Date(new Date().getTime() + 86400000)
          .toJSON()
          .slice(0, 10);
        return server.start({
          KINTO_EOS: `"${tomorrow}"`,
          KINTO_EOS_URL: "http://www.perdu.com",
          KINTO_EOS_MESSAGE: "Boom",
        });
      });

      after(() => server.stop());

      beforeEach(() => sandbox.stub(console, "warn"));

      it("should warn when the server sends a deprecation Alert header", () => {
        return tasks.sync().then(_ => {
          sinon.assert.calledWithExactly(
            console.warn,
            "Boom",
            "http://www.perdu.com"
          );
        });
      });
    });

    describe("Hard EOL", () => {
      before(() => {
        const lastWeek = new Date(new Date().getTime() - 7 * 86400000)
          .toJSON()
          .slice(0, 10);
        return server.start({
          KINTO_EOS: `"${lastWeek}"`,
          KINTO_EOS_URL: "http://www.perdu.com",
          KINTO_EOS_MESSAGE: "Boom",
        });
      });

      after(() => server.stop());

      beforeEach(() => sandbox.stub(console, "warn"));

      it("should reject with a 410 Gone when hard EOL is received", () => {
        return tasks
          .sync()
          .should.be.rejectedWith(Error, /HTTP 410 Gone: Service deprecated/);
      });
    });
  });
});