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

const { ClientEngine, ClientsRec } = ChromeUtils.importESModule(
  "resource://services-sync/engines/clients.sys.mjs"
);
const { CryptoWrapper } = ChromeUtils.importESModule(
  "resource://services-sync/record.sys.mjs"
);
const { Service } = ChromeUtils.importESModule(
  "resource://services-sync/service.sys.mjs"
);

const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days
const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day

let engine;

/**
 * Unpack the record with this ID, and verify that it has the same version that
 * we should be putting into records.
 */
async function check_record_version(user, id) {
  let payload = user.collection("clients").wbo(id).data;

  let rec = new CryptoWrapper();
  rec.id = id;
  rec.collection = "clients";
  rec.ciphertext = payload.ciphertext;
  rec.hmac = payload.hmac;
  rec.IV = payload.IV;

  let cleartext = await rec.decrypt(
    Service.collectionKeys.keyForCollection("clients")
  );

  _("Payload is " + JSON.stringify(cleartext));
  equal(Services.appinfo.version, cleartext.version);
  equal(1, cleartext.protocols.length);
  equal("1.5", cleartext.protocols[0]);
}

// compare 2 different command arrays, taking into account that a flowID
// attribute must exist, be unique in the commands, but isn't specified in
// "expected" as the value isn't known.
function compareCommands(actual, expected, description) {
  let tweakedActual = JSON.parse(JSON.stringify(actual));
  tweakedActual.map(elt => delete elt.flowID);
  deepEqual(tweakedActual, expected, description);
  // each item must have a unique flowID.
  let allIDs = new Set(actual.map(elt => elt.flowID).filter(fid => !!fid));
  equal(allIDs.size, actual.length, "all items have unique IDs");
}

async function syncClientsEngine(server) {
  engine._lastFxADevicesFetch = 0;
  engine.lastModified = server.getCollection("foo", "clients").timestamp;
  await engine._sync();
}

add_task(async function setup() {
  engine = Service.clientsEngine;
});

async function cleanup() {
  for (const pref of Svc.PrefBranch.getChildList("")) {
    Svc.PrefBranch.clearUserPref(pref);
  }
  await engine._tracker.clearChangedIDs();
  await engine._resetClient();
  // un-cleanup the logs (the resetBranch will have reset their levels), since
  // not all the tests use SyncTestingInfrastructure, and it's cheap.
  syncTestLogging();
  // We don't finalize storage at cleanup, since we use the same clients engine
  // instance across all tests.
}

add_task(async function test_bad_hmac() {
  _("Ensure that Clients engine deletes corrupt records.");
  let deletedCollections = [];
  let deletedItems = [];
  let callback = {
    onItemDeleted(username, coll, wboID) {
      deletedItems.push(coll + "/" + wboID);
    },
    onCollectionDeleted(username, coll) {
      deletedCollections.push(coll);
    },
  };
  Object.setPrototypeOf(callback, SyncServerCallback);
  let server = await serverForFoo(engine, callback);
  let user = server.user("foo");

  function check_clients_count(expectedCount) {
    let coll = user.collection("clients");

    // Treat a non-existent collection as empty.
    equal(expectedCount, coll ? coll.count() : 0);
  }

  function check_client_deleted(id) {
    let coll = user.collection("clients");
    let wbo = coll.wbo(id);
    return !wbo || !wbo.payload;
  }

  async function uploadNewKeys() {
    await generateNewKeys(Service.collectionKeys);
    let serverKeys = Service.collectionKeys.asWBO("crypto", "keys");
    await serverKeys.encrypt(Service.identity.syncKeyBundle);
    ok(
      (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success
    );
  }

  try {
    await configureIdentity({ username: "foo" }, server);
    await Service.login();

    await generateNewKeys(Service.collectionKeys);

    _("First sync, client record is uploaded");
    equal(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    check_clients_count(0);
    await syncClientsEngine(server);
    check_clients_count(1);
    ok(engine.lastRecordUpload > 0);
    ok(!engine.isFirstSync);

    // Our uploaded record has a version.
    await check_record_version(user, engine.localID);

    // Initial setup can wipe the server, so clean up.
    deletedCollections = [];
    deletedItems = [];

    _("Change our keys and our client ID, reupload keys.");
    let oldLocalID = engine.localID; // Preserve to test for deletion!
    engine.localID = Utils.makeGUID();
    await engine.resetClient();
    await generateNewKeys(Service.collectionKeys);
    let serverKeys = Service.collectionKeys.asWBO("crypto", "keys");
    await serverKeys.encrypt(Service.identity.syncKeyBundle);
    ok(
      (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success
    );

    _("Sync.");
    await syncClientsEngine(server);

    _("Old record " + oldLocalID + " was deleted, new one uploaded.");
    check_clients_count(1);
    check_client_deleted(oldLocalID);

    _(
      "Now change our keys but don't upload them. " +
        "That means we get an HMAC error but redownload keys."
    );
    Service.lastHMACEvent = 0;
    engine.localID = Utils.makeGUID();
    await engine.resetClient();
    await generateNewKeys(Service.collectionKeys);
    deletedCollections = [];
    deletedItems = [];
    check_clients_count(1);
    await syncClientsEngine(server);

    _("Old record was not deleted, new one uploaded.");
    equal(deletedCollections.length, 0);
    equal(deletedItems.length, 0);
    check_clients_count(2);

    _(
      "Now try the scenario where our keys are wrong *and* there's a bad record."
    );
    // Clean up and start fresh.
    user.collection("clients")._wbos = {};
    Service.lastHMACEvent = 0;
    engine.localID = Utils.makeGUID();
    await engine.resetClient();
    deletedCollections = [];
    deletedItems = [];
    check_clients_count(0);

    await uploadNewKeys();

    // Sync once to upload a record.
    await syncClientsEngine(server);
    check_clients_count(1);

    // Generate and upload new keys, so the old client record is wrong.
    await uploadNewKeys();

    // Create a new client record and new keys. Now our keys are wrong, as well
    // as the object on the server. We'll download the new keys and also delete
    // the bad client record.
    oldLocalID = engine.localID; // Preserve to test for deletion!
    engine.localID = Utils.makeGUID();
    await engine.resetClient();
    await generateNewKeys(Service.collectionKeys);
    let oldKey = Service.collectionKeys.keyForCollection();

    equal(deletedCollections.length, 0);
    equal(deletedItems.length, 0);
    await syncClientsEngine(server);
    equal(deletedItems.length, 1);
    check_client_deleted(oldLocalID);
    check_clients_count(1);
    let newKey = Service.collectionKeys.keyForCollection();
    ok(!oldKey.equals(newKey));
  } finally {
    await cleanup();
    await promiseStopServer(server);
  }
});

add_task(async function test_properties() {
  _("Test lastRecordUpload property");
  try {
    equal(
      Svc.PrefBranch.getPrefType("clients.lastRecordUpload"),
      Ci.nsIPrefBranch.PREF_INVALID
    );
    equal(engine.lastRecordUpload, 0);

    let now = Date.now();
    engine.lastRecordUpload = now / 1000;
    equal(engine.lastRecordUpload, Math.floor(now / 1000));
  } finally {
    await cleanup();
  }
});

add_task(async function test_full_sync() {
  _("Ensure that Clients engine fetches all records for each sync.");

  let now = new_timestamp();
  let server = await serverForFoo(engine);
  let user = server.user("foo");

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

  let activeID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: activeID,
      name: "Active client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  let deletedID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: deletedID,
      name: "Client to delete",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    let store = engine._store;

    _("First sync. 2 records downloaded; our record uploaded.");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);
    ok(engine.lastRecordUpload > 0);
    ok(!engine.isFirstSync);
    deepEqual(
      user.collection("clients").keys().sort(),
      [activeID, deletedID, engine.localID].sort(),
      "Our record should be uploaded on first sync"
    );
    let ids = await store.getAllIDs();
    deepEqual(
      Object.keys(ids).sort(),
      [activeID, deletedID, engine.localID].sort(),
      "Other clients should be downloaded on first sync"
    );

    _("Delete a record, then sync again");
    let collection = server.getCollection("foo", "clients");
    collection.remove(deletedID);
    // Simulate a timestamp update in info/collections.
    await syncClientsEngine(server);

    _("Record should be updated");
    ids = await store.getAllIDs();
    deepEqual(
      Object.keys(ids).sort(),
      [activeID, engine.localID].sort(),
      "Deleted client should be removed on next sync"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_sync() {
  _("Ensure that Clients engine uploads a new client record once a week.");

  let server = await serverForFoo(engine);
  let user = server.user("foo");

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

  function clientWBO() {
    return user.collection("clients").wbo(engine.localID);
  }

  try {
    _("First sync. Client record is uploaded.");
    equal(clientWBO(), undefined);
    equal(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);
    ok(!!clientWBO().payload);
    ok(engine.lastRecordUpload > 0);
    ok(!engine.isFirstSync);

    _(
      "Let's time travel more than a week back, new record should've been uploaded."
    );
    engine.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH;
    let lastweek = engine.lastRecordUpload;
    clientWBO().payload = undefined;
    await syncClientsEngine(server);
    ok(!!clientWBO().payload);
    ok(engine.lastRecordUpload > lastweek);
    ok(!engine.isFirstSync);

    _("Remove client record.");
    await engine.removeClientData();
    equal(clientWBO().payload, undefined);

    _("Time travel one day back, no record uploaded.");
    engine.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH;
    let yesterday = engine.lastRecordUpload;
    await syncClientsEngine(server);
    equal(clientWBO().payload, undefined);
    equal(engine.lastRecordUpload, yesterday);
    ok(!engine.isFirstSync);
  } finally {
    await cleanup();
    await promiseStopServer(server);
  }
});

add_task(async function test_client_name_change() {
  _("Ensure client name change incurs a client record update.");

  let tracker = engine._tracker;

  engine.localID; // Needed to increase the tracker changedIDs count.
  let initialName = engine.localName;

  tracker.start();
  _("initial name: " + initialName);

  // Tracker already has data, so clear it.
  await tracker.clearChangedIDs();

  let initialScore = tracker.score;

  let changedIDs = await tracker.getChangedIDs();
  equal(Object.keys(changedIDs).length, 0);

  Services.prefs.setStringPref(
    "identity.fxaccounts.account.device.name",
    "new name"
  );
  await tracker.asyncObserver.promiseObserversComplete();

  _("new name: " + engine.localName);
  notEqual(initialName, engine.localName);
  changedIDs = await tracker.getChangedIDs();
  equal(Object.keys(changedIDs).length, 1);
  ok(engine.localID in changedIDs);
  ok(tracker.score > initialScore);
  ok(tracker.score >= SCORE_INCREMENT_XLARGE);

  await tracker.stop();

  await cleanup();
});

add_task(async function test_fxa_device_id_change() {
  _("Ensure an FxA device ID change incurs a client record update.");

  let tracker = engine._tracker;

  engine.localID; // Needed to increase the tracker changedIDs count.

  tracker.start();

  // Tracker already has data, so clear it.
  await tracker.clearChangedIDs();

  let initialScore = tracker.score;

  let changedIDs = await tracker.getChangedIDs();
  equal(Object.keys(changedIDs).length, 0);

  Services.obs.notifyObservers(null, "fxaccounts:new_device_id");
  await tracker.asyncObserver.promiseObserversComplete();

  changedIDs = await tracker.getChangedIDs();
  equal(Object.keys(changedIDs).length, 1);
  ok(engine.localID in changedIDs);
  ok(tracker.score > initialScore);
  ok(tracker.score >= SINGLE_USER_THRESHOLD);

  await tracker.stop();

  await cleanup();
});

add_task(async function test_last_modified() {
  _("Ensure that remote records have a sane serverLastModified attribute.");

  let now = new_timestamp();
  let server = await serverForFoo(engine);
  let user = server.user("foo");

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

  let activeID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: activeID,
      name: "Active client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    let collection = user.collection("clients");

    _("Sync to download the record");
    await syncClientsEngine(server);

    equal(
      engine._store._remoteClients[activeID].serverLastModified,
      now - 10,
      "last modified in the local record is correctly the server last-modified"
    );

    _("Modify the record and re-upload it");
    // set a new name to make sure we really did upload.
    engine._store._remoteClients[activeID].name = "New name";
    engine._modified.set(activeID, 0);
    // The sync above also did a POST, so adjust our lastModified.
    engine.lastModified = server.getCollection("foo", "clients").timestamp;
    await engine._uploadOutgoing();

    _("Local record should have updated timestamp");
    ok(engine._store._remoteClients[activeID].serverLastModified >= now);

    _("Record on the server should have new name but not serverLastModified");
    let payload = collection.cleartext(activeID);
    equal(payload.name, "New name");
    equal(payload.serverLastModified, undefined);
  } finally {
    await cleanup();
    server.deleteCollections("foo");
    await promiseStopServer(server);
  }
});

add_task(async function test_send_command() {
  _("Verifies _sendCommandToClient puts commands in the outbound queue.");

  let store = engine._store;
  let tracker = engine._tracker;
  let remoteId = Utils.makeGUID();
  let rec = new ClientsRec("clients", remoteId);

  await store.create(rec);
  await store.createRecord(remoteId, "clients");

  let action = "testCommand";
  let args = ["foo", "bar"];
  let extra = { flowID: "flowy" };

  await engine._sendCommandToClient(action, args, remoteId, extra);

  let newRecord = store._remoteClients[remoteId];
  let clientCommands = (await engine._readCommands())[remoteId];
  notEqual(newRecord, undefined);
  equal(clientCommands.length, 1);

  let command = clientCommands[0];
  equal(command.command, action);
  equal(command.args.length, 2);
  deepEqual(command.args, args);
  ok(command.flowID);

  const changes = await tracker.getChangedIDs();
  notEqual(changes[remoteId], undefined);

  await cleanup();
});

// The browser UI might call _addClientCommand indirectly without awaiting on the returned promise.
// We need to make sure this doesn't result on commands not being saved.
add_task(async function test_add_client_command_race() {
  let promises = [];
  for (let i = 0; i < 100; i++) {
    promises.push(
      engine._addClientCommand(`client-${i}`, { command: "cmd", args: [] })
    );
  }
  await Promise.all(promises);

  let localCommands = await engine._readCommands();
  for (let i = 0; i < 100; i++) {
    equal(localCommands[`client-${i}`].length, 1);
  }
});

add_task(async function test_command_validation() {
  _("Verifies that command validation works properly.");

  let store = engine._store;

  let testCommands = [
    ["resetAll", [], true],
    ["resetAll", ["foo"], false],
    ["resetEngine", ["tabs"], true],
    ["resetEngine", [], false],
    ["wipeEngine", ["tabs"], true],
    ["wipeEngine", [], false],
    ["logout", [], true],
    ["logout", ["foo"], false],
    ["__UNKNOWN__", [], false],
  ];

  for (let [action, args, expectedResult] of testCommands) {
    let remoteId = Utils.makeGUID();
    let rec = new ClientsRec("clients", remoteId);

    await store.create(rec);
    await store.createRecord(remoteId, "clients");

    await engine.sendCommand(action, args, remoteId);

    let newRecord = store._remoteClients[remoteId];
    notEqual(newRecord, undefined);

    let clientCommands = (await engine._readCommands())[remoteId];

    if (expectedResult) {
      _("Ensuring command is sent: " + action);
      equal(clientCommands.length, 1);

      let command = clientCommands[0];
      equal(command.command, action);
      deepEqual(command.args, args);

      notEqual(engine._tracker, undefined);
      const changes = await engine._tracker.getChangedIDs();
      notEqual(changes[remoteId], undefined);
    } else {
      _("Ensuring command is scrubbed: " + action);
      equal(clientCommands, undefined);

      if (store._tracker) {
        equal(engine._tracker[remoteId], undefined);
      }
    }
  }
  await cleanup();
});

add_task(async function test_command_duplication() {
  _("Ensures duplicate commands are detected and not added");

  let store = engine._store;
  let remoteId = Utils.makeGUID();
  let rec = new ClientsRec("clients", remoteId);
  await store.create(rec);
  await store.createRecord(remoteId, "clients");

  let action = "resetAll";
  let args = [];

  await engine.sendCommand(action, args, remoteId);
  await engine.sendCommand(action, args, remoteId);

  let clientCommands = (await engine._readCommands())[remoteId];
  equal(clientCommands.length, 1);

  _("Check variant args length");
  await engine._saveCommands({});

  action = "resetEngine";
  await engine.sendCommand(action, [{ x: "foo" }], remoteId);
  await engine.sendCommand(action, [{ x: "bar" }], remoteId);

  _("Make sure we spot a real dupe argument.");
  await engine.sendCommand(action, [{ x: "bar" }], remoteId);

  clientCommands = (await engine._readCommands())[remoteId];
  equal(clientCommands.length, 2);

  await cleanup();
});

add_task(async function test_command_invalid_client() {
  _("Ensures invalid client IDs are caught");

  let id = Utils.makeGUID();
  let error;

  try {
    await engine.sendCommand("wipeEngine", ["tabs"], id);
  } catch (ex) {
    error = ex;
  }

  equal(error.message.indexOf("Unknown remote client ID: "), 0);

  await cleanup();
});

add_task(async function test_process_incoming_commands() {
  _("Ensures local commands are executed");

  engine.localCommands = [{ command: "logout", args: [] }];

  let ev = "weave:service:logout:finish";

  let logoutPromise = new Promise(resolve => {
    var handler = function () {
      Svc.Obs.remove(ev, handler);

      resolve();
    };

    Svc.Obs.add(ev, handler);
  });

  // logout command causes processIncomingCommands to return explicit false.
  ok(!(await engine.processIncomingCommands()));

  await logoutPromise;

  await cleanup();
});

add_task(async function test_filter_duplicate_names() {
  _(
    "Ensure that we exclude clients with identical names that haven't synced in a week."
  );

  let now = new_timestamp();
  let server = await serverForFoo(engine);
  let user = server.user("foo");

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

  // Synced recently.
  let recentID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: recentID,
      name: "My Phone",
      type: "mobile",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  // Dupe of our client, synced more than 1 week ago.
  let dupeID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: dupeID,
      name: engine.localName,
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 604820
  );

  // Synced more than 1 week ago, but not a dupe.
  let oldID = Utils.makeGUID();
  user.collection("clients").insertRecord(
    {
      id: oldID,
      name: "My old desktop",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 604820
  );

  try {
    let store = engine._store;

    _("First sync");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);
    ok(engine.lastRecordUpload > 0);
    ok(!engine.isFirstSync);
    deepEqual(
      user.collection("clients").keys().sort(),
      [recentID, dupeID, oldID, engine.localID].sort(),
      "Our record should be uploaded on first sync"
    );

    let ids = await store.getAllIDs();
    deepEqual(
      Object.keys(ids).sort(),
      [recentID, dupeID, oldID, engine.localID].sort(),
      "Duplicate ID should remain in getAllIDs"
    );
    ok(
      await engine._store.itemExists(dupeID),
      "Dupe ID should be considered as existing for Sync methods."
    );
    ok(
      !engine.remoteClientExists(dupeID),
      "Dupe ID should not be considered as existing for external methods."
    );

    // dupe desktop should not appear in .deviceTypes.
    equal(engine.deviceTypes.get("desktop"), 2);
    equal(engine.deviceTypes.get("mobile"), 1);

    // dupe desktop should not appear in stats
    deepEqual(engine.stats, {
      hasMobile: 1,
      names: [engine.localName, "My Phone", "My old desktop"],
      numClients: 3,
    });

    ok(engine.remoteClientExists(oldID), "non-dupe ID should exist.");
    ok(!engine.remoteClientExists(dupeID), "dupe ID should not exist");
    equal(
      engine.remoteClients.length,
      2,
      "dupe should not be in remoteClients"
    );

    // Check that a subsequent Sync doesn't report anything as being processed.
    let counts;
    Svc.Obs.add("weave:engine:sync:applied", function observe(subject, data) {
      Svc.Obs.remove("weave:engine:sync:applied", observe);
      counts = subject;
    });

    await syncClientsEngine(server);
    equal(counts.applied, 0); // We didn't report applying any records.
    equal(counts.reconciled, 4); // We reported reconcilliation for all records
    equal(counts.succeeded, 0);
    equal(counts.failed, 0);
    equal(counts.newFailed, 0);

    _("Broadcast logout to all clients");
    await engine.sendCommand("logout", []);
    await syncClientsEngine(server);

    let collection = server.getCollection("foo", "clients");
    let recentPayload = collection.cleartext(recentID);
    compareCommands(
      recentPayload.commands,
      [{ command: "logout", args: [] }],
      "Should send commands to the recent client"
    );

    let oldPayload = collection.cleartext(oldID);
    compareCommands(
      oldPayload.commands,
      [{ command: "logout", args: [] }],
      "Should send commands to the week-old client"
    );

    let dupePayload = collection.cleartext(dupeID);
    deepEqual(
      dupePayload.commands,
      [],
      "Should not send commands to the dupe client"
    );

    _("Update the dupe client's modified time");
    collection.insertRecord(
      {
        id: dupeID,
        name: engine.localName,
        type: "desktop",
        commands: [],
        version: "48",
        protocols: ["1.5"],
      },
      now - 10
    );

    _("Second sync.");
    await syncClientsEngine(server);

    ids = await store.getAllIDs();
    deepEqual(
      Object.keys(ids).sort(),
      [recentID, oldID, dupeID, engine.localID].sort(),
      "Stale client synced, so it should no longer be marked as a dupe"
    );

    ok(
      engine.remoteClientExists(dupeID),
      "Dupe ID should appear as it synced."
    );

    // Recently synced dupe desktop should appear in .deviceTypes.
    equal(engine.deviceTypes.get("desktop"), 3);

    // Recently synced dupe desktop should now appear in stats
    deepEqual(engine.stats, {
      hasMobile: 1,
      names: [engine.localName, "My Phone", engine.localName, "My old desktop"],
      numClients: 4,
    });

    ok(
      engine.remoteClientExists(dupeID),
      "recently synced dupe ID should now exist"
    );
    equal(
      engine.remoteClients.length,
      3,
      "recently synced dupe should now be in remoteClients"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_command_sync() {
  _("Ensure that commands are synced across clients.");

  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let user = server.user("foo");
  let remoteId = Utils.makeGUID();

  function clientWBO(id) {
    return user.collection("clients").wbo(id);
  }

  _("Create remote client record");
  user.collection("clients").insertRecord({
    id: remoteId,
    name: "Remote client",
    type: "desktop",
    commands: [],
    version: "48",
    protocols: ["1.5"],
  });

  try {
    _("Syncing.");
    await syncClientsEngine(server);

    _("Checking remote record was downloaded.");
    let clientRecord = engine._store._remoteClients[remoteId];
    notEqual(clientRecord, undefined);
    equal(clientRecord.commands.length, 0);

    _("Send a command to the remote client.");
    await engine.sendCommand("wipeEngine", ["tabs"]);
    let clientCommands = (await engine._readCommands())[remoteId];
    equal(clientCommands.length, 1);
    await syncClientsEngine(server);

    _("Checking record was uploaded.");
    notEqual(clientWBO(engine.localID).payload, undefined);
    ok(engine.lastRecordUpload > 0);
    ok(!engine.isFirstSync);

    notEqual(clientWBO(remoteId).payload, undefined);

    Svc.PrefBranch.setCharPref("client.GUID", remoteId);
    await engine._resetClient();
    equal(engine.localID, remoteId);
    _("Performing sync on resetted client.");
    await syncClientsEngine(server);
    notEqual(engine.localCommands, undefined);
    equal(engine.localCommands.length, 1);

    let command = engine.localCommands[0];
    equal(command.command, "wipeEngine");
    equal(command.args.length, 1);
    equal(command.args[0], "tabs");
  } finally {
    await cleanup();

    try {
      let collection = server.getCollection("foo", "clients");
      collection.remove(remoteId);
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_clients_not_in_fxa_list() {
  _("Ensure that clients not in the FxA devices list are marked as stale.");

  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let remoteId = Utils.makeGUID();
  let remoteId2 = Utils.makeGUID();
  let collection = server.getCollection("foo", "clients");

  _("Create remote client records");
  collection.insertRecord({
    id: remoteId,
    name: "Remote client",
    type: "desktop",
    commands: [],
    version: "48",
    fxaDeviceId: remoteId,
    protocols: ["1.5"],
  });

  collection.insertRecord({
    id: remoteId2,
    name: "Remote client 2",
    type: "desktop",
    commands: [],
    version: "48",
    fxaDeviceId: remoteId2,
    protocols: ["1.5"],
  });

  let fxAccounts = engine.fxAccounts;
  engine.fxAccounts = {
    notifyDevices() {
      return Promise.resolve(true);
    },
    device: {
      getLocalId() {
        return fxAccounts.device.getLocalId();
      },
      getLocalName() {
        return fxAccounts.device.getLocalName();
      },
      getLocalType() {
        return fxAccounts.device.getLocalType();
      },
      recentDeviceList: [{ id: remoteId }],
      refreshDeviceList() {
        return Promise.resolve(true);
      },
    },
    _internal: {
      now() {
        return Date.now();
      },
    },
  };

  try {
    _("Syncing.");
    await syncClientsEngine(server);

    ok(!engine._store._remoteClients[remoteId].stale);
    ok(engine._store._remoteClients[remoteId2].stale);
  } finally {
    engine.fxAccounts = fxAccounts;
    await cleanup();

    try {
      collection.remove(remoteId);
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_dupe_device_ids() {
  _(
    "Ensure that we mark devices with duplicate fxaDeviceIds but older lastModified as stale."
  );

  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let remoteId = Utils.makeGUID();
  let remoteId2 = Utils.makeGUID();
  let remoteDeviceId = Utils.makeGUID();

  let collection = server.getCollection("foo", "clients");

  _("Create remote client records");
  collection.insertRecord(
    {
      id: remoteId,
      name: "Remote client",
      type: "desktop",
      commands: [],
      version: "48",
      fxaDeviceId: remoteDeviceId,
      protocols: ["1.5"],
    },
    new_timestamp() - 3
  );
  collection.insertRecord({
    id: remoteId2,
    name: "Remote client",
    type: "desktop",
    commands: [],
    version: "48",
    fxaDeviceId: remoteDeviceId,
    protocols: ["1.5"],
  });

  let fxAccounts = engine.fxAccounts;
  engine.fxAccounts = {
    notifyDevices() {
      return Promise.resolve(true);
    },
    device: {
      getLocalId() {
        return fxAccounts.device.getLocalId();
      },
      getLocalName() {
        return fxAccounts.device.getLocalName();
      },
      getLocalType() {
        return fxAccounts.device.getLocalType();
      },
      recentDeviceList: [{ id: remoteDeviceId }],
      refreshDeviceList() {
        return Promise.resolve(true);
      },
    },
    _internal: {
      now() {
        return Date.now();
      },
    },
  };

  try {
    _("Syncing.");
    await syncClientsEngine(server);

    ok(engine._store._remoteClients[remoteId].stale);
    ok(!engine._store._remoteClients[remoteId2].stale);
  } finally {
    engine.fxAccounts = fxAccounts;
    await cleanup();

    try {
      collection.remove(remoteId);
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_refresh_fxa_device_list() {
  _("Ensure we refresh the fxa device list when we expect to.");

  await engine._store.wipe();
  engine._lastFxaDeviceRefresh = 0;
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let numRefreshes = 0;
  let now = Date.now();
  let fxAccounts = engine.fxAccounts;
  engine.fxAccounts = {
    notifyDevices() {
      return Promise.resolve(true);
    },
    device: {
      getLocalId() {
        return fxAccounts.device.getLocalId();
      },
      getLocalName() {
        return fxAccounts.device.getLocalName();
      },
      getLocalType() {
        return fxAccounts.device.getLocalType();
      },
      recentDeviceList: [],
      refreshDeviceList() {
        numRefreshes += 1;
        return Promise.resolve(true);
      },
    },
    _internal: {
      now() {
        return now;
      },
    },
  };

  try {
    _("Syncing.");
    await syncClientsEngine(server);
    Assert.equal(numRefreshes, 1, "first sync should refresh");
    now += 1000; // a second later.
    await syncClientsEngine(server);
    Assert.equal(numRefreshes, 1, "next sync should not refresh");
    now += 60 * 60 * 2 * 1000; // 2 hours later
    await syncClientsEngine(server);
    Assert.equal(numRefreshes, 2, "2 hours later should refresh");
    now += 1000; // a second later.
    Assert.equal(numRefreshes, 2, "next sync should not refresh");
  } finally {
    await cleanup();
    await promiseStopServer(server);
  }
});

add_task(async function test_optional_client_fields() {
  _("Ensure that we produce records with the fields added in Bug 1097222.");

  const SUPPORTED_PROTOCOL_VERSIONS = ["1.5"];
  let local = await engine._store.createRecord(engine.localID, "clients");
  equal(local.name, engine.localName);
  equal(local.type, engine.localType);
  equal(local.version, Services.appinfo.version);
  deepEqual(local.protocols, SUPPORTED_PROTOCOL_VERSIONS);

  // Optional fields.
  // Make sure they're what they ought to be...
  equal(local.os, Services.appinfo.OS);
  equal(local.appPackage, Services.appinfo.ID);

  // ... and also that they're non-empty.
  ok(!!local.os);
  ok(!!local.appPackage);
  ok(!!local.application);

  // We don't currently populate device or formfactor.
  // See Bug 1100722, Bug 1100723.

  await cleanup();
});

add_task(async function test_merge_commands() {
  _("Verifies local commands for remote clients are merged with the server's");

  let now = new_timestamp();
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let collection = server.getCollection("foo", "clients");

  let desktopID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: desktopID,
      name: "Desktop client",
      type: "desktop",
      commands: [
        {
          command: "wipeEngine",
          args: ["history"],
          flowID: Utils.makeGUID(),
        },
      ],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  let mobileID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: mobileID,
      name: "Mobile client",
      type: "mobile",
      commands: [
        {
          command: "logout",
          args: [],
          flowID: Utils.makeGUID(),
        },
      ],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    _("First sync. 2 records downloaded.");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);

    _("Broadcast logout to all clients");
    await engine.sendCommand("logout", []);
    await syncClientsEngine(server);

    let desktopPayload = collection.cleartext(desktopID);
    compareCommands(
      desktopPayload.commands,
      [
        {
          command: "wipeEngine",
          args: ["history"],
        },
        {
          command: "logout",
          args: [],
        },
      ],
      "Should send the logout command to the desktop client"
    );

    let mobilePayload = collection.cleartext(mobileID);
    compareCommands(
      mobilePayload.commands,
      [{ command: "logout", args: [] }],
      "Should not send a duplicate logout to the mobile client"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_duplicate_remote_commands() {
  _(
    "Verifies local commands for remote clients are sent only once (bug 1289287)"
  );

  let now = new_timestamp();
  let server = await serverForFoo(engine);

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

  let collection = server.getCollection("foo", "clients");

  let desktopID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: desktopID,
      name: "Desktop client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    _("First sync. 1 record downloaded.");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);

    _("Send command to client to wipe history engine");
    await engine.sendCommand("wipeEngine", ["history"]);
    await syncClientsEngine(server);

    _(
      "Simulate the desktop client consuming the command and syncing to the server"
    );
    collection.insertRecord(
      {
        id: desktopID,
        name: "Desktop client",
        type: "desktop",
        commands: [],
        version: "48",
        protocols: ["1.5"],
      },
      now - 10
    );

    _("Send another command to the desktop client to wipe tabs engine");
    await engine.sendCommand("wipeEngine", ["tabs"], desktopID);
    await syncClientsEngine(server);

    let desktopPayload = collection.cleartext(desktopID);
    compareCommands(
      desktopPayload.commands,
      [
        {
          command: "wipeEngine",
          args: ["tabs"],
        },
      ],
      "Should only send the second command to the desktop client"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_upload_after_reboot() {
  _("Multiple downloads, reboot, then upload (bug 1289287)");

  let now = new_timestamp();
  let server = await serverForFoo(engine);

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

  let collection = server.getCollection("foo", "clients");

  let deviceBID = Utils.makeGUID();
  let deviceCID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: deviceBID,
      name: "Device B",
      type: "desktop",
      commands: [
        {
          command: "wipeEngine",
          args: ["history"],
          flowID: Utils.makeGUID(),
        },
      ],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );
  collection.insertRecord(
    {
      id: deviceCID,
      name: "Device C",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    _("First sync. 2 records downloaded.");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);
    await syncClientsEngine(server);

    _("Send command to client to wipe tab engine");
    await engine.sendCommand("wipeEngine", ["tabs"], deviceBID);

    const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing;
    SyncEngine.prototype._uploadOutgoing = async () =>
      engine._onRecordsWritten([], [deviceBID]);
    await syncClientsEngine(server);

    let deviceBPayload = collection.cleartext(deviceBID);
    compareCommands(
      deviceBPayload.commands,
      [
        {
          command: "wipeEngine",
          args: ["history"],
        },
      ],
      "Should be the same because the upload failed"
    );

    _("Simulate the client B consuming the command and syncing to the server");
    collection.insertRecord(
      {
        id: deviceBID,
        name: "Device B",
        type: "desktop",
        commands: [],
        version: "48",
        protocols: ["1.5"],
      },
      now - 10
    );

    // Simulate reboot
    SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing;
    engine = Service.clientsEngine = new ClientEngine(Service);
    await engine.initialize();

    await syncClientsEngine(server);

    deviceBPayload = collection.cleartext(deviceBID);
    compareCommands(
      deviceBPayload.commands,
      [
        {
          command: "wipeEngine",
          args: ["tabs"],
        },
      ],
      "Should only had written our outgoing command"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_keep_cleared_commands_after_reboot() {
  _(
    "Download commands, fail upload, reboot, then apply new commands (bug 1289287)"
  );

  let now = new_timestamp();
  let server = await serverForFoo(engine);

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

  let collection = server.getCollection("foo", "clients");

  let deviceBID = Utils.makeGUID();
  let deviceCID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: engine.localID,
      name: "Device A",
      type: "desktop",
      commands: [
        {
          command: "wipeEngine",
          args: ["history"],
          flowID: Utils.makeGUID(),
        },
        {
          command: "wipeEngine",
          args: ["tabs"],
          flowID: Utils.makeGUID(),
        },
      ],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );
  collection.insertRecord(
    {
      id: deviceBID,
      name: "Device B",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );
  collection.insertRecord(
    {
      id: deviceCID,
      name: "Device C",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    _("First sync. Download remote and our record.");
    strictEqual(engine.lastRecordUpload, 0);
    ok(engine.isFirstSync);

    const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing;
    SyncEngine.prototype._uploadOutgoing = async () =>
      engine._onRecordsWritten([], [deviceBID]);
    let commandsProcessed = 0;
    engine.service.wipeClient = _engine => {
      commandsProcessed++;
    };

    await syncClientsEngine(server);
    await engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves
    equal(commandsProcessed, 2, "We processed 2 commands");

    let localRemoteRecord = collection.cleartext(engine.localID);
    compareCommands(
      localRemoteRecord.commands,
      [
        {
          command: "wipeEngine",
          args: ["history"],
        },
        {
          command: "wipeEngine",
          args: ["tabs"],
        },
      ],
      "Should be the same because the upload failed"
    );

    // Another client sends a wipe command
    collection.insertRecord(
      {
        id: engine.localID,
        name: "Device A",
        type: "desktop",
        commands: [
          {
            command: "wipeEngine",
            args: ["history"],
            flowID: Utils.makeGUID(),
          },
          {
            command: "wipeEngine",
            args: ["tabs"],
            flowID: Utils.makeGUID(),
          },
          {
            command: "wipeEngine",
            args: ["bookmarks"],
            flowID: Utils.makeGUID(),
          },
        ],
        version: "48",
        protocols: ["1.5"],
      },
      now - 5
    );

    // Simulate reboot
    SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing;
    engine = Service.clientsEngine = new ClientEngine(Service);
    await engine.initialize();

    commandsProcessed = 0;
    engine.service.wipeClient = _engine => {
      commandsProcessed++;
    };
    await syncClientsEngine(server);
    await engine.processIncomingCommands();
    equal(
      commandsProcessed,
      1,
      "We processed one command (the other were cleared)"
    );

    localRemoteRecord = collection.cleartext(deviceBID);
    deepEqual(localRemoteRecord.commands, [], "Should be empty");
  } finally {
    await cleanup();

    // Reset service (remove mocks)
    engine = Service.clientsEngine = new ClientEngine(Service);
    await engine.initialize();
    await engine._resetClient();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_deleted_commands() {
  _("Verifies commands for a deleted client are discarded");

  let now = new_timestamp();
  let server = await serverForFoo(engine);

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

  let collection = server.getCollection("foo", "clients");

  let activeID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: activeID,
      name: "Active client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  let deletedID = Utils.makeGUID();
  collection.insertRecord(
    {
      id: deletedID,
      name: "Client to delete",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    },
    now - 10
  );

  try {
    _("First sync. 2 records downloaded.");
    await syncClientsEngine(server);

    _("Delete a record on the server.");
    collection.remove(deletedID);

    _("Broadcast a command to all clients");
    await engine.sendCommand("logout", []);
    await syncClientsEngine(server);

    deepEqual(
      collection.keys().sort(),
      [activeID, engine.localID].sort(),
      "Should not reupload deleted clients"
    );

    let activePayload = collection.cleartext(activeID);
    compareCommands(
      activePayload.commands,
      [{ command: "logout", args: [] }],
      "Should send the command to the active client"
    );
  } finally {
    await cleanup();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_command_sync() {
  _("Notify other clients when writing their record.");

  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let collection = server.getCollection("foo", "clients");
  let remoteId = Utils.makeGUID();
  let remoteId2 = Utils.makeGUID();

  _("Create remote client record 1");
  collection.insertRecord({
    id: remoteId,
    name: "Remote client",
    type: "desktop",
    commands: [],
    version: "48",
    protocols: ["1.5"],
  });

  _("Create remote client record 2");
  collection.insertRecord({
    id: remoteId2,
    name: "Remote client 2",
    type: "mobile",
    commands: [],
    version: "48",
    protocols: ["1.5"],
  });

  try {
    equal(collection.count(), 2, "2 remote records written");
    await syncClientsEngine(server);
    equal(
      collection.count(),
      3,
      "3 remote records written (+1 for the synced local record)"
    );

    await engine.sendCommand("wipeEngine", ["tabs"]);
    await engine._tracker.addChangedID(engine.localID);
    const getClientFxaDeviceId = sinon
      .stub(engine, "getClientFxaDeviceId")
      .callsFake(id => "fxa-" + id);
    const engineMock = sinon.mock(engine);
    let _notifyCollectionChanged = engineMock
      .expects("_notifyCollectionChanged")
      .withArgs(["fxa-" + remoteId, "fxa-" + remoteId2]);
    _("Syncing.");
    await syncClientsEngine(server);
    _notifyCollectionChanged.verify();

    engineMock.restore();
    getClientFxaDeviceId.restore();
  } finally {
    await cleanup();
    await engine._tracker.clearChangedIDs();

    try {
      server.deleteCollections("foo");
    } finally {
      await promiseStopServer(server);
    }
  }
});

add_task(async function ensureSameFlowIDs() {
  let events = [];
  let origRecordTelemetryEvent = Service.recordTelemetryEvent;
  Service.recordTelemetryEvent = (object, method, value, extra) => {
    events.push({ object, method, value, extra });
  };

  let server = await serverForFoo(engine);
  try {
    // Setup 2 clients, send them a command, and ensure we get to events
    // written, both with the same flowID.
    await SyncTestingInfrastructure(server);
    let collection = server.getCollection("foo", "clients");

    let remoteId = Utils.makeGUID();
    let remoteId2 = Utils.makeGUID();

    _("Create remote client record 1");
    collection.insertRecord({
      id: remoteId,
      name: "Remote client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    });

    _("Create remote client record 2");
    collection.insertRecord({
      id: remoteId2,
      name: "Remote client 2",
      type: "mobile",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    });

    await syncClientsEngine(server);
    await engine.sendCommand("wipeEngine", ["tabs"]);
    await syncClientsEngine(server);
    equal(events.length, 2);
    // we don't know what the flowID is, but do know it should be the same.
    equal(events[0].extra.flowID, events[1].extra.flowID);
    // Wipe remote clients to ensure deduping doesn't prevent us from adding the command.
    for (let client of Object.values(engine._store._remoteClients)) {
      client.commands = [];
    }
    // check it's correctly used when we specify a flow ID
    events.length = 0;
    let flowID = Utils.makeGUID();
    await engine.sendCommand("wipeEngine", ["tabs"], null, { flowID });
    await syncClientsEngine(server);
    equal(events.length, 2);
    equal(events[0].extra.flowID, flowID);
    equal(events[1].extra.flowID, flowID);

    // Wipe remote clients to ensure deduping doesn't prevent us from adding the command.
    for (let client of Object.values(engine._store._remoteClients)) {
      client.commands = [];
    }

    // and that it works when something else is in "extra"
    events.length = 0;
    await engine.sendCommand("wipeEngine", ["tabs"], null, {
      reason: "testing",
    });
    await syncClientsEngine(server);
    equal(events.length, 2);
    equal(events[0].extra.flowID, events[1].extra.flowID);
    equal(events[0].extra.reason, "testing");
    equal(events[1].extra.reason, "testing");
    // Wipe remote clients to ensure deduping doesn't prevent us from adding the command.
    for (let client of Object.values(engine._store._remoteClients)) {
      client.commands = [];
    }

    // and when both are specified.
    events.length = 0;
    await engine.sendCommand("wipeEngine", ["tabs"], null, {
      reason: "testing",
      flowID,
    });
    await syncClientsEngine(server);
    equal(events.length, 2);
    equal(events[0].extra.flowID, flowID);
    equal(events[1].extra.flowID, flowID);
    equal(events[0].extra.reason, "testing");
    equal(events[1].extra.reason, "testing");
    // Wipe remote clients to ensure deduping doesn't prevent us from adding the command.
    for (let client of Object.values(engine._store._remoteClients)) {
      client.commands = [];
    }
  } finally {
    Service.recordTelemetryEvent = origRecordTelemetryEvent;
    cleanup();
    await promiseStopServer(server);
  }
});

add_task(async function test_duplicate_commands_telemetry() {
  let events = [];
  let origRecordTelemetryEvent = Service.recordTelemetryEvent;
  Service.recordTelemetryEvent = (object, method, value, extra) => {
    events.push({ object, method, value, extra });
  };

  let server = await serverForFoo(engine);
  try {
    await SyncTestingInfrastructure(server);
    let collection = server.getCollection("foo", "clients");

    let remoteId = Utils.makeGUID();
    let remoteId2 = Utils.makeGUID();

    _("Create remote client record 1");
    collection.insertRecord({
      id: remoteId,
      name: "Remote client",
      type: "desktop",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    });

    _("Create remote client record 2");
    collection.insertRecord({
      id: remoteId2,
      name: "Remote client 2",
      type: "mobile",
      commands: [],
      version: "48",
      protocols: ["1.5"],
    });

    await syncClientsEngine(server);
    // Make sure deduping works before syncing
    await engine.sendCommand("wipeEngine", ["history"], remoteId);
    await engine.sendCommand("wipeEngine", ["history"], remoteId);
    equal(events.length, 1);
    await syncClientsEngine(server);
    // And after syncing.
    await engine.sendCommand("wipeEngine", ["history"], remoteId);
    equal(events.length, 1);
    // Ensure we aren't deduping commands to different clients
    await engine.sendCommand("wipeEngine", ["history"], remoteId2);
    equal(events.length, 2);
  } finally {
    Service.recordTelemetryEvent = origRecordTelemetryEvent;
    cleanup();
    await promiseStopServer(server);
  }
});

add_task(async function test_other_clients_notified_on_first_sync() {
  _(
    "Ensure that other clients are notified when we upload our client record for the first time."
  );

  await engine.resetLastSync();
  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  const fxAccounts = engine.fxAccounts;
  let calls = 0;
  engine.fxAccounts = {
    device: {
      getLocalId() {
        return fxAccounts.device.getLocalId();
      },
      getLocalName() {
        return fxAccounts.device.getLocalName();
      },
      getLocalType() {
        return fxAccounts.device.getLocalType();
      },
    },
    notifyDevices() {
      calls++;
      return Promise.resolve(true);
    },
    _internal: {
      now() {
        return Date.now();
      },
    },
  };

  try {
    engine.lastRecordUpload = 0;
    _("First sync, should notify other clients");
    await syncClientsEngine(server);
    equal(calls, 1);

    _("Second sync, should not notify other clients");
    await syncClientsEngine(server);
    equal(calls, 1);
  } finally {
    engine.fxAccounts = fxAccounts;
    cleanup();
    await promiseStopServer(server);
  }
});

add_task(
  async function device_disconnected_notification_updates_known_stale_clients() {
    const spyUpdate = sinon.spy(engine, "updateKnownStaleClients");

    Services.obs.notifyObservers(
      null,
      "fxaccounts:device_disconnected",
      JSON.stringify({ isLocalDevice: false })
    );
    ok(spyUpdate.calledOnce, "updateKnownStaleClients should be called");
    spyUpdate.resetHistory();

    Services.obs.notifyObservers(
      null,
      "fxaccounts:device_disconnected",
      JSON.stringify({ isLocalDevice: true })
    );
    ok(spyUpdate.notCalled, "updateKnownStaleClients should not be called");

    spyUpdate.restore();
  }
);

add_task(async function update_known_stale_clients() {
  const makeFakeClient = id => ({ id, fxaDeviceId: `fxa-${id}` });
  const clients = [
    makeFakeClient("one"),
    makeFakeClient("two"),
    makeFakeClient("three"),
  ];
  const stubRemoteClients = sinon
    .stub(engine._store, "_remoteClients")
    .get(() => {
      return clients;
    });
  const stubFetchFxADevices = sinon
    .stub(engine, "_fetchFxADevices")
    .callsFake(() => {
      engine._knownStaleFxADeviceIds = ["fxa-one", "fxa-two"];
    });

  engine._knownStaleFxADeviceIds = null;
  await engine.updateKnownStaleClients();
  ok(clients[0].stale);
  ok(clients[1].stale);
  ok(!clients[2].stale);

  stubRemoteClients.restore();
  stubFetchFxADevices.restore();
});

add_task(async function test_create_record_command_limit() {
  await engine._store.wipe();
  await generateNewKeys(Service.collectionKeys);

  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  const fakeLimit = 4 * 1024;

  let maxSizeStub = sinon
    .stub(Service, "getMemcacheMaxRecordPayloadSize")
    .callsFake(() => fakeLimit);

  let user = server.user("foo");
  let remoteId = Utils.makeGUID();

  _("Create remote client record");
  user.collection("clients").insertRecord({
    id: remoteId,
    name: "Remote client",
    type: "desktop",
    commands: [],
    version: "57",
    protocols: ["1.5"],
  });

  try {
    _("Initial sync.");
    await syncClientsEngine(server);

    _("Send a fairly sane number of commands.");

    for (let i = 0; i < 5; ++i) {
      await engine.sendCommand("wipeEngine", [`history: ${i}`], remoteId);
    }

    await syncClientsEngine(server);

    _("Make sure they all fit and weren't dropped.");
    let parsedServerRecord = user.collection("clients").cleartext(remoteId);

    equal(parsedServerRecord.commands.length, 5);

    await engine.sendCommand("wipeEngine", ["history"], remoteId);

    _("Send a not-sane number of commands.");
    // Much higher than the maximum number of commands we could actually fit.
    for (let i = 0; i < 500; ++i) {
      await engine.sendCommand("wipeEngine", [`tabs: ${i}`], remoteId);
    }

    await syncClientsEngine(server);

    _("Ensure we didn't overflow the server limit.");
    let wbo = user.collection("clients").wbo(remoteId);
    less(wbo.payload.length, fakeLimit);

    _(
      "And that the data we uploaded is both sane json and containing some commands."
    );
    let remoteCommands = wbo.getCleartext().commands;
    greater(remoteCommands.length, 2);
    let firstCommand = remoteCommands[0];
    _(
      "The first command should still be present, since it had a high priority"
    );
    equal(firstCommand.command, "wipeEngine");
    _("And the last command in the list should be the last command we sent.");
    let lastCommand = remoteCommands[remoteCommands.length - 1];
    equal(lastCommand.command, "wipeEngine");
    deepEqual(lastCommand.args, ["tabs: 499"]);
  } finally {
    maxSizeStub.restore();
    await cleanup();
    try {
      let collection = server.getCollection("foo", "clients");
      collection.remove(remoteId);
    } finally {
      await promiseStopServer(server);
    }
  }
});
