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

// Tests for `History.update` as implemented in History.sys.mjs

"use strict";

add_task(async function test_error_cases() {
  Assert.throws(
    () => PlacesUtils.history.update("not an object"),
    /Error: PageInfo: Input should be a valid object/,
    "passing a string as pageInfo should throw an Error"
  );
  Assert.throws(
    () => PlacesUtils.history.update(null),
    /Error: PageInfo: Input should be/,
    "passing a null as pageInfo should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        description: "Test description",
      }),
    /Error: PageInfo: The following properties were expected: url, guid/,
    "not included a url or a guid should throw"
  );
  Assert.throws(
    () => PlacesUtils.history.update({ url: "not a valid url string" }),
    /Error: PageInfo: Invalid value for property/,
    "passing an invalid url should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        description: 123,
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a non-string description in pageInfo should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        guid: "invalid guid",
        description: "Test description",
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a invalid guid in pageInfo should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        previewImageURL: "not a valid url string",
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing an invlid preview image url in pageInfo should throw an Error"
  );
  Assert.throws(
    () => {
      let imageName = "a-very-long-string".repeat(10000);
      let previewImageURL = `http://valid.uri.com/${imageName}.png`;
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        previewImageURL,
      });
    },
    /Error: PageInfo: Invalid value for property/,
    "passing an oversized previewImageURL in pageInfo should throw an Error"
  );
  Assert.throws(
    () => PlacesUtils.history.update({ url: "http://valid.uri.com" }),
    /TypeError: pageInfo object must at least/,
    "passing a pageInfo with neither description, previewImageURL, nor annotations should throw a TypeError"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        annotations: "asd",
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a pageInfo with incorrect annotations type should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        annotations: new Map(),
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a pageInfo with an empty annotations type should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        annotations: new Map([[1234, "value"]]),
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a pageInfo with an invalid key type should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        annotations: new Map([["test", ["myarray"]]]),
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a pageInfo with an invalid key type should throw an Error"
  );
  Assert.throws(
    () =>
      PlacesUtils.history.update({
        url: "http://valid.uri.com",
        annotations: new Map([["test", { anno: "value" }]]),
      }),
    /Error: PageInfo: Invalid value for property/,
    "passing a pageInfo with an invalid key type should throw an Error"
  );
});

add_task(async function test_description_change_saved() {
  await PlacesUtils.history.clear();

  let TEST_URL = "http://mozilla.org/test_description_change_saved";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));

  let description = "Test description";
  await PlacesUtils.history.update({ url: TEST_URL, description });
  let descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    { url: TEST_URL }
  );
  Assert.equal(
    description,
    descriptionInDB,
    "description should be updated via URL as expected"
  );

  description = "";
  await PlacesUtils.history.update({ url: TEST_URL, description });
  descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    { url: TEST_URL }
  );
  Assert.strictEqual(
    null,
    descriptionInDB,
    "an empty description should set it to null in the database"
  );

  let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
    url: TEST_URL,
  });
  description = "Test description";
  await PlacesUtils.history.update({ url: TEST_URL, guid, description });
  descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    { url: TEST_URL }
  );
  Assert.equal(
    description,
    descriptionInDB,
    "description should be updated via GUID as expected"
  );

  description = "Test descipriton".repeat(1000);
  await PlacesUtils.history.update({ url: TEST_URL, description });
  descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    { url: TEST_URL }
  );
  // This test is bogus, the lhs of the comparison is a boolean expression,
  // but the intent is to compare the lengths of the two strings.
  // Caught by the linter, tracked in bug 1973910.
  // eslint-disable-next-line mozilla/no-comparison-or-assignment-inside-ok
  Assert.ok(
    !!descriptionInDB.length < description.length,
    "a long description should be truncated"
  );

  description = null;
  await PlacesUtils.history.update({ url: TEST_URL, description });
  descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    { url: TEST_URL }
  );
  Assert.strictEqual(
    description,
    descriptionInDB,
    "a null description should set it to null in the database"
  );
});

add_task(async function test_siteName_change_saved() {
  await PlacesUtils.history.clear();

  let TEST_URL = "http://mozilla.org/test_siteName_change_saved";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));

  let siteName = "Test site name";
  await PlacesUtils.history.update({ url: TEST_URL, siteName });
  let siteNameInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "site_name",
    { url: TEST_URL }
  );
  Assert.equal(
    siteName,
    siteNameInDB,
    "siteName should be updated via URL as expected"
  );

  siteName = "";
  await PlacesUtils.history.update({ url: TEST_URL, siteName });
  siteNameInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "site_name",
    { url: TEST_URL }
  );
  Assert.strictEqual(
    null,
    siteNameInDB,
    "an empty siteName should set it to null in the database"
  );

  let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
    url: TEST_URL,
  });
  siteName = "Test site name";
  await PlacesUtils.history.update({ url: TEST_URL, guid, siteName });
  siteNameInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "site_name",
    { url: TEST_URL }
  );
  Assert.equal(
    siteName,
    siteNameInDB,
    "siteName should be updated via GUID as expected"
  );

  siteName = "Test site name".repeat(1000);
  await PlacesUtils.history.update({ url: TEST_URL, siteName });
  siteNameInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "site_name",
    {
      url: TEST_URL,
    }
  );
  // This test is bogus, the lhs of the comparison is a boolean expression,
  // but the intent is to compare the lengths of the two strings.
  // Caught by the linter, tracked in bug 1973910.
  // eslint-disable-next-line mozilla/no-comparison-or-assignment-inside-ok
  Assert.ok(
    !!siteNameInDB.length < siteName.length,
    "a long siteName should be truncated"
  );

  siteName = null;
  await PlacesUtils.history.update({ url: TEST_URL, siteName });
  siteNameInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "site_name",
    {
      url: TEST_URL,
    }
  );
  Assert.strictEqual(
    siteName,
    siteNameInDB,
    "a null siteName should set it to null in the database"
  );
});

add_task(async function test_previewImageURL_change_saved() {
  await PlacesUtils.history.clear();

  let TEST_URL = "http://mozilla.org/test_previewImageURL_change_saved";
  let IMAGE_URL = "http://mozilla.org/test_preview_image.png";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));

  let previewImageURL = IMAGE_URL;
  await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
  let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.equal(
    previewImageURL,
    previewImageURLInDB,
    "previewImageURL should be updated via URL as expected"
  );

  previewImageURL = null;
  await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
  previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.strictEqual(
    null,
    previewImageURLInDB,
    "a null previewImageURL should set it to null in the database"
  );

  let guid = await PlacesTestUtils.getDatabaseValue("moz_places", "guid", {
    url: TEST_URL,
  });
  previewImageURL = IMAGE_URL;
  await PlacesUtils.history.update({ guid, previewImageURL });
  previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.equal(
    previewImageURL,
    previewImageURLInDB,
    "previewImageURL should be updated via GUID as expected"
  );

  previewImageURL = "";
  await PlacesUtils.history.update({ url: TEST_URL, previewImageURL });
  previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.strictEqual(
    null,
    previewImageURLInDB,
    "an empty previewImageURL should set it to null in the database"
  );
});

add_task(async function test_change_description_and_preview_saved() {
  await PlacesUtils.history.clear();

  let TEST_URL = "http://mozilla.org/test_change_both_saved";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(await PlacesTestUtils.isPageInDB(TEST_URL));

  let description = "Test description";
  let previewImageURL = "http://mozilla.org/test_preview_image.png";

  await PlacesUtils.history.update({
    url: TEST_URL,
    description,
    previewImageURL,
  });
  let descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    {
      url: TEST_URL,
    }
  );
  let previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.equal(
    description,
    descriptionInDB,
    "description should be updated via URL as expected"
  );
  Assert.equal(
    previewImageURL,
    previewImageURLInDB,
    "previewImageURL should be updated via URL as expected"
  );

  // Update description should not touch other fields
  description = null;
  await PlacesUtils.history.update({ url: TEST_URL, description });
  descriptionInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "description",
    {
      url: TEST_URL,
    }
  );
  previewImageURLInDB = await PlacesTestUtils.getDatabaseValue(
    "moz_places",
    "preview_image_url",
    {
      url: TEST_URL,
    }
  );
  Assert.strictEqual(
    description,
    descriptionInDB,
    "description should be updated via URL as expected"
  );
  Assert.equal(
    previewImageURL,
    previewImageURLInDB,
    "previewImageURL should not be updated"
  );
});

/**
 * Gets annotation information from the database for the specified URL and
 * annotation name.
 *
 * @param {string} pageUrl The URL to search for.
 * @param {string} annoName The name of the annotation to search for.
 * @returns {Array} An array of objects containing the annotations found.
 */
async function getAnnotationInfoFromDB(pageUrl, annoName) {
  let db = await PlacesUtils.promiseDBConnection();

  let rows = await db.execute(
    `
    SELECT a.content, a.flags, a.expiration, a.type FROM moz_anno_attributes n
    JOIN moz_annos a ON n.id = a.anno_attribute_id
    JOIN moz_places h ON h.id = a.place_id
    WHERE h.url_hash = hash(:pageUrl) AND h.url = :pageUrl
      AND n.name = :annoName
  `,
    { annoName, pageUrl }
  );

  let result = rows.map(row => {
    return {
      content: row.getResultByName("content"),
      flags: row.getResultByName("flags"),
      expiration: row.getResultByName("expiration"),
      type: row.getResultByName("type"),
    };
  });

  return result;
}

add_task(async function test_simple_change_annotations() {
  await PlacesUtils.history.clear();

  const TEST_URL = "http://mozilla.org/test_change_both_saved";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(
    await PlacesTestUtils.isPageInDB(TEST_URL),
    "Should have inserted the page into the database."
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([["test/annotation", "testContent"]]),
  });

  let pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    1,
    "Should have one annotation for the page"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation"),
    "testContent",
    "Should have the correct annotation"
  );

  let annotationInfo = await getAnnotationInfoFromDB(
    TEST_URL,
    "test/annotation"
  );
  Assert.deepEqual(
    {
      content: "testContent",
      flags: 0,
      type: PlacesUtils.history.ANNOTATION_TYPE_STRING,
      expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER,
    },
    annotationInfo[0],
    "Should have stored the correct annotation data in the db"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([["test/annotation2", "testAnno"]]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    2,
    "Should have two annotations for the page"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation"),
    "testContent",
    "Should have the correct value for the first annotation"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation2"),
    "testAnno",
    "Should have the correct value for the second annotation"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([["test/annotation", 1234]]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    2,
    "Should still have two annotations for the page"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation"),
    1234,
    "Should have the updated the first annotation value"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation2"),
    "testAnno",
    "Should have kept the value for the second annotation"
  );

  annotationInfo = await getAnnotationInfoFromDB(TEST_URL, "test/annotation");
  Assert.deepEqual(
    {
      content: 1234,
      flags: 0,
      type: PlacesUtils.history.ANNOTATION_TYPE_INT64,
      expiration: PlacesUtils.history.ANNOTATION_EXPIRE_NEVER,
    },
    annotationInfo[0],
    "Should have updated the annotation data in the db"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([["test/annotation", null]]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    1,
    "Should have removed only the first annotation"
  );
  Assert.strictEqual(
    pageInfo.annotations.get("test/annotation"),
    undefined,
    "Should have removed only the first annotation"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation2"),
    "testAnno",
    "Should have kept the value for the second annotation"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([["test/annotation2", null]]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left");

  let db = await PlacesUtils.promiseDBConnection();
  let rows = await db.execute(`
    SELECT * FROM moz_annos
  `);
  Assert.equal(rows.length, 0, "Should be no annotations left in the db");
});

add_task(async function test_change_multiple_annotations() {
  await PlacesUtils.history.clear();

  const TEST_URL = "http://mozilla.org/test_change_both_saved";
  await PlacesTestUtils.addVisits(TEST_URL);
  Assert.ok(
    await PlacesTestUtils.isPageInDB(TEST_URL),
    "Should have inserted the page into the database."
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([
      ["test/annotation", "testContent"],
      ["test/annotation2", "testAnno"],
    ]),
  });

  let pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    2,
    "Should have inserted the two annotations for the page."
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation"),
    "testContent",
    "Should have the correct value for the first annotation"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation2"),
    "testAnno",
    "Should have the correct value for the second annotation"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([
      ["test/annotation", 123456],
      ["test/annotation2", 135246],
    ]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(
    pageInfo.annotations.size,
    2,
    "Should have two annotations for the page"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation"),
    123456,
    "Should have the correct value for the first annotation"
  );
  Assert.equal(
    pageInfo.annotations.get("test/annotation2"),
    135246,
    "Should have the correct value for the second annotation"
  );

  await PlacesUtils.history.update({
    url: TEST_URL,
    annotations: new Map([
      ["test/annotation", null],
      ["test/annotation2", null],
    ]),
  });

  pageInfo = await PlacesUtils.history.fetch(TEST_URL, {
    includeAnnotations: true,
  });

  Assert.equal(pageInfo.annotations.size, 0, "Should have no annotations left");
});

add_task(async function test_annotations_nonexisting_page() {
  info("Adding annotations to a non existing page should be silent");
  await PlacesUtils.history.update({
    url: "http://nonexisting.moz/",
    annotations: new Map([["test/annotation", null]]),
  });
});

add_task(async function test_annotations_nonexisting_page() {
  info("Adding annotations to a non existing page should be silent");
  await PlacesUtils.history.update({
    url: "http://nonexisting.moz/",
    annotations: new Map([["test/annotation", null]]),
  });
});
