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

const { Service } = ChromeUtils.importESModule(
  "resource://services-sync/service.sys.mjs"
);
const { WBORecord } = ChromeUtils.importESModule(
  "resource://services-sync/record.sys.mjs"
);
const { RotaryEngine } = ChromeUtils.importESModule(
  "resource://testing-common/services/sync/rotaryengine.sys.mjs"
);
const { getFxAccountsSingleton } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccounts.sys.mjs"
);
const fxAccounts = getFxAccountsSingleton();

function SteamStore(engine) {
  Store.call(this, "Steam", engine);
}
Object.setPrototypeOf(SteamStore.prototype, Store.prototype);

function SteamTracker(name, engine) {
  LegacyTracker.call(this, name || "Steam", engine);
}
Object.setPrototypeOf(SteamTracker.prototype, LegacyTracker.prototype);

function SteamEngine(service) {
  SyncEngine.call(this, "steam", service);
}

SteamEngine.prototype = {
  _storeObj: SteamStore,
  _trackerObj: SteamTracker,
  _errToThrow: null,
  problemsToReport: null,
  async _sync() {
    if (this._errToThrow) {
      throw this._errToThrow;
    }
  },
  getValidator() {
    return new SteamValidator();
  },
};
Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype);

function BogusEngine(service) {
  SyncEngine.call(this, "bogus", service);
}

BogusEngine.prototype = Object.create(SteamEngine.prototype);

class SteamValidator {
  async canValidate() {
    return true;
  }

  async validate(engine) {
    return {
      problems: new SteamValidationProblemData(engine.problemsToReport),
      version: 1,
      duration: 0,
      recordCount: 0,
    };
  }
}

class SteamValidationProblemData {
  constructor(problemsToReport = []) {
    this.problemsToReport = problemsToReport;
  }

  getSummary() {
    return this.problemsToReport;
  }
}

async function cleanAndGo(engine, server) {
  await engine._tracker.clearChangedIDs();
  for (const pref of Svc.PrefBranch.getChildList("")) {
    Svc.PrefBranch.clearUserPref(pref);
  }
  syncTestLogging();
  Service.recordManager.clearCache();
  await promiseStopServer(server);
}

async function sync_engine(engine, errorRegex = undefined) {
  // Clear out status so failures from previous syncs won't show in the record.
  let { Status } = ChromeUtils.importESModule(
    "resource://services-sync/status.sys.mjs"
  );
  Status._engines = {};
  Status.partial = false;
  // Neuter the scheduler as it interacts badly with some of the tests - the
  // engine being synced usually isn't the registered engine, so we see
  // scored incremented and not removed, which schedules unexpected syncs.
  let oldObserve = Service.scheduler.observe;
  Service.scheduler.observe = () => {};
  try {
    Svc.Obs.notify("weave:service:sync:start");
    let caughtError;
    try {
      await engine.sync();
    } catch (e) {
      caughtError = e;
    }
    if (caughtError) {
      ok(
        errorRegex.test(JSON.stringify(caughtError)),
        "Engine sync error expected."
      );
      Svc.Obs.notify("weave:service:sync:error", caughtError);
    } else {
      ok(!errorRegex, "No engine sync error expected.");
      Svc.Obs.notify("weave:service:sync:finish");
    }
  } finally {
    Service.scheduler.observe = oldObserve;
  }
}

add_setup(async function () {
  // Avoid addon manager complaining about not being initialized
  await Service.engineManager.unregister("addons");
  await Service.engineManager.unregister("extension-storage");

  do_get_profile(); // FOG requires a profile dir.
  Services.fog.initializeFOG();

  // We're not using `wait_for_ping` which means we aren't implicitly calling
  // get_sync_test_telemetry() so often. So call it here for its side effects.
  let telem = get_sync_test_telemetry();
  // Unlike the telemetry tests, we actually want the Glean APIs to be called.
  // So pretend we're in production.
  telem.isProductionSyncUser = () => true;
});

add_task(async function test_basic() {
  enableValidationPrefs();

  let helper = track_collections_helper();
  let upd = helper.with_updated_collection;

  let handlers = {
    "/1.1/johndoe/info/collections": helper.handler,
    "/1.1/johndoe/storage/crypto/keys": upd(
      "crypto",
      new ServerWBO("keys").handler()
    ),
    "/1.1/johndoe/storage/meta/global": upd(
      "meta",
      new ServerWBO("global").handler()
    ),
  };

  let collections = [
    "clients",
    "bookmarks",
    "forms",
    "history",
    "passwords",
    "prefs",
    "tabs",
  ];

  for (let coll of collections) {
    handlers["/1.1/johndoe/storage/" + coll] = upd(
      coll,
      new ServerCollection({}, true).handler()
    );
  }

  let server = httpd_setup(handlers);
  await configureIdentity({ username: "johndoe" }, server);

  // Test that a basic batch can make it.
  await GleanPings.sync.testSubmission(
    reason => {
      equal(reason, "schedule");
      Glean.syncs.syncs.testGetValue().forEach(assert_success_sync);
    },
    async () => {
      await Service.sync();
    }
  );

  for (const pref of Svc.PrefBranch.getChildList("")) {
    Svc.PrefBranch.clearUserPref(pref);
  }
  await promiseStopServer(server);
});

add_task(async function test_processIncoming_error() {
  let engine = Service.engineManager.get("bookmarks");
  await engine.initialize();
  let store = engine._store;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("bookmarks");
  try {
    // Create a bogus record that when synced down will provoke a
    // network error which in turn provokes an exception in _processIncoming.
    const BOGUS_GUID = "zzzzzzzzzzzz";
    let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!");
    bogus_record.get = function get() {
      throw new Error("Sync this!");
    };
    // Make it 10 minutes old so it will only be synced in the toFetch phase.
    bogus_record.modified = Date.now() / 1000 - 60 * 10;
    await engine.setLastSync(Date.now() / 1000 - 60);
    engine.toFetch = new SerializableSet([BOGUS_GUID]);

    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        deepEqual(sync.failureReason, { name: "httperror", code: 500 });
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.name, "bookmarks-buffered");
        deepEqual(e.failureReason, { name: "httperror", code: 500 });
      },
      async () => {
        await sync_engine(engine, /500 Internal Server Error/);
      }
    );
  } finally {
    await store.wipe();
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_uploading() {
  // Clear out status, so failures from previous syncs won't show up in the
  // telemetry ping.
  let { Status } = ChromeUtils.importESModule(
    "resource://services-sync/status.sys.mjs"
  );
  Status._engines = {};
  Status.partial = false;

  let engine = Service.engineManager.get("bookmarks");
  await engine.initialize();
  let store = engine._store;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let bmk = await PlacesUtils.bookmarks.insert({
    parentGuid: PlacesUtils.bookmarks.toolbarGuid,
    url: "http://getfirefox.com/",
    title: "Get Firefox!",
  });

  try {
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.name, "bookmarks-buffered");
        greater(e.outgoing[0].sent, 0);
        ok(!e.incoming);
      },
      async () => {
        await sync_engine(engine);
      }
    );

    await PlacesUtils.bookmarks.update({
      guid: bmk.guid,
      title: "New Title",
    });

    await store.wipe();
    await engine.resetClient();
    // We don't sync via the service, so don't re-hit info/collections, so
    // lastModified remaning at zero breaks things subtly...
    engine.lastModified = null;

    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.name, "bookmarks-buffered");
        equal(e.outgoing.length, 1);
        ok(!!e.incoming);
      },
      async () => {
        await sync_engine(engine);
      }
    );
  } finally {
    // Clean up.
    await store.wipe();
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_upload_failed() {
  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO("flying");

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

  await SyncTestingInfrastructure(server);
  await configureIdentity({ username: "foo" }, server);

  let engine = new RotaryEngine(Service);
  engine._store.items = {
    flying: "LNER Class A3 4472",
    scotsman: "Flying Scotsman",
    peppercorn: "Peppercorn Class",
  };
  const FLYING_CHANGED = 12345;
  const SCOTSMAN_CHANGED = 23456;
  const PEPPERCORN_CHANGED = 34567;
  await engine._tracker.addChangedID("flying", FLYING_CHANGED);
  await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED);
  await engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED);

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

  try {
    await engine.setLastSync(123); // needs to be non-zero so that tracker is queried
    let changes = await engine._tracker.getChangedIDs();
    _(
      `test_upload_failed: Rotary tracker contents at first sync: ${JSON.stringify(
        changes
      )}`
    );
    engine.enabled = true;
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.incoming, null);
        deepEqual(e.outgoing, [
          {
            sent: 3,
            failed: 2,
            failedReasons: [
              { name: "scotsman", count: 1 },
              { name: "peppercorn", count: 1 },
            ],
          },
        ]);
      },
      async () => {
        await sync_engine(engine);
      }
    );

    await engine.setLastSync(123);

    changes = await engine._tracker.getChangedIDs();
    _(
      `test_upload_failed: Rotary tracker contents at second sync: ${JSON.stringify(
        changes
      )}`
    );
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        deepEqual(e.outgoing, [
          {
            sent: 2,
            failed: 2,
            failedReasons: [
              { name: "scotsman", count: 1 },
              { name: "peppercorn", count: 1 },
            ],
          },
        ]);
      },
      async () => {
        await sync_engine(engine);
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await engine.finalize();
  }
});

add_task(async function test_sync_partialUpload() {
  let collection = new ServerCollection();
  let server = sync_httpd_setup({
    "/1.1/foo/storage/rotary": collection.handler(),
  });
  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let engine = new RotaryEngine(Service);
  await engine.setLastSync(123);

  // 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;
    await engine._tracker.addChangedID(id, i);
    // Let two items in the first upload batch fail.
    if (i != 23 && i != 42) {
      collection.insert(id);
    }
  }

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

  try {
    let changes = await engine._tracker.getChangedIDs();
    _(
      `test_sync_partialUpload: Rotary tracker contents at first sync: ${JSON.stringify(
        changes
      )}`
    );
    engine.enabled = true;
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        ok(!sync.failureReason);
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.name, "rotary");
        ok(!e.incoming);
        ok(!e.failureReason);
        deepEqual(e.outgoing, [
          {
            sent: 234,
            failed: 2,
            failedReasons: [
              { name: "record-no-23", count: 1 },
              { name: "record-no-42", count: 1 },
            ],
          },
        ]);
      },
      async () => {
        await sync_engine(engine);
      }
    );

    collection.post = function () {
      throw new Error("Failure");
    };

    engine._store.items["record-no-1000"] = "Record No. 1000";
    await engine._tracker.addChangedID("record-no-1000", 1000);
    collection.insert("record-no-1000", 1000);

    await engine.setLastSync(123);

    changes = await engine._tracker.getChangedIDs();
    _(
      `test_sync_partialUpload: Rotary tracker contents at second sync: ${JSON.stringify(
        changes
      )}`
    );
    // It would be nice if we had a more descriptive error for this...
    const uploadFailureError = {
      name: "httperror",
      code: 500,
    };
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        deepEqual(sync.failureReason, uploadFailureError);
        equal(sync.engines.length, 1);

        const e = sync.engines[0];
        equal(e.name, "rotary");
        deepEqual(e.incoming, {
          failed: 1,
          failedReasons: [
            { name: "No ciphertext: nothing to decrypt?", count: 1 },
          ],
        });
        ok(!e.outgoing);
        deepEqual(e.failureReason, uploadFailureError);
      },
      async () => {
        await sync_engine(engine, /500 Internal Server Error/);
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await engine.finalize();
  }
});

add_task(async function test_generic_engine_fail() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let e = new Error("generic failure message");
  engine._errToThrow = e;

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        deepEqual(
          sync.engines.find(err => err.name === "steam").failureReason,
          {
            name: "unexpectederror",
            error: String(e),
          }
        );
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_engine_fail_weird_errors() {
  enableValidationPrefs();
  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  try {
    let msg = "Bad things happened!";
    engine._errToThrow = { message: msg };
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        deepEqual(
          sync.engines.find(err => err.name === "steam").failureReason,
          {
            name: "unexpectederror",
            error: msg,
          }
        );
      },
      async () => {
        await Service.sync();
      }
    );
    let e = { msg };
    engine._errToThrow = e;
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        deepEqual(
          sync.engines.find(err => err.name === "steam").failureReason,
          {
            name: "unexpectederror",
            error: JSON.stringify(e),
          }
        );
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    Service.engineManager.unregister(engine);
  }
});

add_task(async function test_overrideTelemetryName() {
  enableValidationPrefs(["steam"]);

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.overrideTelemetryName = "steam-but-better";
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  const problemsToReport = [
    { name: "someProblem", count: 123 },
    { name: "anotherProblem", count: 456 },
  ];

  try {
    info("Sync with validation problems");
    engine.problemsToReport = problemsToReport;
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        ok(!sync.engines.find(e => e.name === "steam"));
        const eng = sync.engines.find(e => e.name === "steam-but-better");
        ok(eng);
        deepEqual(
          eng.validation,
          {
            version: 1,
            checked: 0,
            problems: problemsToReport,
          },
          "Should include validation report with overridden name"
        );
      },
      async () => {
        await Service.sync();
      }
    );

    info("Sync without validation problems");
    engine.problemsToReport = null;
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        ok(!sync.engines.find(e => e.name === "steam"));
        const eng = sync.engines.find(e => e.name === "steam-but-better");
        ok(eng);
        ok(
          !eng.validation,
          "Should not include validation report when there are no problems"
        );
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_engine_fail_ioerror() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  // create an IOError to re-throw as part of Sync.
  try {
    // (Note that fakeservices.js has replaced Utils.jsonMove etc, but for
    // this test we need the real one so we get real exceptions from the
    // filesystem.)
    await Utils._real_jsonMove("file-does-not-exist", "anything", {});
  } catch (ex) {
    engine._errToThrow = ex;
  }
  ok(engine._errToThrow, "expecting exception");

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_engine_fail_ioerror: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        const failureReason = sync.engines.find(
          e => e.name === "steam"
        ).failureReason;
        equal(failureReason.name, "unexpectederror");
        // ensure the profile dir in the exception message has been stripped.
        ok(
          !failureReason.error.includes(PathUtils.profileDir),
          failureReason.error
        );
        ok(failureReason.error.includes("[profileDir]"), failureReason.error);
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_clean_urls() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  engine._errToThrow = new TypeError(
    "http://www.google .com is not a valid URL."
  );

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`);
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        const failureReason = sync.engines.find(
          e => e.name === "steam"
        ).failureReason;
        equal(failureReason.name, "unexpectederror");
        equal(failureReason.error, "<URL> is not a valid URL.");
      },
      async () => {
        await Service.sync();
      }
    );
    // Handle other errors that include urls.
    engine._errToThrow =
      "Other error message that includes some:url/foo/bar/ in it.";
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        const failureReason = sync.engines.find(
          e => e.name === "steam"
        ).failureReason;
        equal(failureReason.name, "unexpectederror");
        equal(
          failureReason.error,
          "Other error message that includes <URL> in it."
        );
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

// Arrange for a sync to hit a "real" OS error during a sync and make sure it's sanitized.
add_task(async function test_clean_real_os_error() {
  enableValidationPrefs();

  // Simulate a real error.
  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let path = PathUtils.join(PathUtils.profileDir, "no", "such", "path.json");
  try {
    await IOUtils.readJSON(path);
    throw new Error("should fail to read the file");
  } catch (ex) {
    engine._errToThrow = ex;
  }

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`);
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        const failureReason = sync.engines.find(
          e => e.name === "steam"
        ).failureReason;
        equal(failureReason.name, "unexpectederror");
        equal(
          failureReason.error,
          "OS error [File/Path not found] Could not open `[profileDir]/no/such/path.json': file does not exist"
        );
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_initial_sync_engines() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  // These are the only ones who actually have things to sync at startup.
  let telemetryEngineNames = ["clients", "prefs", "tabs", "bookmarks-buffered"];
  let server = await serverForEnginesWithKeys(
    { foo: "password" },
    ["bookmarks", "prefs", "tabs"].map(name => Service.engineManager.get(name))
  );
  await SyncTestingInfrastructure(server);
  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_initial_sync_engines: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.engines.find(e => e.name === "clients").outgoing[0].sent, 1);
        equal(sync.engines.find(e => e.name === "tabs").outgoing[0].sent, 1);

        sync.engines
          .filter(e => telemetryEngineNames.includes(e.name))
          .forEach(e => {
            greaterOrEqual(e.took, 1);
            ok(!!e.outgoing);
            equal(e.outgoing.length, 1);
            notEqual(e.outgoing[0].sent, undefined);
            equal(e.outgoing[0].failed, undefined);
            equal(e.outgoing[0].failedReasons, undefined);
          });
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_nserror() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  engine._errToThrow = Components.Exception(
    "NS_ERROR_UNKNOWN_HOST",
    Cr.NS_ERROR_UNKNOWN_HOST
  );
  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_nserror: Steam tracker contents: ${JSON.stringify(changes)}`);
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        deepEqual(sync.status, {
          service: SYNC_FAILED_PARTIAL,
          sync: LOGIN_FAILED_NETWORK_ERROR,
        });
        const eng = sync.engines.find(e => e.name === "steam");
        deepEqual(eng.failureReason, {
          name: "httperror",
          code: Cr.NS_ERROR_UNKNOWN_HOST,
        });
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_sync_why() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let e = new Error("generic failure message");
  engine._errToThrow = e;

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32));

        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);

        const sync = syncs[0];
        equal(sync.why, "user");
      },
      async () => {
        await Service.sync({ why: "user" });
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_discarding() {
  enableValidationPrefs();

  let helper = track_collections_helper();
  let upd = helper.with_updated_collection;
  let telem = get_sync_test_telemetry();
  telem.maxPayloadCount = 2;
  telem.submissionInterval = Infinity;

  let server;
  try {
    let handlers = {
      "/1.1/johndoe/info/collections": helper.handler,
      "/1.1/johndoe/storage/crypto/keys": upd(
        "crypto",
        new ServerWBO("keys").handler()
      ),
      "/1.1/johndoe/storage/meta/global": upd(
        "meta",
        new ServerWBO("global").handler()
      ),
    };

    let collections = [
      "clients",
      "bookmarks",
      "forms",
      "history",
      "passwords",
      "prefs",
      "tabs",
    ];

    for (let coll of collections) {
      handlers["/1.1/johndoe/storage/" + coll] = upd(
        coll,
        new ServerCollection({}, true).handler()
      );
    }

    server = httpd_setup(handlers);
    await configureIdentity({ username: "johndoe" }, server);
    GleanPings.sync.testBeforeNextSubmit(() => {
      ok(false, "Submitted telemetry ping when we should not have.");
    });

    for (let i = 0; i < 5; ++i) {
      await Service.sync();
    }
    telem.submissionInterval = -1;

    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        equal(Glean.syncs.discarded.testGetValue(), 4);
        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 2);
        syncs.forEach(assert_success_sync);
      },
      async () => {
        await Service.sync(); // Sixth time's the charm.
      }
    );
  } finally {
    telem.maxPayloadCount = 500;
    telem.submissionInterval = -1;
    if (server) {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_no_foreign_engines_in_error_ping() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  engine._errToThrow = new Error("Oh no!");
  await SyncTestingInfrastructure(server);
  try {
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);
        const sync = syncs[0];
        equal(sync.status.service, SYNC_FAILED_PARTIAL);
        ok(sync.engines.every(e => e.name !== "bogus"));
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_no_foreign_engines_in_success_ping() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  await SyncTestingInfrastructure(server);
  try {
    await GleanPings.sync.testSubmission(
      reason => {
        equal(reason, "schedule");
        const syncs = Glean.syncs.syncs.testGetValue();
        equal(syncs.length, 1);
        const sync = syncs[0];
        ok(sync.engines.every(e => e.name !== "bogus"));
      },
      async () => {
        await Service.sync();
      }
    );
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_no_node_type() {
  let server = sync_httpd_setup({});
  await configureIdentity(null, server);

  await GleanPings.sync.testSubmission(
    reason => {
      equal(reason, "schedule");
      Assert.strictEqual(Glean.syncs.syncNodeType.testGetValue(), null);
    },
    async () => {
      await Service.sync();
    }
  );
  await promiseStopServer(server);
});

add_task(async function test_node_type() {
  Service.identity.logout();
  let server = sync_httpd_setup({});
  await configureIdentity({ node_type: "the-node-type" }, server);

  await GleanPings.sync.testSubmission(
    reason => {
      equal(reason, "schedule");
      equal(Glean.syncs.syncNodeType.testGetValue(), "the-node-type");
    },
    async () => {
      await Service.sync();
    }
  );
  await promiseStopServer(server);
});

add_task(async function test_node_type_change() {
  Service.identity.logout();
  let server = sync_httpd_setup({});
  await configureIdentity({ node_type: "first-node-type" }, server);
  // Default to submitting each hour - we should still submit on node change.
  let telem = get_sync_test_telemetry();
  telem.submissionInterval = 60 * 60 * 1000;
  // reset the node type from previous test or our first sync will submit.
  telem.lastSyncNodeType = null;
  // do 2 syncs with the same node type.
  await Service.sync();
  await Service.sync();
  // then another with a different node type.
  Service.identity.logout();
  await configureIdentity({ node_type: "second-node-type" }, server);
  await GleanPings.sync.testSubmission(
    () => {
      equal(
        Glean.syncs.syncs.testGetValue().length,
        2,
        "2 syncs in first ping"
      );
      equal(Glean.syncs.syncNodeType.testGetValue(), "first-node-type");
    },
    async () => {
      await Service.sync();
    }
  );
  await GleanPings.sync.testSubmission(
    () => {
      equal(
        Glean.syncs.syncs.testGetValue().length,
        1,
        "1 sync in second ping"
      );
      equal(Glean.syncs.syncNodeType.testGetValue(), "second-node-type");
    },
    async () => {
      telem.finish();
    }
  );
  await promiseStopServer(server);
});

add_task(async function test_deletion_request_ping() {
  async function assertRecordedSyncDeviceID(expected) {
    // `onAccountInitOrChange` sets the id asynchronously, so wait a tick.
    await Promise.resolve();
    equal(Glean.deletionRequest.syncDeviceId.testGetValue(), expected);
  }
  Services.fog.testResetFOG();

  const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff";
  const MOCK_DEVICE_ID1 = "ffeeddccbbaa99887766554433221100";
  const MOCK_DEVICE_ID2 = "aabbccddeeff99887766554433221100";

  // Calculated by hand using SHA256(DEVICE_ID + HASHED_UID)[:32]
  const SANITIZED_DEVICE_ID1 = "dd7c845006df9baa1c6d756926519c8c";
  const SANITIZED_DEVICE_ID2 = "0d06919a736fc029007e1786a091882c";

  let currentDeviceID = null;
  sinon.stub(fxAccounts.device, "getLocalId").callsFake(() => {
    return Promise.resolve(currentDeviceID);
  });
  let telem = get_sync_test_telemetry();
  sinon.stub(telem, "isProductionSyncUser").callsFake(() => true);
  fxAccounts.telemetry._setHashedUID(false);

  try {
    // The scalar should start out undefined, since no user is actually logged in.
    await assertRecordedSyncDeviceID(null);

    // If we start up without knowing the hashed UID, it should stay undefined.
    telem.observe(null, "weave:service:ready");
    await assertRecordedSyncDeviceID(null);

    // But now let's say we've discovered the hashed UID from the server.
    fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
    currentDeviceID = MOCK_DEVICE_ID1;

    // Now when we load up, we'll record the sync device id.
    telem.observe(null, "weave:service:ready");
    await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID1);

    // When the device-id changes we'll update it.
    currentDeviceID = MOCK_DEVICE_ID2;
    telem.observe(null, "fxaccounts:new_device_id");
    await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID2);

    // When the user signs out we'll clear it.
    telem.observe(null, "fxaccounts:onlogout");
    await assertRecordedSyncDeviceID("");
  } finally {
    fxAccounts.telemetry._setHashedUID(false);
    telem.isProductionSyncUser.restore();
    fxAccounts.device.getLocalId.restore();
  }
});

// TODO: Is the topic `"weave:telemetry:migration"` presently hooked up?
add_task(async function test_migration() {
  let telem = get_sync_test_telemetry();
  const migrationInfo = {
    entries: 42,
    entries_successful: 42,
    extensions: 84,
    extensions_successful: 83,
    openFailure: false,
  };
  telem._addMigrationRecord("webext-storage", migrationInfo);
  await GleanPings.sync.testSubmission(
    () => {
      const migrations = Glean.syncs.migrations.testGetValue();
      equal(migrations.length, 1);
      deepEqual(migrations[0], {
        migration_type: "webext-storage",
        entries: 42,
        entriesSuccessful: 42,
        extensions: 84,
        extensionsSuccessful: 83,
        openFailure: false,
      });
    },
    () => {
      telem.finish();
    }
  );
});
