/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

Cu.import("resource://services-sync/constants.js");
Cu.import("resource://services-sync/engines.js");
Cu.import("resource://services-sync/main.js");
Cu.import("resource://services-sync/policies.js");
Cu.import("resource://services-sync/record.js");
Cu.import("resource://services-sync/resource.js");
Cu.import("resource://services-sync/service.js");
Cu.import("resource://services-sync/util.js");
Cu.import("resource://testing-common/services/sync/rotaryengine.js");

function makeRotaryEngine() {
  return new RotaryEngine(Service);
}

async function clean(engine) {
  Svc.Prefs.resetBranch("");
  Svc.Prefs.set("log.logger.engine.rotary", "Trace");
  Service.recordManager.clearCache();
  engine._tracker.clearChangedIDs();
  await engine.finalize();
}

async function cleanAndGo(engine, server) {
  await clean(engine);
  await promiseStopServer(server);
}

async function promiseClean(engine, server) {
  await clean(engine);
  await promiseStopServer(server);
}

async function createServerAndConfigureClient() {
  let engine = new RotaryEngine(Service);

  let contents = {
    meta: {global: {engines: {rotary: {version: engine.version,
                                       syncID:  engine.syncID}}}},
    crypto: {},
    rotary: {}
  };

  const USER = "foo";
  let server = new SyncServer();
  server.registerUser(USER, "password");
  server.createContents(USER, contents);
  server.start();

  await SyncTestingInfrastructure(server, USER);
  Service._updateCachedURLs();

  return [engine, server, USER];
}


/*
 * Tests
 *
 * SyncEngine._sync() is divided into four rather independent steps:
 *
 * - _syncStartup()
 * - _processIncoming()
 * - _uploadOutgoing()
 * - _syncFinish()
 *
 * In the spirit of unit testing, these are tested individually for
 * different scenarios below.
 */

add_task(async function setup() {
  await generateNewKeys(Service.collectionKeys);
  Svc.Prefs.set("log.logger.engine.rotary", "Trace");
});

add_task(async function test_syncStartup_emptyOrOutdatedGlobalsResetsSync() {
  _("SyncEngine._syncStartup resets sync and wipes server data if there's no or an outdated global record");

  // Some server side data that's going to be wiped
  let collection = new ServerCollection();
  collection.insert("flying",
                    encryptPayload({id: "flying",
                                    denomination: "LNER Class A3 4472"}));
  collection.insert("scotsman",
                    encryptPayload({id: "scotsman",
                                    denomination: "Flying Scotsman"}));

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  engine._store.items = {rekolok: "Rekonstruktionslokomotive"};
  try {

    // Confirm initial environment
    Assert.equal(engine._tracker.changedIDs.rekolok, undefined);
    let metaGlobal = await Service.recordManager.get(engine.metaURL);
    Assert.equal(metaGlobal.payload.engines, undefined);
    Assert.ok(!!collection.payload("flying"));
    Assert.ok(!!collection.payload("scotsman"));

    engine.lastSync = Date.now() / 1000;
    engine.lastSyncLocal = Date.now();

    // Trying to prompt a wipe -- we no longer track CryptoMeta per engine,
    // so it has nothing to check.
    await engine._syncStartup();

    // The meta/global WBO has been filled with data about the engine
    let engineData = metaGlobal.payload.engines.rotary;
    Assert.equal(engineData.version, engine.version);
    Assert.equal(engineData.syncID, engine.syncID);

    // Sync was reset and server data was wiped
    Assert.equal(engine.lastSync, 0);
    Assert.equal(collection.payload("flying"), undefined);
    Assert.equal(collection.payload("scotsman"), undefined);

  } finally {
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_syncStartup_serverHasNewerVersion() {
  _("SyncEngine._syncStartup ");

  let global = new ServerWBO("global", {engines: {rotary: {version: 23456}}});
  let server = httpd_setup({
      "/1.1/foo/storage/meta/global": global.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  try {

    // The server has a newer version of the data and our engine can
    // handle.  That should give us an exception.
    let error;
    try {
      await engine._syncStartup();
    } catch (ex) {
      error = ex;
    }
    Assert.equal(error.failureCode, VERSION_OUT_OF_DATE);

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_syncStartup_syncIDMismatchResetsClient() {
  _("SyncEngine._syncStartup resets sync if syncIDs don't match");

  let server = sync_httpd_setup({});

  await SyncTestingInfrastructure(server);

  // global record with a different syncID than our engine has
  let engine = makeRotaryEngine();
  let global = new ServerWBO("global",
                             {engines: {rotary: {version: engine.version,
                                                syncID: "foobar"}}});
  server.registerPathHandler("/1.1/foo/storage/meta/global", global.handler());

  try {

    // Confirm initial environment
    Assert.equal(engine.syncID, "fake-guid-00");
    Assert.equal(engine._tracker.changedIDs.rekolok, undefined);

    engine.lastSync = Date.now() / 1000;
    engine.lastSyncLocal = Date.now();
    await engine._syncStartup();

    // The engine has assumed the server's syncID
    Assert.equal(engine.syncID, "foobar");

    // Sync was reset
    Assert.equal(engine.lastSync, 0);

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_emptyServer() {
  _("SyncEngine._processIncoming working with an empty server backend");

  let collection = new ServerCollection();
  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  try {

    // Merely ensure that this code path is run without any errors
    await engine._processIncoming();
    Assert.equal(engine.lastSync, 0);

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_createFromServer() {
  _("SyncEngine._processIncoming creates new records from server data");

  // Some server records that will be downloaded
  let collection = new ServerCollection();
  collection.insert("flying",
                    encryptPayload({id: "flying",
                                    denomination: "LNER Class A3 4472"}));
  collection.insert("scotsman",
                    encryptPayload({id: "scotsman",
                                    denomination: "Flying Scotsman"}));

  // Two pathological cases involving relative URIs gone wrong.
  let pathologicalPayload = encryptPayload({id: "../pathological",
                                            denomination: "Pathological Case"});
  collection.insert("../pathological", pathologicalPayload);

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler(),
      "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(),
      "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler()
  });

  await SyncTestingInfrastructure(server);

  await generateNewKeys(Service.collectionKeys);

  let engine = makeRotaryEngine();
  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    // Confirm initial environment
    Assert.equal(engine.lastSync, 0);
    Assert.equal(engine.lastModified, null);
    Assert.equal(engine._store.items.flying, undefined);
    Assert.equal(engine._store.items.scotsman, undefined);
    Assert.equal(engine._store.items["../pathological"], undefined);

    await engine._syncStartup();
    await engine._processIncoming();

    // Timestamps of last sync and last server modification are set.
    Assert.ok(engine.lastSync > 0);
    Assert.ok(engine.lastModified > 0);

    // Local records have been created from the server data.
    Assert.equal(engine._store.items.flying, "LNER Class A3 4472");
    Assert.equal(engine._store.items.scotsman, "Flying Scotsman");
    Assert.equal(engine._store.items["../pathological"], "Pathological Case");

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_reconcile() {
  _("SyncEngine._processIncoming updates local records");

  let collection = new ServerCollection();

  // This server record is newer than the corresponding client one,
  // so it'll update its data.
  collection.insert("newrecord",
                    encryptPayload({id: "newrecord",
                                    denomination: "New stuff..."}));

  // This server record is newer than the corresponding client one,
  // so it'll update its data.
  collection.insert("newerserver",
                    encryptPayload({id: "newerserver",
                                    denomination: "New data!"}));

  // This server record is 2 mins older than the client counterpart
  // but identical to it, so we're expecting the client record's
  // changedID to be reset.
  collection.insert("olderidentical",
                    encryptPayload({id: "olderidentical",
                                    denomination: "Older but identical"}));
  collection._wbos.olderidentical.modified -= 120;

  // This item simply has different data than the corresponding client
  // record (which is unmodified), so it will update the client as well
  collection.insert("updateclient",
                    encryptPayload({id: "updateclient",
                                    denomination: "Get this!"}));

  // This is a dupe of 'original'.
  collection.insert("duplication",
                    encryptPayload({id: "duplication",
                                    denomination: "Original Entry"}));

  // This record is marked as deleted, so we're expecting the client
  // record to be removed.
  collection.insert("nukeme",
                    encryptPayload({id: "nukeme",
                                    denomination: "Nuke me!",
                                    deleted: true}));

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  engine._store.items = {newerserver: "New data, but not as new as server!",
                         olderidentical: "Older but identical",
                         updateclient: "Got data?",
                         original: "Original Entry",
                         long_original: "Long Original Entry",
                         nukeme: "Nuke me!"};
  // Make this record 1 min old, thus older than the one on the server
  engine._tracker.addChangedID("newerserver", Date.now() / 1000 - 60);
  // This record has been changed 2 mins later than the one on the server
  engine._tracker.addChangedID("olderidentical", Date.now() / 1000);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    // Confirm initial environment
    Assert.equal(engine._store.items.newrecord, undefined);
    Assert.equal(engine._store.items.newerserver, "New data, but not as new as server!");
    Assert.equal(engine._store.items.olderidentical, "Older but identical");
    Assert.equal(engine._store.items.updateclient, "Got data?");
    Assert.equal(engine._store.items.nukeme, "Nuke me!");
    Assert.ok(engine._tracker.changedIDs.olderidentical > 0);

    await engine._syncStartup();
    await engine._processIncoming();

    // Timestamps of last sync and last server modification are set.
    Assert.ok(engine.lastSync > 0);
    Assert.ok(engine.lastModified > 0);

    // The new record is created.
    Assert.equal(engine._store.items.newrecord, "New stuff...");

    // The 'newerserver' record is updated since the server data is newer.
    Assert.equal(engine._store.items.newerserver, "New data!");

    // The data for 'olderidentical' is identical on the server, so
    // it's no longer marked as changed anymore.
    Assert.equal(engine._store.items.olderidentical, "Older but identical");
    Assert.equal(engine._tracker.changedIDs.olderidentical, undefined);

    // Updated with server data.
    Assert.equal(engine._store.items.updateclient, "Get this!");

    // The incoming ID is preferred.
    Assert.equal(engine._store.items.original, undefined);
    Assert.equal(engine._store.items.duplication, "Original Entry");
    Assert.notEqual(engine._delete.ids.indexOf("original"), -1);

    // The 'nukeme' record marked as deleted is removed.
    Assert.equal(engine._store.items.nukeme, undefined);
  } finally {
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_processIncoming_reconcile_local_deleted() {
  _("Ensure local, duplicate ID is deleted on server.");

  // When a duplicate is resolved, the local ID (which is never taken) should
  // be deleted on the server.
  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"});
  let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  record = encryptPayload({id: "DUPE_LOCAL", denomination: "local"});
  wbo = new ServerWBO("DUPE_LOCAL", record, now - 1);
  server.insertWBO(user, "rotary", wbo);

  await engine._store.create({id: "DUPE_LOCAL", denomination: "local"});
  Assert.ok((await engine._store.itemExists("DUPE_LOCAL")));
  Assert.equal("DUPE_LOCAL", (await engine._findDupe({id: "DUPE_INCOMING"})));

  await engine._sync();

  do_check_attribute_count(engine._store.items, 1);
  Assert.ok("DUPE_INCOMING" in engine._store.items);

  let collection = server.getCollection(user, "rotary");
  Assert.equal(1, collection.count());
  Assert.notEqual(undefined, collection.wbo("DUPE_INCOMING"));

  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_reconcile_equivalent() {
  _("Ensure proper handling of incoming records that match local.");

  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  let record = encryptPayload({id: "entry", denomination: "denomination"});
  let wbo = new ServerWBO("entry", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  engine._store.items = {entry: "denomination"};
  Assert.ok((await engine._store.itemExists("entry")));

  await engine._sync();

  do_check_attribute_count(engine._store.items, 1);

  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_reconcile_locally_deleted_dupe_new() {
  _("Ensure locally deleted duplicate record newer than incoming is handled.");

  // This is a somewhat complicated test. It ensures that if a client receives
  // a modified record for an item that is deleted locally but with a different
  // ID that the incoming record is ignored. This is a corner case for record
  // handling, but it needs to be supported.
  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"});
  let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  // Simulate a locally-deleted item.
  engine._store.items = {};
  engine._tracker.addChangedID("DUPE_LOCAL", now + 3);
  Assert.equal(false, (await engine._store.itemExists("DUPE_LOCAL")));
  Assert.equal(false, (await engine._store.itemExists("DUPE_INCOMING")));
  Assert.equal("DUPE_LOCAL", (await engine._findDupe({id: "DUPE_INCOMING"})));

  engine.lastModified = server.getCollection(user, engine.name).timestamp;
  await engine._sync();

  // After the sync, the server's payload for the original ID should be marked
  // as deleted.
  do_check_empty(engine._store.items);
  let collection = server.getCollection(user, "rotary");
  Assert.equal(1, collection.count());
  wbo = collection.wbo("DUPE_INCOMING");
  Assert.notEqual(null, wbo);
  let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
  Assert.ok(payload.deleted);

  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_reconcile_locally_deleted_dupe_old() {
  _("Ensure locally deleted duplicate record older than incoming is restored.");

  // This is similar to the above test except it tests the condition where the
  // incoming record is newer than the local deletion, therefore overriding it.

  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"});
  let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  // Simulate a locally-deleted item.
  engine._store.items = {};
  engine._tracker.addChangedID("DUPE_LOCAL", now + 1);
  Assert.equal(false, (await engine._store.itemExists("DUPE_LOCAL")));
  Assert.equal(false, (await engine._store.itemExists("DUPE_INCOMING")));
  Assert.equal("DUPE_LOCAL", (await engine._findDupe({id: "DUPE_INCOMING"})));

  await engine._sync();

  // Since the remote change is newer, the incoming item should exist locally.
  do_check_attribute_count(engine._store.items, 1);
  Assert.ok("DUPE_INCOMING" in engine._store.items);
  Assert.equal("incoming", engine._store.items.DUPE_INCOMING);

  let collection = server.getCollection(user, "rotary");
  Assert.equal(1, collection.count());
  wbo = collection.wbo("DUPE_INCOMING");
  let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
  Assert.equal("incoming", payload.denomination);

  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_reconcile_changed_dupe() {
  _("Ensure that locally changed duplicate record is handled properly.");

  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  // The local record is newer than the incoming one, so it should be retained.
  let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"});
  let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  await engine._store.create({id: "DUPE_LOCAL", denomination: "local"});
  engine._tracker.addChangedID("DUPE_LOCAL", now + 3);
  Assert.ok((await engine._store.itemExists("DUPE_LOCAL")));
  Assert.equal("DUPE_LOCAL", (await engine._findDupe({id: "DUPE_INCOMING"})));

  engine.lastModified = server.getCollection(user, engine.name).timestamp;
  await engine._sync();

  // The ID should have been changed to incoming.
  do_check_attribute_count(engine._store.items, 1);
  Assert.ok("DUPE_INCOMING" in engine._store.items);

  // On the server, the local ID should be deleted and the incoming ID should
  // have its payload set to what was in the local record.
  let collection = server.getCollection(user, "rotary");
  Assert.equal(1, collection.count());
  wbo = collection.wbo("DUPE_INCOMING");
  Assert.notEqual(undefined, wbo);
  let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
  Assert.equal("local", payload.denomination);

  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_reconcile_changed_dupe_new() {
  _("Ensure locally changed duplicate record older than incoming is ignored.");

  // This test is similar to the above except the incoming record is younger
  // than the local record. The incoming record should be authoritative.
  let [engine, server, user] = await createServerAndConfigureClient();

  let now = Date.now() / 1000 - 10;
  engine.lastSync = now;
  engine.lastModified = now + 1;

  let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"});
  let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2);
  server.insertWBO(user, "rotary", wbo);

  await engine._store.create({id: "DUPE_LOCAL", denomination: "local"});
  engine._tracker.addChangedID("DUPE_LOCAL", now + 1);
  Assert.ok((await engine._store.itemExists("DUPE_LOCAL")));
  Assert.equal("DUPE_LOCAL", (await engine._findDupe({id: "DUPE_INCOMING"})));

  engine.lastModified = server.getCollection(user, engine.name).timestamp;
  await engine._sync();

  // The ID should have been changed to incoming.
  do_check_attribute_count(engine._store.items, 1);
  Assert.ok("DUPE_INCOMING" in engine._store.items);

  // On the server, the local ID should be deleted and the incoming ID should
  // have its payload retained.
  let collection = server.getCollection(user, "rotary");
  Assert.equal(1, collection.count());
  wbo = collection.wbo("DUPE_INCOMING");
  Assert.notEqual(undefined, wbo);
  let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
  Assert.equal("incoming", payload.denomination);
  await cleanAndGo(engine, server);
});

add_task(async function test_processIncoming_resume_toFetch() {
  _("toFetch and previousFailed items left over from previous syncs are fetched on the next sync, along with new items.");

  const LASTSYNC = Date.now() / 1000;

  // Server records that will be downloaded
  let collection = new ServerCollection();
  collection.insert("flying",
                    encryptPayload({id: "flying",
                                    denomination: "LNER Class A3 4472"}));
  collection.insert("scotsman",
                    encryptPayload({id: "scotsman",
                                    denomination: "Flying Scotsman"}));
  collection.insert("rekolok",
                    encryptPayload({id: "rekolok",
                                    denomination: "Rekonstruktionslokomotive"}));
  for (let i = 0; i < 3; i++) {
    let id = "failed" + i;
    let payload = encryptPayload({id, denomination: "Record No. " + i});
    let wbo = new ServerWBO(id, payload);
    wbo.modified = LASTSYNC - 10;
    collection.insertWBO(wbo);
  }

  collection.wbo("flying").modified =
    collection.wbo("scotsman").modified = LASTSYNC - 10;
  collection._wbos.rekolok.modified = LASTSYNC + 10;

  // Time travel 10 seconds into the future but still download the above WBOs.
  let engine = makeRotaryEngine();
  engine.lastSync = LASTSYNC;
  engine.toFetch = ["flying", "scotsman"];
  engine.previousFailed = ["failed0", "failed1", "failed2"];

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};
  try {

    // Confirm initial environment
    Assert.equal(engine._store.items.flying, undefined);
    Assert.equal(engine._store.items.scotsman, undefined);
    Assert.equal(engine._store.items.rekolok, undefined);

    await engine._syncStartup();
    await engine._processIncoming();

    // Local records have been created from the server data.
    Assert.equal(engine._store.items.flying, "LNER Class A3 4472");
    Assert.equal(engine._store.items.scotsman, "Flying Scotsman");
    Assert.equal(engine._store.items.rekolok, "Rekonstruktionslokomotive");
    Assert.equal(engine._store.items.failed0, "Record No. 0");
    Assert.equal(engine._store.items.failed1, "Record No. 1");
    Assert.equal(engine._store.items.failed2, "Record No. 2");
    Assert.equal(engine.previousFailed.length, 0);
  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_notify_count() {
  _("Ensure that failed records are reported only once.");

  const NUMBER_OF_RECORDS = 15;

  // Engine that fails every 5 records.
  let engine = makeRotaryEngine();
  engine._store._applyIncomingBatch = engine._store.applyIncomingBatch;
  engine._store.applyIncomingBatch = async function(records) {
    let sortedRecords = records.sort((a, b) => a.id > b.id ? 1 : -1);
    let recordsToApply = [], recordsToFail = [];
    for (let i = 0; i < sortedRecords.length; i++) {
      (i % 5 === 0 ? recordsToFail : recordsToApply).push(sortedRecords[i]);
    }
    await engine._store._applyIncomingBatch(recordsToApply);
    return recordsToFail.map(record => record.id);
  };

  // Create a batch of server side records.
  let collection = new ServerCollection();
  for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
    let id = "record-no-" + i.toString(10).padStart(2, "0");
    let payload = encryptPayload({id, denomination: "Record No. " + id});
    collection.insert(id, payload);
  }

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};
  try {
    // Confirm initial environment.
    Assert.equal(engine.lastSync, 0);
    Assert.equal(engine.toFetch.length, 0);
    Assert.equal(engine.previousFailed.length, 0);
    do_check_empty(engine._store.items);

    let called = 0;
    let counts;
    function onApplied(count) {
      _("Called with " + JSON.stringify(counts));
      counts = count;
      called++;
    }
    Svc.Obs.add("weave:engine:sync:applied", onApplied);

    // Do sync.
    await engine._syncStartup();
    await engine._processIncoming();

    // Confirm failures.
    do_check_attribute_count(engine._store.items, 12);
    Assert.deepEqual(engine.previousFailed, ["record-no-00", "record-no-05",
      "record-no-10"]);

    // There are newly failed records and they are reported.
    Assert.equal(called, 1);
    Assert.equal(counts.failed, 3);
    Assert.equal(counts.applied, 15);
    Assert.equal(counts.newFailed, 3);
    Assert.equal(counts.succeeded, 12);

    // Sync again, 1 of the failed items are the same, the rest didn't fail.
    await engine._processIncoming();

    // Confirming removed failures.
    do_check_attribute_count(engine._store.items, 14);
    Assert.deepEqual(engine.previousFailed, ["record-no-00"]);

    Assert.equal(called, 2);
    Assert.equal(counts.failed, 1);
    Assert.equal(counts.applied, 3);
    Assert.equal(counts.newFailed, 0);
    Assert.equal(counts.succeeded, 2);

    Svc.Obs.remove("weave:engine:sync:applied", onApplied);
  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_previousFailed() {
  _("Ensure that failed records are retried.");

  const NUMBER_OF_RECORDS = 14;

  // Engine that alternates between failing and applying every 2 records.
  let engine = makeRotaryEngine();
  engine._store._applyIncomingBatch = engine._store.applyIncomingBatch;
  engine._store.applyIncomingBatch = async function(records) {
    let sortedRecords = records.sort((a, b) => a.id > b.id ? 1 : -1);
    let recordsToApply = [], recordsToFail = [];
    let chunks = Array.from(PlacesSyncUtils.chunkArray(sortedRecords, 2));
    for (let i = 0; i < chunks.length; i++) {
      (i % 2 === 0 ? recordsToFail : recordsToApply).push(...chunks[i]);
    }
    await engine._store._applyIncomingBatch(recordsToApply);
    return recordsToFail.map(record => record.id);
  };

  // Create a batch of server side records.
  let collection = new ServerCollection();
  for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
    let id = "record-no-" + i.toString(10).padStart(2, "0");
    let payload = encryptPayload({id, denomination: "Record No. " + i});
    collection.insert(id, payload);
  }

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};
  try {
    // Confirm initial environment.
    Assert.equal(engine.lastSync, 0);
    Assert.equal(engine.toFetch.length, 0);
    Assert.equal(engine.previousFailed.length, 0);
    do_check_empty(engine._store.items);

    // Initial failed items in previousFailed to be reset.
    let previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()];
    engine.previousFailed = previousFailed;
    Assert.equal(engine.previousFailed, previousFailed);

    // Do sync.
    await engine._syncStartup();
    await engine._processIncoming();

    // Expected result: 4 sync batches with 2 failures each => 8 failures
    do_check_attribute_count(engine._store.items, 6);
    Assert.deepEqual(engine.previousFailed, ["record-no-00", "record-no-01",
      "record-no-04", "record-no-05", "record-no-08", "record-no-09",
      "record-no-12", "record-no-13"]);

    // Sync again with the same failed items (records 0, 1, 8, 9).
    await engine._processIncoming();

    // A second sync with the same failed items should not add the same items again.
    // Items that did not fail a second time should no longer be in previousFailed.
    do_check_attribute_count(engine._store.items, 10);
    Assert.deepEqual(engine.previousFailed, ["record-no-00", "record-no-01",
      "record-no-08", "record-no-09"]);

    // Refetched items that didn't fail the second time are in engine._store.items.
    Assert.equal(engine._store.items["record-no-04"], "Record No. 4");
    Assert.equal(engine._store.items["record-no-05"], "Record No. 5");
    Assert.equal(engine._store.items["record-no-12"], "Record No. 12");
    Assert.equal(engine._store.items["record-no-13"], "Record No. 13");
  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_failed_records() {
  _("Ensure that failed records from _reconcile and applyIncomingBatch are refetched.");

  // Let's create three and a bit batches worth of server side records.
  let APPLY_BATCH_SIZE = 50;
  let collection = new ServerCollection();
  const NUMBER_OF_RECORDS = APPLY_BATCH_SIZE * 3 + 5;
  for (let i = 0; i < NUMBER_OF_RECORDS; i++) {
    let id = "record-no-" + i;
    let payload = encryptPayload({id, denomination: "Record No. " + id});
    let wbo = new ServerWBO(id, payload);
    wbo.modified = Date.now() / 1000 + 60 * (i - APPLY_BATCH_SIZE * 3);
    collection.insertWBO(wbo);
  }

  // Engine that batches but likes to throw on a couple of records,
  // two in each batch: the even ones fail in reconcile, the odd ones
  // in applyIncoming.
  const BOGUS_RECORDS = ["record-no-" + 42,
                         "record-no-" + 23,
                         "record-no-" + (42 + APPLY_BATCH_SIZE),
                         "record-no-" + (23 + APPLY_BATCH_SIZE),
                         "record-no-" + (42 + APPLY_BATCH_SIZE * 2),
                         "record-no-" + (23 + APPLY_BATCH_SIZE * 2),
                         "record-no-" + (2 + APPLY_BATCH_SIZE * 3),
                         "record-no-" + (1 + APPLY_BATCH_SIZE * 3)];
  let engine = makeRotaryEngine();

  engine.__reconcile = engine._reconcile;
  engine._reconcile = async function _reconcile(record) {
    if (BOGUS_RECORDS.indexOf(record.id) % 2 == 0) {
      throw new Error("I don't like this record! Baaaaaah!");
    }
    return this.__reconcile.apply(this, arguments);
  };
  engine._store._applyIncoming = engine._store.applyIncoming;
  engine._store.applyIncoming = async function(record) {
    if (BOGUS_RECORDS.indexOf(record.id) % 2 == 1) {
      throw new Error("I don't like this record! Baaaaaah!");
    }
    return this._applyIncoming.apply(this, arguments);
  };

  // Keep track of requests made of a collection.
  let count = 0;
  let uris  = [];
  function recording_handler(recordedCollection) {
    let h = recordedCollection.handler();
    return function(req, res) {
      ++count;
      uris.push(req.path + "?" + req.queryString);
      return h(req, res);
    };
  }
  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": recording_handler(collection)
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    // Confirm initial environment
    Assert.equal(engine.lastSync, 0);
    Assert.equal(engine.toFetch.length, 0);
    Assert.equal(engine.previousFailed.length, 0);
    do_check_empty(engine._store.items);

    let observerSubject;
    let observerData;
    Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) {
      Svc.Obs.remove("weave:engine:sync:applied", onApplied);
      observerSubject = subject;
      observerData = data;
    });

    await engine._syncStartup();
    await engine._processIncoming();

    // Ensure that all records but the bogus 4 have been applied.
    do_check_attribute_count(engine._store.items,
                             NUMBER_OF_RECORDS - BOGUS_RECORDS.length);

    // Ensure that the bogus records will be fetched again on the next sync.
    Assert.equal(engine.previousFailed.length, BOGUS_RECORDS.length);
    Assert.deepEqual(engine.previousFailed.sort(), BOGUS_RECORDS.sort());

    // Ensure the observer was notified
    Assert.equal(observerData, engine.name);
    Assert.equal(observerSubject.failed, BOGUS_RECORDS.length);
    Assert.equal(observerSubject.newFailed, BOGUS_RECORDS.length);

    // Testing batching of failed item fetches.
    // Try to sync again. Ensure that we split the request into chunks to avoid
    // URI length limitations.
    async function batchDownload(batchSize) {
      count = 0;
      uris  = [];
      engine.guidFetchBatchSize = batchSize;
      await engine._processIncoming();
      _("Tried again. Requests: " + count + "; URIs: " + JSON.stringify(uris));
      return count;
    }

    // There are 8 bad records, so this needs 3 fetches.
    _("Test batching with ID batch size 3, normal mobile batch size.");
    Assert.equal((await batchDownload(3)), 3);

    // Now see with a more realistic limit.
    _("Test batching with sufficient ID batch size.");
    Assert.equal((await batchDownload(BOGUS_RECORDS.length)), 1);
  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_processIncoming_decrypt_failed() {
  _("Ensure that records failing to decrypt are either replaced or refetched.");

  // Some good and some bogus records. One doesn't contain valid JSON,
  // the other will throw during decrypt.
  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO(
      "flying", encryptPayload({id: "flying",
                                denomination: "LNER Class A3 4472"}));
  collection._wbos.nojson = new ServerWBO("nojson", "This is invalid JSON");
  collection._wbos.nojson2 = new ServerWBO("nojson2", "This is invalid JSON");
  collection._wbos.scotsman = new ServerWBO(
      "scotsman", encryptPayload({id: "scotsman",
                                  denomination: "Flying Scotsman"}));
  collection._wbos.nodecrypt = new ServerWBO("nodecrypt", "Decrypt this!");
  collection._wbos.nodecrypt2 = new ServerWBO("nodecrypt2", "Decrypt this!");

  // Patch the fake crypto service to throw on the record above.
  Weave.Crypto._decrypt = Weave.Crypto.decrypt;
  Weave.Crypto.decrypt = function(ciphertext) {
    if (ciphertext == "Decrypt this!") {
      throw new Error(
          "Derp! Cipher finalized failed. Im ur crypto destroyin ur recordz.");
    }
    return this._decrypt.apply(this, arguments);
  };

  // Some broken records also exist locally.
  let engine = makeRotaryEngine();
  engine.enabled = true;
  engine._store.items = {nojson: "Valid JSON",
                         nodecrypt: "Valid ciphertext"};

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};
  try {

    // Confirm initial state
    Assert.equal(engine.toFetch.length, 0);
    Assert.equal(engine.previousFailed.length, 0);

    let observerSubject;
    let observerData;
    Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) {
      Svc.Obs.remove("weave:engine:sync:applied", onApplied);
      observerSubject = subject;
      observerData = data;
    });

    engine.lastSync = collection.wbo("nojson").modified - 1;
    let ping = await sync_engine_and_validate_telem(engine, true);
    Assert.equal(ping.engines[0].incoming.applied, 2);
    Assert.equal(ping.engines[0].incoming.failed, 4);
    Assert.equal(ping.engines[0].incoming.newFailed, 4);

    Assert.equal(engine.previousFailed.length, 4);
    Assert.equal(engine.previousFailed[0], "nojson");
    Assert.equal(engine.previousFailed[1], "nojson2");
    Assert.equal(engine.previousFailed[2], "nodecrypt");
    Assert.equal(engine.previousFailed[3], "nodecrypt2");

    // Ensure the observer was notified
    Assert.equal(observerData, engine.name);
    Assert.equal(observerSubject.applied, 2);
    Assert.equal(observerSubject.failed, 4);

  } finally {
    await promiseClean(engine, server);
  }
});


add_task(async function test_uploadOutgoing_toEmptyServer() {
  _("SyncEngine._uploadOutgoing uploads new records to server");

  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO("flying");
  collection._wbos.scotsman = new ServerWBO("scotsman");

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler(),
      "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(),
      "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler()
  });

  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let engine = makeRotaryEngine();
  engine.lastSync = 123; // needs to be non-zero so that tracker is queried
  engine._store.items = {flying: "LNER Class A3 4472",
                         scotsman: "Flying Scotsman"};
  // Mark one of these records as changed
  engine._tracker.addChangedID("scotsman", 0);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    // Confirm initial environment
    Assert.equal(engine.lastSyncLocal, 0);
    Assert.equal(collection.payload("flying"), undefined);
    Assert.equal(collection.payload("scotsman"), undefined);

    await engine._syncStartup();
    await engine._uploadOutgoing();

    // Local timestamp has been set.
    Assert.ok(engine.lastSyncLocal > 0);

    // Ensure the marked record ('scotsman') has been uploaded and is
    // no longer marked.
    Assert.equal(collection.payload("flying"), undefined);
    Assert.ok(!!collection.payload("scotsman"));
    Assert.equal(JSON.parse(collection.wbo("scotsman").data.ciphertext).id,
                 "scotsman");
    Assert.equal(engine._tracker.changedIDs.scotsman, undefined);

    // The 'flying' record wasn't marked so it wasn't uploaded
    Assert.equal(collection.payload("flying"), undefined);

  } finally {
    await cleanAndGo(engine, server);
  }
});

async function test_uploadOutgoing_max_record_payload_bytes(allowSkippedRecord) {
  _("SyncEngine._uploadOutgoing throws when payload is bigger than max_record_payload_bytes");
  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO("flying");
  collection._wbos.scotsman = new ServerWBO("scotsman");

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler(),
      "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(),
      "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler(),
  });

  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let engine = makeRotaryEngine();
  engine.allowSkippedRecord = allowSkippedRecord;
  engine.lastSync = 1;
  engine._store.items = { flying: "a".repeat(1024 * 1024), scotsman: "abcd" };

  engine._tracker.addChangedID("flying", 1000);
  engine._tracker.addChangedID("scotsman", 1000);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {
    // Confirm initial environment
    Assert.equal(engine.lastSyncLocal, 0);
    Assert.equal(collection.payload("flying"), undefined);
    Assert.equal(collection.payload("scotsman"), undefined);

    await engine._syncStartup();
    await engine._uploadOutgoing();

    if (!allowSkippedRecord) {
      do_throw("should not get here");
    }

    await engine.trackRemainingChanges();

    // Check we uploaded the other record to the server
    Assert.ok(collection.payload("scotsman"));
    // And that we won't try to upload the huge record next time.
    Assert.equal(engine._tracker.changedIDs.flying, undefined);

  } catch (e) {
    if (allowSkippedRecord) {
      do_throw("should not get here");
    }

    await engine.trackRemainingChanges();

    // Check that we will try to upload the huge record next time
    Assert.equal(engine._tracker.changedIDs.flying, 1000);
  } finally {
    // Check we didn't upload the oversized record to the server
    Assert.equal(collection.payload("flying"), undefined);
    await cleanAndGo(engine, server);
  }
}


add_task(async function test_uploadOutgoing_max_record_payload_bytes_disallowSkippedRecords() {
  return test_uploadOutgoing_max_record_payload_bytes(false);
});


add_task(async function test_uploadOutgoing_max_record_payload_bytes_allowSkippedRecords() {
  return test_uploadOutgoing_max_record_payload_bytes(true);
});


add_task(async function test_uploadOutgoing_failed() {
  _("SyncEngine._uploadOutgoing doesn't clear the tracker of objects that failed to upload.");

  let collection = new ServerCollection();
  // We only define the "flying" WBO on the server, not the "scotsman"
  // and "peppercorn" ones.
  collection._wbos.flying = new ServerWBO("flying");

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  engine.lastSync = 123; // needs to be non-zero so that tracker is queried
  engine._store.items = {flying: "LNER Class A3 4472",
                         scotsman: "Flying Scotsman",
                         peppercorn: "Peppercorn Class"};
  // Mark these records as changed
  const FLYING_CHANGED = 12345;
  const SCOTSMAN_CHANGED = 23456;
  const PEPPERCORN_CHANGED = 34567;
  engine._tracker.addChangedID("flying", FLYING_CHANGED);
  engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED);
  engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    // Confirm initial environment
    Assert.equal(engine.lastSyncLocal, 0);
    Assert.equal(collection.payload("flying"), undefined);
    Assert.equal(engine._tracker.changedIDs.flying, FLYING_CHANGED);
    Assert.equal(engine._tracker.changedIDs.scotsman, SCOTSMAN_CHANGED);
    Assert.equal(engine._tracker.changedIDs.peppercorn, PEPPERCORN_CHANGED);

    engine.enabled = true;
    await sync_engine_and_validate_telem(engine, true);

    // Local timestamp has been set.
    Assert.ok(engine.lastSyncLocal > 0);

    // Ensure the 'flying' record has been uploaded and is no longer marked.
    Assert.ok(!!collection.payload("flying"));
    Assert.equal(engine._tracker.changedIDs.flying, undefined);

    // The 'scotsman' and 'peppercorn' records couldn't be uploaded so
    // they weren't cleared from the tracker.
    Assert.equal(engine._tracker.changedIDs.scotsman, SCOTSMAN_CHANGED);
    Assert.equal(engine._tracker.changedIDs.peppercorn, PEPPERCORN_CHANGED);

  } finally {
    await promiseClean(engine, server);
  }
});

async function createRecordFailTelemetry(allowSkippedRecord) {
  Service.identity.username = "foo";
  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO("flying");
  collection._wbos.scotsman = new ServerWBO("scotsman");

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  engine.allowSkippedRecord = allowSkippedRecord;
  let oldCreateRecord = engine._store.createRecord;
  engine._store.createRecord = async (id, col) => {
    if (id != "flying") {
      throw new Error("oops");
    }
    return oldCreateRecord.call(engine._store, id, col);
  };
  engine.lastSync = 123; // needs to be non-zero so that tracker is queried
  engine._store.items = {flying: "LNER Class A3 4472",
                         scotsman: "Flying Scotsman"};
  // Mark these records as changed
  const FLYING_CHANGED = 12345;
  const SCOTSMAN_CHANGED = 23456;
  engine._tracker.addChangedID("flying", FLYING_CHANGED);
  engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  let ping;
  try {
    // Confirm initial environment
    Assert.equal(engine.lastSyncLocal, 0);
    Assert.equal(collection.payload("flying"), undefined);
    Assert.equal(engine._tracker.changedIDs.flying, FLYING_CHANGED);
    Assert.equal(engine._tracker.changedIDs.scotsman, SCOTSMAN_CHANGED);

    engine.enabled = true;
    ping = await sync_engine_and_validate_telem(engine, true, onErrorPing => {
      ping = onErrorPing;
    });

    if (!allowSkippedRecord) {
      do_throw("should not get here");
    }

    // Ensure the 'flying' record has been uploaded and is no longer marked.
    Assert.ok(!!collection.payload("flying"));
    Assert.equal(engine._tracker.changedIDs.flying, undefined);
  } catch (err) {
    if (allowSkippedRecord) {
      do_throw("should not get here");
    }

    // Ensure the 'flying' record has not been uploaded and is still marked
    Assert.ok(!collection.payload("flying"));
    Assert.ok(engine._tracker.changedIDs.flying);
  } finally {
    // Local timestamp has been set.
    Assert.ok(engine.lastSyncLocal > 0);

    // We reported in telemetry that we failed a record
    Assert.equal(ping.engines[0].outgoing[0].failed, 1);

    // In any case, the 'scotsman' record couldn't be created so it wasn't
    // uploaded nor it was not cleared from the tracker.
    Assert.ok(!collection.payload("scotsman"));
    Assert.equal(engine._tracker.changedIDs.scotsman, SCOTSMAN_CHANGED);

    engine._store.createRecord = oldCreateRecord;
    await promiseClean(engine, server);
  }
}

add_task(async function test_uploadOutgoing_createRecord_throws_reported_telemetry() {
  _("SyncEngine._uploadOutgoing reports a failed record to telemetry if createRecord throws");
  await createRecordFailTelemetry(true);
});

add_task(async function test_uploadOutgoing_createRecord_throws_dontAllowSkipRecord() {
  _("SyncEngine._uploadOutgoing will throw if createRecord throws and allowSkipRecord is set to false");
  await createRecordFailTelemetry(false);
});

add_task(async function test_uploadOutgoing_largeRecords() {
  _("SyncEngine._uploadOutgoing throws on records larger than the max record payload size");

  let collection = new ServerCollection();

  let engine = makeRotaryEngine();
  engine.allowSkippedRecord = false;
  engine._store.items["large-item"] = "Y".repeat(Service.getMaxRecordPayloadSize() * 2);
  engine._tracker.addChangedID("large-item", 0);
  collection.insert("large-item");


  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  try {
    await engine._syncStartup();
    let error = null;
    try {
      await engine._uploadOutgoing();
    } catch (e) {
      error = e;
    }
    ok(!!error);
  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_syncFinish_deleteByIds() {
  _("SyncEngine._syncFinish deletes server records slated for deletion (list of record IDs).");

  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO(
      "flying", encryptPayload({id: "flying",
                                denomination: "LNER Class A3 4472"}));
  collection._wbos.scotsman = new ServerWBO(
      "scotsman", encryptPayload({id: "scotsman",
                                  denomination: "Flying Scotsman"}));
  collection._wbos.rekolok = new ServerWBO(
      "rekolok", encryptPayload({id: "rekolok",
                                denomination: "Rekonstruktionslokomotive"}));

  let server = httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });
  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  try {
    engine._delete = {ids: ["flying", "rekolok"]};
    await engine._syncFinish();

    // The 'flying' and 'rekolok' records were deleted while the
    // 'scotsman' one wasn't.
    Assert.equal(collection.payload("flying"), undefined);
    Assert.ok(!!collection.payload("scotsman"));
    Assert.equal(collection.payload("rekolok"), undefined);

    // The deletion todo list has been reset.
    Assert.equal(engine._delete.ids, undefined);

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_syncFinish_deleteLotsInBatches() {
  _("SyncEngine._syncFinish deletes server records in batches of 100 (list of record IDs).");

  let collection = new ServerCollection();

  // Let's count how many times the client does a DELETE request to the server
  var noOfUploads = 0;
  collection.delete = (function(orig) {
    return function() {
      noOfUploads++;
      return orig.apply(this, arguments);
    };
  }(collection.delete));

  // Create a bunch of records on the server
  let now = Date.now();
  for (var i = 0; i < 234; i++) {
    let id = "record-no-" + i;
    let payload = encryptPayload({id, denomination: "Record No. " + i});
    let wbo = new ServerWBO(id, payload);
    wbo.modified = now / 1000 - 60 * (i + 110);
    collection.insertWBO(wbo);
  }

  let server = httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let engine = makeRotaryEngine();
  try {

    // Confirm initial environment
    Assert.equal(noOfUploads, 0);

    // Declare what we want to have deleted: all records no. 100 and
    // up and all records that are less than 200 mins old (which are
    // records 0 thru 90).
    engine._delete = {ids: [],
                      newer: now / 1000 - 60 * 200.5};
    for (i = 100; i < 234; i++) {
      engine._delete.ids.push("record-no-" + i);
    }

    await engine._syncFinish();

    // Ensure that the appropriate server data has been wiped while
    // preserving records 90 thru 200.
    for (i = 0; i < 234; i++) {
      let id = "record-no-" + i;
      if (i <= 90 || i >= 100) {
        Assert.equal(collection.payload(id), undefined);
      } else {
        Assert.ok(!!collection.payload(id));
      }
    }

    // The deletion was done in batches
    Assert.equal(noOfUploads, 2 + 1);

    // The deletion todo list has been reset.
    Assert.equal(engine._delete.ids, undefined);

  } finally {
    await cleanAndGo(engine, server);
  }
});


add_task(async function test_sync_partialUpload() {
  _("SyncEngine.sync() keeps changedIDs that couldn't be uploaded.");

  let collection = new ServerCollection();
  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });
  let oldServerConfiguration = Service.serverConfiguration;
  Service.serverConfiguration = {
    max_post_records: 100
  };
  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let engine = makeRotaryEngine();
  engine.lastSync = 123; // needs to be non-zero so that tracker is queried
  engine.lastSyncLocal = 456;

  // Let the third upload fail completely
  var noOfUploads = 0;
  collection.post = (function(orig) {
    return function() {
      if (noOfUploads == 2)
        throw new Error("FAIL!");
      noOfUploads++;
      return orig.apply(this, arguments);
    };
  }(collection.post));

  // Create a bunch of records (and server side handlers)
  for (let i = 0; i < 234; i++) {
    let id = "record-no-" + i;
    engine._store.items[id] = "Record No. " + i;
    engine._tracker.addChangedID(id, i);
    // Let two items in the first upload batch fail.
    if ((i != 23) && (i != 42)) {
      collection.insert(id);
    }
  }

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  try {

    engine.enabled = true;
    let error;
    try {
      await sync_engine_and_validate_telem(engine, true);
    } catch (ex) {
      error = ex;
    }

    ok(!!error);

    // The timestamp has been updated.
    Assert.ok(engine.lastSyncLocal > 456);

    for (let i = 0; i < 234; i++) {
      let id = "record-no-" + i;
      // Ensure failed records are back in the tracker:
      // * records no. 23 and 42 were rejected by the server,
      // * records after the third batch and higher couldn't be uploaded because
      //   we failed hard on the 3rd upload.
      if ((i == 23) || (i == 42) || (i >= 200))
        Assert.equal(engine._tracker.changedIDs[id], i);
      else
        Assert.equal(false, id in engine._tracker.changedIDs);
    }

  } finally {
    Service.serverConfiguration = oldServerConfiguration;
    await promiseClean(engine, server);
  }
});

add_task(async function test_canDecrypt_noCryptoKeys() {
  _("SyncEngine.canDecrypt returns false if the engine fails to decrypt items on the server, e.g. due to a missing crypto key collection.");

  // Wipe collection keys so we can test the desired scenario.
  Service.collectionKeys.clear();

  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO(
      "flying", encryptPayload({id: "flying",
                                denomination: "LNER Class A3 4472"}));

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);
  let engine = makeRotaryEngine();
  try {

    Assert.equal(false, (await engine.canDecrypt()));

  } finally {
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_canDecrypt_true() {
  _("SyncEngine.canDecrypt returns true if the engine can decrypt the items on the server.");

  await generateNewKeys(Service.collectionKeys);

  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO(
      "flying", encryptPayload({id: "flying",
                                denomination: "LNER Class A3 4472"}));

  let server = sync_httpd_setup({
      "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);
  let engine = makeRotaryEngine();
  try {

    Assert.ok((await engine.canDecrypt()));

  } finally {
    await cleanAndGo(engine, server);
  }

});

add_task(async function test_syncapplied_observer() {
  const NUMBER_OF_RECORDS = 10;

  let engine = makeRotaryEngine();

  // Create a batch of server side records.
  let collection = new ServerCollection();
  for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
    let id = "record-no-" + i;
    let payload = encryptPayload({id, denomination: "Record No. " + id});
    collection.insert(id, payload);
  }

  let server = httpd_setup({
    "/1.1/foo/storage/rotary": collection.handler()
  });

  await SyncTestingInfrastructure(server);

  let meta_global = Service.recordManager.set(engine.metaURL,
                                              new WBORecord(engine.metaURL));
  meta_global.payload.engines = {rotary: {version: engine.version,
                                         syncID: engine.syncID}};

  let numApplyCalls = 0;
  let engine_name;
  let count;
  function onApplied(subject, data) {
    numApplyCalls++;
    engine_name = data;
    count = subject;
  }

  Svc.Obs.add("weave:engine:sync:applied", onApplied);

  try {
    Service.scheduler.hasIncomingItems = false;

    // Do sync.
    await engine._syncStartup();
    await engine._processIncoming();

    do_check_attribute_count(engine._store.items, 10);

    Assert.equal(numApplyCalls, 1);
    Assert.equal(engine_name, "rotary");
    Assert.equal(count.applied, 10);

    Assert.ok(Service.scheduler.hasIncomingItems);
  } finally {
    await cleanAndGo(engine, server);
    Service.scheduler.hasIncomingItems = false;
    Svc.Obs.remove("weave:engine:sync:applied", onApplied);
  }
});
