diff options
Diffstat (limited to 'toolkit/components/places/tests/sync')
23 files changed, 17962 insertions, 0 deletions
diff --git a/toolkit/components/places/tests/sync/head_sync.js b/toolkit/components/places/tests/sync/head_sync.js new file mode 100644 index 0000000000..7dd69e275b --- /dev/null +++ b/toolkit/components/places/tests/sync/head_sync.js @@ -0,0 +1,461 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Import common head. +{ + /* import-globals-from ../head_common.js */ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +var { CanonicalJSON } = ChromeUtils.importESModule( + "resource://gre/modules/CanonicalJSON.sys.mjs" +); +var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs"); + +var { PlacesSyncUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesSyncUtils.sys.mjs" +); +var { SyncedBookmarksMirror } = ChromeUtils.importESModule( + "resource://gre/modules/SyncedBookmarksMirror.sys.mjs" +); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +var { + HTTP_400, + HTTP_401, + HTTP_402, + HTTP_403, + HTTP_404, + HTTP_405, + HTTP_406, + HTTP_407, + HTTP_408, + HTTP_409, + HTTP_410, + HTTP_411, + HTTP_412, + HTTP_413, + HTTP_414, + HTTP_415, + HTTP_417, + HTTP_500, + HTTP_501, + HTTP_502, + HTTP_503, + HTTP_504, + HTTP_505, + HttpError, + HttpServer, +} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs"); + +// These titles are defined in Database::CreateBookmarkRoots +const BookmarksMenuTitle = "menu"; +const BookmarksToolbarTitle = "toolbar"; +const UnfiledBookmarksTitle = "unfiled"; +const MobileBookmarksTitle = "mobile"; + +function run_test() { + let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror"); + bufLog.level = Log.Level.All; + + let sqliteLog = Log.repository.getLogger("Sqlite"); + sqliteLog.level = Log.Level.Error; + + let formatter = new Log.BasicFormatter(); + let appender = new Log.DumpAppender(formatter); + appender.level = Log.Level.All; + + for (let log of [bufLog, sqliteLog]) { + log.addAppender(appender); + } + + do_get_profile(); + run_next_test(); +} + +// A test helper to insert local roots directly into Places, since the public +// bookmarks APIs no longer support custom roots. +async function insertLocalRoot({ guid, title }) { + await PlacesUtils.withConnectionWrapper( + "insertLocalRoot", + async function (db) { + let dateAdded = PlacesUtils.toPRTime(new Date()); + await db.execute( + ` + INSERT INTO moz_bookmarks(guid, type, parent, position, title, + dateAdded, lastModified) + VALUES(:guid, :type, (SELECT id FROM moz_bookmarks + WHERE guid = :parentGuid), + (SELECT COUNT(*) FROM moz_bookmarks + WHERE parent = (SELECT id FROM moz_bookmarks + WHERE guid = :parentGuid)), + :title, :dateAdded, :dateAdded)`, + { + guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.rootGuid, + title, + dateAdded, + } + ); + } + ); +} + +// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext. +// This exists to avoid importing `record.js` from Sync. +function makeRecord(cleartext) { + return new Proxy( + { cleartext }, + { + get(target, property, receiver) { + if (property == "cleartext") { + return target.cleartext; + } + if (property == "cleartextToString") { + return () => JSON.stringify(target.cleartext); + } + return target.cleartext[property]; + }, + set(target, property, value, receiver) { + if (property == "cleartext") { + target.cleartext = value; + } else if (property != "cleartextToString") { + target.cleartext[property] = value; + } + }, + has(target, property) { + return property == "cleartext" || property in target.cleartext; + }, + deleteProperty(target, property) {}, + ownKeys(target) { + return ["cleartext", ...Reflect.ownKeys(target)]; + }, + } + ); +} + +async function storeRecords(buf, records, options) { + await buf.store(records.map(makeRecord), options); +} + +async function storeChangesInMirror(buf, changesToUpload) { + let cleartexts = []; + for (let recordId in changesToUpload) { + changesToUpload[recordId].synced = true; + cleartexts.push(changesToUpload[recordId].cleartext); + } + await storeRecords(buf, cleartexts, { needsMerge: false }); + await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload); +} + +function inspectChangeRecords(changeRecords) { + let results = { updated: [], deleted: [] }; + for (let [id, record] of Object.entries(changeRecords)) { + (record.tombstone ? results.deleted : results.updated).push(id); + } + results.updated.sort(); + results.deleted.sort(); + return results; +} + +async function promiseManyDatesAdded(guids) { + let datesAdded = new Map(); + let db = await PlacesUtils.promiseDBConnection(); + for (let chunk of PlacesUtils.chunkArray(guids, 100)) { + let rows = await db.executeCached( + ` + SELECT guid, dateAdded FROM moz_bookmarks + WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`, + chunk + ); + if (rows.length != chunk.length) { + throw new TypeError("Can't fetch date added for nonexistent items"); + } + for (let row of rows) { + let dateAdded = row.getResultByName("dateAdded") / 1000; + datesAdded.set(row.getResultByName("guid"), dateAdded); + } + } + return datesAdded; +} + +async function fetchLocalTree(rootGuid) { + function bookmarkNodeToInfo(node) { + let { guid, index, title, typeCode: type } = node; + let itemInfo = { guid, index, title, type }; + if (node.annos) { + let syncableAnnos = node.annos.filter(anno => + [PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes( + anno.name + ) + ); + if (syncableAnnos.length) { + itemInfo.annos = syncableAnnos; + } + } + if (node.uri) { + itemInfo.url = node.uri; + } + if (node.keyword) { + itemInfo.keyword = node.keyword; + } + if (node.children) { + itemInfo.children = node.children.map(bookmarkNodeToInfo); + } + if (node.tags) { + itemInfo.tags = node.tags.split(",").sort(); + } + return itemInfo; + } + let root = await PlacesUtils.promiseBookmarksTree(rootGuid); + return bookmarkNodeToInfo(root); +} + +async function assertLocalTree(rootGuid, expected, message) { + let actual = await fetchLocalTree(rootGuid); + if (!ObjectUtils.deepEqual(actual, expected)) { + info( + `Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}` + ); + info( + `Actual structure for ${rootGuid}: ${CanonicalJSON.stringify(actual)}` + ); + throw new Assert.constructor.AssertionError({ actual, expected, message }); + } +} + +function makeLivemarkServer() { + let server = new HttpServer(); + server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml")); + server.start(-1); + return { + server, + get site() { + let { identity } = server; + let host = identity.primaryHost.includes(":") + ? `[${identity.primaryHost}]` + : identity.primaryHost; + return `${identity.primaryScheme}://${host}:${identity.primaryPort}`; + }, + stopServer() { + return new Promise(resolve => server.stop(resolve)); + }, + }; +} + +function shuffle(array) { + let results = []; + for (let i = 0; i < array.length; ++i) { + let randomIndex = Math.floor(Math.random() * (i + 1)); + results[i] = results[randomIndex]; + results[randomIndex] = array[i]; + } + return results; +} + +async function fetchAllKeywords(info) { + let entries = []; + await PlacesUtils.keywords.fetch(info, entry => entries.push(entry)); + return entries; +} + +async function openMirror(name, options = {}) { + let buf = await SyncedBookmarksMirror.open({ + path: `${name}_buf.sqlite`, + recordStepTelemetry(...args) { + if (options.recordStepTelemetry) { + options.recordStepTelemetry.call(this, ...args); + } + }, + recordValidationTelemetry(...args) { + if (options.recordValidationTelemetry) { + options.recordValidationTelemetry.call(this, ...args); + } + }, + }); + return buf; +} + +function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) { + this.notifications = []; + this.ignoreDates = ignoreDates; + this.skipTags = skipTags; + this.handlePlacesEvents = this.handlePlacesEvents.bind(this); +} + +BookmarkObserver.prototype = { + handlePlacesEvents(events) { + for (let event of events) { + switch (event.type) { + case "bookmark-added": { + if (this.skipTags && event.isTagging) { + continue; + } + let params = { + itemId: event.id, + parentId: event.parentId, + index: event.index, + type: event.itemType, + urlHref: event.url, + title: event.title, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + tags: event.tags, + frecency: event.frecency, + hidden: event.hidden, + visitCount: event.visitCount, + }; + if (!this.ignoreDates) { + params.dateAdded = event.dateAdded; + params.lastVisitDate = event.lastVisitDate; + } + this.notifications.push({ name: "bookmark-added", params }); + break; + } + case "bookmark-removed": { + if (this.skipTags && event.isTagging) { + continue; + } + // Since we are now skipping tags on the listener side we don't + // prevent unTagging notifications from going out. These events cause empty + // tags folders to be removed which creates another bookmark-removed notification + if ( + this.skipTags && + event.parentGuid == PlacesUtils.bookmarks.tagsGuid + ) { + continue; + } + let params = { + itemId: event.id, + parentId: event.parentId, + index: event.index, + type: event.itemType, + urlHref: event.url || null, + title: event.title, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + }; + this.notifications.push({ name: "bookmark-removed", params }); + break; + } + case "bookmark-moved": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + source: event.source, + guid: event.guid, + newIndex: event.index, + newParentGuid: event.parentGuid, + oldIndex: event.oldIndex, + oldParentGuid: event.oldParentGuid, + isTagging: event.isTagging, + title: event.title, + tags: event.tags, + frecency: event.frecency, + hidden: event.hidden, + visitCount: event.visitCount, + dateAdded: event.dateAdded, + lastVisitDate: event.lastVisitDate, + }; + this.notifications.push({ name: "bookmark-moved", params }); + break; + } + case "bookmark-guid-changed": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }; + this.notifications.push({ name: "bookmark-guid-changed", params }); + break; + } + case "bookmark-title-changed": { + const params = { + itemId: event.id, + guid: event.guid, + title: event.title, + parentGuid: event.parentGuid, + }; + this.notifications.push({ name: "bookmark-title-changed", params }); + break; + } + case "bookmark-url-changed": { + const params = { + itemId: event.id, + type: event.itemType, + urlHref: event.url, + guid: event.guid, + parentGuid: event.parentGuid, + source: event.source, + isTagging: event.isTagging, + }; + this.notifications.push({ name: "bookmark-url-changed", params }); + break; + } + } + } + }, + + check(expectedNotifications) { + PlacesUtils.observers.removeListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-guid-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + this.handlePlacesEvents + ); + if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) { + info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`); + info(`Actual notifications: ${JSON.stringify(this.notifications)}`); + throw new Assert.constructor.AssertionError({ + actual: this.notifications, + expected: expectedNotifications, + }); + } + }, +}; + +function expectBookmarkChangeNotifications(options) { + let observer = new BookmarkObserver(options); + PlacesUtils.observers.addListener( + [ + "bookmark-added", + "bookmark-removed", + "bookmark-moved", + "bookmark-guid-changed", + "bookmark-title-changed", + "bookmark-url-changed", + ], + observer.handlePlacesEvents + ); + return observer; +} + +// Copies a support file to a temporary fixture file, allowing the support +// file to be reused for multiple tests. +async function setupFixtureFile(fixturePath) { + let fixtureFile = do_get_file(fixturePath); + let tempFile = FileTestUtils.getTempFile(fixturePath); + await IOUtils.copy(fixtureFile.path, tempFile.path); + return tempFile; +} diff --git a/toolkit/components/places/tests/sync/mirror_corrupt.sqlite b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite new file mode 100644 index 0000000000..ed3613447c --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_corrupt.sqlite @@ -0,0 +1 @@ +Not a database! diff --git a/toolkit/components/places/tests/sync/mirror_v1.sqlite b/toolkit/components/places/tests/sync/mirror_v1.sqlite Binary files differnew file mode 100644 index 0000000000..f0b8853616 --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_v1.sqlite diff --git a/toolkit/components/places/tests/sync/mirror_v5.sqlite b/toolkit/components/places/tests/sync/mirror_v5.sqlite Binary files differnew file mode 100644 index 0000000000..2a798ae908 --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_v5.sqlite diff --git a/toolkit/components/places/tests/sync/mirror_v8.sqlite b/toolkit/components/places/tests/sync/mirror_v8.sqlite Binary files differnew file mode 100644 index 0000000000..94d559f08d --- /dev/null +++ b/toolkit/components/places/tests/sync/mirror_v8.sqlite diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.html b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html new file mode 100644 index 0000000000..53ad366b1f --- /dev/null +++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.html @@ -0,0 +1,18 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1>Bookmarks Menu</H1> + +<DL><p> + <DT><A HREF="https://www.mozilla.org/" ADD_DATE="1471365662" LAST_MODIFIED="1471366005" LAST_CHARSET="UTF-8">Mozilla</A> + <DD>Mozilla home + <DT><H3 ADD_DATE="1449080379" LAST_MODIFIED="1471366005" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> + <DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="https://www.mozilla.org/en-US/firefox/" ADD_DATE="1471365681" LAST_MODIFIED="1471366005" SHORTCUTURL="fx" LAST_CHARSET="UTF-8" TAGS="browser">Firefox</A> + <DD>Firefox home + </DL><p> +</DL> diff --git a/toolkit/components/places/tests/sync/sync_utils_bookmarks.json b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json new file mode 100644 index 0000000000..961140843d --- /dev/null +++ b/toolkit/components/places/tests/sync/sync_utils_bookmarks.json @@ -0,0 +1,94 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1449080379324000, + "lastModified": 1471365727344000, + "id": 1, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "Bookmarks Menu", + "index": 0, + "dateAdded": 1449080379324000, + "lastModified": 1471365683893000, + "id": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "NnvGl3CRA4hC", + "title": "Mozilla", + "index": 0, + "dateAdded": 1471365662585000, + "lastModified": 1471365667573000, + "id": 6, + "charset": "UTF-8", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Mozilla home" + } + ], + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/" + } + ] + }, + { + "guid": "toolbar_____", + "title": "Bookmarks Toolbar", + "index": 1, + "dateAdded": 1449080379324000, + "lastModified": 1471365683893000, + "id": 3, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "APzP8MupzA8l", + "title": "Firefox", + "index": 0, + "dateAdded": 1471365681801000, + "lastModified": 1471365687887000, + "id": 7, + "charset": "UTF-8", + "tags": "browser", + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "value": "Firefox home" + } + ], + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/en-US/firefox/", + "keyword": "fx" + } + ] + }, + { + "guid": "unfiled_____", + "title": "Other Bookmarks", + "index": 3, + "dateAdded": 1449080379324000, + "lastModified": 1471365629626000, + "id": 5, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + } + ] +} diff --git a/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js new file mode 100644 index 0000000000..877feb99f4 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_abort_merging.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +var { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); + +add_task(async function test_transaction_in_progress() { + let buf = await openMirror("transaction_in_progress"); + + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + // This transaction should block merging until the transaction is committed. + info("Open transaction on Places connection"); + await buf.db.execute("BEGIN EXCLUSIVE"); + + await Assert.rejects( + buf.apply(), + ex => ex.name == "MergeConflictError", + "Should not merge when a transaction is in progress" + ); + + info("Commit open transaction"); + await buf.db.execute("COMMIT"); + + info("Merging should succeed after committing"); + await buf.apply(); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_abort_store() { + let buf = await openMirror("abort_store"); + + let controller = new AbortController(); + controller.abort(); + await Assert.rejects( + storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + ], + { signal: controller.signal } + ), + ex => ex.name == "InterruptedError", + "Should abort storing when signaled" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_abort_merging() { + let buf = await openMirror("abort_merging"); + + let controller = new AbortController(); + controller.abort(); + await Assert.rejects( + buf.apply({ signal: controller.signal }), + ex => ex.name == "InterruptedError", + "Should abort merge when signaled" + ); + + // Even though the merger is already finalized on the Rust side, the DB + // connection is still open on the JS side. Finalizing `buf` closes it. + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_blocker_state() { + let barrier = new AsyncShutdown.Barrier("Test"); + let buf = await SyncedBookmarksMirror.open({ + path: "blocker_state_buf.sqlite", + finalizeAt: barrier.client, + recordStepTelemetry(...args) {}, + recordValidationTelemetry(...args) {}, + }); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + await buf.tryApply(buf.finalizeController.signal); + await barrier.wait(); + + let state = buf.progress.fetchState(); + let names = []; + for (let s of state.steps) { + equal(typeof s.at, "number", `Should report timestamp for ${s.step}`); + switch (s.step) { + case "fetchLocalTree": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch local tree" + ); + deepEqual( + s.counts, + [ + { name: "items", count: 6 }, + { name: "deletions", count: 0 }, + ], + "Should report number of items in local tree" + ); + break; + + case "fetchRemoteTree": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch remote tree" + ); + deepEqual( + s.counts, + [ + { name: "items", count: 6 }, + { name: "deletions", count: 0 }, + ], + "Should report number of items in remote tree" + ); + break; + + case "merge": + greaterOrEqual(s.took, 0, "Should report time taken to merge"); + deepEqual( + s.counts, + [{ name: "items", count: 6 }], + "Should report merge stats" + ); + break; + + case "apply": + greaterOrEqual(s.took, 0, "Should report time taken to apply"); + ok(!("counts" in s), "Should not report counts for applying"); + break; + + case "notifyObservers": + greaterOrEqual( + s.took, + 0, + "Should report time taken to notify observers" + ); + ok(!("counts" in s), "Should not report counts for observers"); + break; + + case "fetchLocalChangeRecords": + greaterOrEqual( + s.took, + 0, + "Should report time taken to fetch records for upload" + ); + deepEqual( + s.counts, + [{ name: "items", count: 4 }], + "Should report number of records to upload" + ); + break; + + case "finalize": + ok(!("took" in s), "Should not report time taken to finalize"); + ok(!("counts" in s), "Should not report counts for finalizing"); + } + names.push(s.step); + } + deepEqual( + names, + [ + "fetchLocalTree", + "fetchRemoteTree", + "merge", + "apply", + "notifyObservers", + "fetchLocalChangeRecords", + "finalize", + ], + "Should report merge progress after waiting on blocker" + ); + ok( + buf.finalizeController.signal.aborted, + "Should abort finalize signal on shutdown" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_chunking.js b/toolkit/components/places/tests/sync/test_bookmark_chunking.js new file mode 100644 index 0000000000..3652502a3d --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_chunking.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// These tests ensure we correctly chunk statements that exceed SQLite's +// binding parameter limit. + +// Inserts 1500 unfiled bookmarks. Using `PlacesUtils.bookmarks.insertTree` +// is an order of magnitude slower, so we write bookmarks directly into the +// database. +async function insertManyUnfiledBookmarks(db, url) { + await db.executeCached( + ` + INSERT OR IGNORE INTO moz_places(id, url, url_hash, rev_host, hidden, + frecency, guid) + VALUES((SELECT id FROM moz_places + WHERE url_hash = hash(:url) AND + url = :url), :url, hash(:url), :revHost, 0, -1, + generate_guid())`, + { url: url.href, revHost: PlacesUtils.getReversedHost(url) } + ); + + let guids = []; + + for (let position = 0; position < 1500; ++position) { + let title = position.toString(10); + let guid = title.padStart(12, "A"); + await db.executeCached( + ` + INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title, + syncStatus, syncChangeCounter) + VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND + url = :url), + :position, :type, :title, :syncStatus, 1)`, + { + guid, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + position, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + } + ); + guids.push(guid); + } + + return guids; +} + +add_task(async function test_merged_item_chunking() { + let buf = await openMirror("merged_item_chunking"); + + info("Set up local tree with 1500 bookmarks"); + let localGuids = await buf.db.executeTransaction(function () { + let url = new URL("http://example.com/a"); + return insertManyUnfiledBookmarks(buf.db, url); + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Set up remote tree with 1500 bookmarks"); + let toolbarRecord = makeRecord({ + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + }); + let records = [toolbarRecord]; + for (let i = 0; i < 1500; ++i) { + let title = i.toString(10); + let guid = title.padStart(12, "B"); + toolbarRecord.children.push(guid); + records.push( + makeRecord({ + id: guid, + parentid: "toolbar", + type: "bookmark", + title, + bmkUri: "http://example.com/b", + }) + ); + } + await buf.store(shuffle(records)); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.unfiledGuid], + "Should leave unfiled with new remote structure unmerged" + ); + + let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + "toolbar" + ); + deepEqual( + localChildRecordIds, + toolbarRecord.children, + "Should apply all remote toolbar children" + ); + + let guidsToUpload = Object.keys(changesToUpload); + deepEqual( + guidsToUpload.sort(), + ["unfiled", ...localGuids].sort(), + "Should upload unfiled and all new local children" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_deletion_chunking() { + let buf = await openMirror("deletion_chunking"); + + info("Set up local tree with 1500 bookmarks"); + let guids = await buf.db.executeTransaction(function () { + let url = new URL("http://example.com/a"); + return insertManyUnfiledBookmarks(buf.db, url); + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Delete them all on the server"); + let records = [ + makeRecord({ + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + }), + ]; + for (let guid of guids) { + records.push( + makeRecord({ + id: guid, + deleted: true, + }) + ); + } + await buf.store(shuffle(records)); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + deepEqual(changesToUpload, {}, "Should take all remote deletions"); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Shouldn't store tombstones for remote deletions"); + + let localChildRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + "unfiled" + ); + deepEqual( + localChildRecordIds, + [], + "Should delete all unfiled children locally" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_corruption.js b/toolkit/components/places/tests/sync/test_bookmark_corruption.js new file mode 100644 index 0000000000..5f0b0afeef --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js @@ -0,0 +1,3290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function reparentItem(db, guid, newParentGuid = null) { + await db.execute( + ` + UPDATE moz_bookmarks SET + parent = IFNULL((SELECT id FROM moz_bookmarks + WHERE guid = :newParentGuid), 0) + WHERE guid = :guid`, + { newParentGuid, guid } + ); +} + +async function getCountOfBookmarkRows(db) { + let queryRows = await db.execute("SELECT COUNT(*) FROM moz_bookmarks"); + Assert.equal(queryRows.length, 1); + return queryRows[0].getResultByIndex(0); +} + +add_task(async function test_multiple_parents() { + let buf = await openMirror("multiple_parents"); + let now = Date.now(); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "toolbar", + parentid: "places", + type: "folder", + modified: now / 1000 - 10, + children: ["bookmarkAAAA"], + }, + { + id: "menu", + parentid: "places", + type: "folder", + modified: now / 1000 - 5, + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + modified: now / 1000 - 3, + children: ["bookmarkBBBB"], + }, + { + id: "mobile", + parentid: "places", + type: "folder", + modified: now / 1000, + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "toolbar", + type: "bookmark", + title: "A", + modified: now / 1000 - 10, + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "mobile", + type: "bookmark", + title: "B", + modified: now / 1000 - 3, + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave items with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, + "bookmarkAAAA", + "bookmarkBBBB", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + title: MobileBookmarksTitle, + children: [], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + }); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + let newChangesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + newChangesToUpload, + {}, + "Should not upload any changes after updating mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_reupload_replace() { + let buf = await openMirror("reupload_replace"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: [], + }, + ], + { needsMerge: false } + ); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "folderBBBBBB", + "queryCCCCCCC", + "queryDDDDDDD", + ], + }, + { + // A has an invalid URL, but exists locally, so we should reupload a valid + // local copy. This discards _all_ remote changes to A. + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + bmkUri: "!@#$%", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B (remote)", + children: ["bookmarkEEEE"], + }, + { + // E is a bookmark with an invalid URL that doesn't exist locally, so we'll + // delete it. + id: "bookmarkEEEE", + parentid: "folderBBBBBB", + type: "bookmark", + title: "E (remote)", + bmkUri: "!@#$%", + }, + { + // C is a legacy tag query, so we'll rewrite its URL and reupload it. + id: "queryCCCCCCC", + parentid: "menu", + type: "query", + title: "C (remote)", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + // D is a query with an invalid URL, so we'll delete it. + id: "queryDDDDDDD", + parentid: "menu", + type: "query", + title: "D", + bmkUri: "^&*()", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkEEEE", + "folderBBBBBB", + PlacesUtils.bookmarks.menuGuid, + "queryCCCCCCC", + "queryDDDDDDD", + ], + "Should leave invalid A, E, D; reuploaded C; B, menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + "bookmarkAAAA", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", "folderBBBBBB", "queryCCCCCCC"], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + folderBBBBBB: { + // B is reuploaded because we deleted its child E. + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderBBBBBB", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + title: "B (remote)", + children: [], + }, + }, + queryCCCCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryCCCCCCC", + type: "query", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + bmkUri: "place:tag=taggy", + title: "C (remote)", + folderName: "taggy", + }, + }, + queryDDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "queryDDDDDDD", + deleted: true, + }, + }, + bookmarkEEEE: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkEEEE", + deleted: true, + }, + }, + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkEEEE", "queryDDDDDDD"], + "Should store local tombstones for (E D)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_local_roots() { + let buf = await openMirror("corrupt_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "toolbar", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + try { + info("Move local menu into unfiled"); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid + ); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented syncable root" + ); + + info("Move local Places root into toolbar"); + await buf.db.executeTransaction(async function () { + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.rootGuid, + PlacesUtils.bookmarks.toolbarGuid + ); + }); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented Places root" + ); + } finally { + info("Restore local roots"); + await buf.db.executeTransaction(async function () { + await reparentItem(buf.db, PlacesUtils.bookmarks.rootGuid); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + }); + } + + info("Apply remote with restored roots"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + deepEqual(changesToUpload, {}, "Should not reupload any local records"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly with restored roots" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_remote_roots() { + let buf = await openMirror("corrupt_remote_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: Menu > Unfiled"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["unfiled", "bookmarkAAAA"], + }, + { + id: "unfiled", + parentid: "menu", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "toolbar", + deleted: true, + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave deleted roots unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + }, + "Should reupload invalid roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not corrupt local roots" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_missing_children() { + let buf = await openMirror("missing_childen"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: A > ([B] C [D E])"); + { + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["menu"], + deleted: [], + }, + "Should reupload menu without missing children (B D E)" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + "Menu children should be (C)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add (B E) to remote"); + { + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkBBBB", "bookmarkEEEE"], + "Should leave B, E with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkEEEE", "menu"], + deleted: [], + }, + "Should reupload menu and restored children" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + ], + }, + "Menu children should be (C B E)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add D to remote"); + { + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ]); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkDDDD"], + "Should leave D with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkDDDD", "menu"], + deleted: [], + }, + "Should reupload complete menu" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D", + url: "http://example.com/d", + }, + ], + }, + "Menu children should be (C B E D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_without_local_parent() { + let buf = await openMirror("new_orphan_without_local_parent"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // A doesn't exist locally, so we move the bookmarks into "unfiled" without + // reuploading. When the partial uploader returns and uploads A, we'll + // move the bookmarks to the correct folder. + info("Make remote changes: [A] > (B C D)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + ]) + ); + + info("Apply remote with (B C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave orphans B, C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD", "unfiled"], + deleted: [], + }, + "Should reupload orphans (B C D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + "Should move (B C D) to unfiled" + ); + + // A is an orphan because we don't have E locally, but we should move + // (B C D) into A. + info("Add [E] > A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "folderEEEEEE", + type: "folder", + title: "A", + children: ["bookmarkDDDD", "bookmarkCCCC", "bookmarkBBBB"], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderAAAAAA"], + "Should leave A with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "folderAAAAAA", + "unfiled", + ], + deleted: [], + }, + "Should reupload A and its children" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + "Should move (D C B) into A" + ); + + info("Add E to remote"); + await storeRecords(buf, [ + { + id: "folderEEEEEE", + parentid: "menu", + type: "folder", + title: "E", + children: ["folderAAAAAA"], + }, + ]); + + info("Apply remote with E"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderEEEEEE"], + "Should leave E with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["folderAAAAAA", "folderEEEEEE", "menu", "unfiled"], + deleted: [], + }, + "Should move E out of unfiled into menu" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + "Should move Menu > E > A" + ); + + info("Add Menu > E to remote"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderEEEEEE"], + }, + ]); + + info("Apply remote with menu"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload after forming complete tree" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should form complete tree after applying E" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_into_orphaned() { + let buf = await openMirror("move_into_orphaned"); + + info("Set up mirror: Menu > (A B (C > (D (E > F))))"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "folderCCCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "folderEEEEEE"], + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "folderEEEEEE", + parentid: "folderCCCCCC", + type: "folder", + title: "E", + children: ["bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderEEEEEE", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete D, add E > I"); + await PlacesUtils.bookmarks.remove("bookmarkDDDD"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkIIII", + parentGuid: "folderEEEEEE", + title: "I (local)", + url: "http://example.com/i", + }); + + // G doesn't exist on the server. + info("Make remote changes: ([G] > A (C > (D H E))), (C > H)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "folderGGGGGG", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderCCCCCC", + parentid: "folderGGGGGG", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "bookmarkHHHH", "folderEEEEEE"], + }, + { + id: "bookmarkHHHH", + parentid: "folderCCCCCC", + type: "bookmark", + title: "H (remote)", + bmkUri: "http://example.com/h-remote", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA", "folderCCCCCC"], + "Should leave orphaned A, C with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkAAAA", + "bookmarkIIII", + "folderCCCCCC", + "folderEEEEEE", + "menu", + ], + deleted: ["bookmarkDDDD"], + }, + "Should upload records for (A I C E); tombstone for D" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + // A remains in its original place, since we don't use the `parentid`, + // and we don't have a record for G. + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + // C exists on the server, so we take its children and order. D was + // deleted locally, and doesn't exist remotely. C is also a child of + // G, but we don't have a record for it on the server. + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "C", + children: [ + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "H (remote)", + url: "http://example.com/h-remote", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "I (local)", + url: "http://example.com/i", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should treat local tree as canonical if server is missing new parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkDDDD"], + "Should store local tombstone for D" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_with_local_parent() { + let buf = await openMirror("new_orphan_with_local_parent"); + + info("Set up mirror: A > (B D)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkEEEE"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Simulate a partial write by another device that uploaded only B and C. A + // exists locally, so we can move B and C into the correct folder, but not + // the correct positions. + info("Set up remote with orphans: [A] > (C D)"); + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + ]); + + info("Apply remote with (C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkCCCC", "bookmarkDDDD"], + "Should leave orphaned C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "bookmarkDDDD", "folderAAAAAA"], + deleted: [], + }, + "Should reupload orphans (C D) and folder A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move (C D) to end of A" + ); + + // The partial uploader returns and uploads A. + info("Add A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: [ + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + "bookmarkBBBB", + ], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload orphan A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + "folderAAAAAA", + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "B", + url: "http://example.com/b", + }, + ], + }, + "Should update child positions once A exists in mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tombstone_as_child() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let buf = await openMirror("tombstone_as_child"); + // Setup the mirror such that an incoming folder references a tombstone + // as a child. + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkAAAA", "bookmarkTTTT", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkTTTT", + deleted: true, + }, + ]), + { needsMerge: true } + ); + + let changesToUpload = await buf.apply(); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual(idsToUpload.deleted, [], "no new tombstones were created."); + deepEqual(idsToUpload.updated, ["folderAAAAAA"], "parent is re-uploaded"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/a", + index: 0, + title: "Bookmark A", + }, + { + // Note that this was the 3rd child specified on the server record, + // but we we've correctly moved it back to being the second after + // ignoring the tombstone. + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/b", + index: 1, + title: "Bookmark B", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should have ignored tombstone record" + ); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_non_syncable_items() { + let buf = await openMirror("non_syncable_items"); + + info("Insert local orphaned left pane queries"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + guid: "folderLEFTPQ", + url: "place:folder=SOMETHING", + title: "Some query", + }, + { + guid: "folderLEFTPC", + url: "place:folder=SOMETHING_ELSE", + title: "A query under 'All Bookmarks'", + }, + ], + }); + + info( + "Insert syncable local items (A > B) that exist in non-syncable remote root H" + ); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A is non-syncable remotely, but B doesn't exist remotely, so we'll + // remove A from the merged structure, and move B to the menu. + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }); + + info("Insert non-syncable local root C and items (C > (D > E) F)"); + await insertLocalRoot({ + guid: "rootCCCCCCCC", + title: "C", + }); + await PlacesUtils.bookmarks.insertTree({ + guid: "rootCCCCCCCC", + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + // H is a non-syncable root that only exists remotely. + id: "rootHHHHHHHH", + type: "folder", + parentid: "places", + title: "H", + children: ["folderAAAAAA"], + }, + { + // A is a folder with children that's non-syncable remotely, and syncable + // locally. We should remove A and its descendants locally, since its parent + // H is known to be non-syncable remotely. + id: "folderAAAAAA", + parentid: "rootHHHHHHHH", + type: "folder", + title: "A", + children: ["bookmarkFFFF", "bookmarkIIII"], + }, + { + // F exists in two different non-syncable folders: C locally, and A + // remotely. + id: "bookmarkFFFF", + parentid: "folderAAAAAA", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + { + id: "bookmarkIIII", + parentid: "folderAAAAAA", + type: "query", + title: "I", + bmkUri: "http://example.com/i", + }, + { + // The complete left pane root. We should remove all left pane queries + // locally, even though they're syncable, since the left pane root is + // known to be non-syncable. + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + parentid: "folderLEFTPR", + type: "query", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + parentid: "folderLEFTPR", + type: "folder", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + parentid: "folderLEFTPF", + type: "query", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + { + // D, J, and G are syncable remotely, but D is non-syncable locally. Since + // J and G don't exist locally, and are syncable remotely, we'll remove D + // from the merged structure, and move J and G to unfiled. + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderDDDDDD", "bookmarkGGGG"], + }, + { + id: "folderDDDDDD", + parentid: "unfiled", + type: "folder", + title: "D", + children: ["bookmarkJJJJ"], + }, + { + id: "bookmarkJJJJ", + parentid: "folderDDDDDD", + type: "bookmark", + title: "J", + bmkUri: "http://example.com/j", + }, + { + id: "bookmarkGGGG", + parentid: "unfiled", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkFFFF", + "bookmarkIIII", + "bookmarkJJJJ", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + PlacesUtils.bookmarks.menuGuid, + "rootHHHHHHHH", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave non-syncable items and roots with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkBBBB", + "bookmarkJJJJ", + ]); + deepEqual( + changesToUpload, + { + folderAAAAAA: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderAAAAAA", + deleted: true, + }, + }, + folderDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + deleted: true, + }, + }, + folderLEFTPQ: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPQ", + deleted: true, + }, + }, + folderLEFTPC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPC", + deleted: true, + }, + }, + folderLEFTPR: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPR", + deleted: true, + }, + }, + folderLEFTPF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPF", + deleted: true, + }, + }, + rootHHHHHHHH: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "rootHHHHHHHH", + deleted: true, + }, + }, + bookmarkFFFF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkFFFF", + deleted: true, + }, + }, + bookmarkIIII: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkIIII", + deleted: true, + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkJJJJ: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkJJJJ", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: undefined, + bmkUri: "http://example.com/j", + title: "J", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkJJJJ", "bookmarkGGGG"], + }, + }, + }, + "Should upload new structure and tombstones for non-syncable items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkJJJJ", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "J", + url: "http://example.com/j", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should exclude non-syncable items from new local structure" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + [ + "bookmarkFFFF", + "bookmarkIIII", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + "rootHHHHHHHH", + ], + "Should store local tombstones for non-syncable items" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane root and a left-pane query are on the server +add_task(async function test_left_pane_root() { + let buf = await openMirror("lpr"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add a left pane root, a left-pane query and a left-pane folder to the + // mirror, all correctly parented. + // Because we can determine this is a complete tree that's outside our + // syncable trees, we expect none of them to be applied. + await storeRecords( + buf, + shuffle( + [ + { + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + type: "folder", + parentid: "folderLEFTPR", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + type: "query", + parentid: "folderLEFTPF", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + ], + { needsMerge: true } + ) + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and a check we didn't write *any* items to the places database, even + // outside of our user roots. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane query (without the left-pane root) is on +// the server +add_task(async function test_left_pane_query() { + let buf = await openMirror("lpq"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add the left pane root and left-pane folders to the mirror, correctly parented. + // We should not apply it because we made a policy decision to not apply + // orphaned queries (bug 1433182) + await storeRecords( + buf, + [ + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + ], + { needsMerge: true } + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and further check we didn't apply it as mis-rooted. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_partial_cycle() { + let buf = await openMirror("partial_cycle"); + + info("Set up mirror: Menu > A > B > C"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Try to create a cycle: move A into B, and B into the menu, but don't upload + // a record for the menu. + info("Make remote changes: A > C"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A (remote)", + children: ["bookmarkCCCC"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B (remote)", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderBBBBBB> can't contain itself/, + "Should abort merge if remote tree parents form `parentid` cycle" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_complete_cycle() { + let buf = await openMirror("complete_cycle"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // This test is order-dependent. We shouldn't recurse infinitely, but, + // depending on the order of the records, we might ignore the circular + // subtree because there's nothing linking it back to the rest of the + // tree. + info("Make remote changes: Menu > A > B > C > A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "folderBBBBBB", + type: "folder", + title: "C", + children: ["folderDDDDDD"], + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + title: "D", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderAAAAAA> can't contain itself/, + "Should abort merge if remote tree parents form cycle through `children`" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_guid() { + let now = new Date(); + + let buf = await openMirror("invalid_guid"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bad!guid~", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bad!guid~", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bad!guid~", PlacesUtils.bookmarks.menuGuid], + "Should leave bad GUID and menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + + let recordIdsToUpload = Object.keys(changesToUpload); + let newGuid = recordIdsToUpload.find( + recordId => !["bad!guid~", "menu"].includes(recordId) + ); + + equal( + recordIdsToUpload.length, + 3, + "Should reupload menu, C, and tombstone for bad GUID" + ); + + deepEqual( + changesToUpload["bad!guid~"], + { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bad!guid~", + deleted: true, + }, + }, + "Should upload tombstone for C's invalid GUID" + ); + + deepEqual( + changesToUpload[newGuid], + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: newGuid, + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/c", + title: "C", + }, + }, + "Should reupload C with new GUID" + ); + + deepEqual( + changesToUpload.menu, + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", newGuid, "bookmarkBBBB"], + }, + }, + "Should reupload menu with new child GUID for C" + ); + + await assertLocalTree(PlacesUtils.bookmarks.menuGuid, { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: newGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B", + url: "http://example.com/b", + }, + ], + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bad!guid~"], + "Should store local tombstone for C's invalid GUID" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_sync_status_mismatches() { + let dateAdded = new Date(); + + let buf = await openMirror("sync_status_mismatches"); + + info("Ensure mirror is up-to-date with Places"); + let initialChangesToUpload = await buf.apply(); + + deepEqual( + Object.keys(initialChangesToUpload).sort(), + ["menu", "mobile", "toolbar", "unfiled"], + "Should upload roots on first merge" + ); + + await storeChangesInMirror(buf, initialChangesToUpload); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + // A is NORMAL in Places, but doesn't exist in the mirror. + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded, + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + // B is NEW in Places and exists in the mirror. + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded, + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + [ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + bmkUri: "http://example.com/b", + title: "B", + }, + { + // C is flagged as merged in the mirror, but doesn't exist in Places. + id: "bookmarkCCCC", + parentid: "toolbar", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ], + { needsMerge: false } + ); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + ]); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + }, + "Should flag (A B) and their parents for upload" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent C correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_local_urls() { + let buf = await openMirror("invalid_local_urls"); + + info("Skip uploading local roots on first merge"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Set up local tree"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A has an invalid URL locally and doesn't exist remotely, so we + // should delete it without uploading a tombstone. + guid: "bookmarkAAAA", + title: "A (local)", + url: "http://example.com/a", + }, + { + // B has an invalid URL locally and has a valid URL remotely, so + // we should replace our local copy with the remote one. + guid: "bookmarkBBBB", + title: "B (local)", + url: "http://example.com/b", + }, + { + // C has an invalid URL on both sides, so we should delete it locally + // and upload a tombstone. + guid: "bookmarkCCCC", + title: "A (local)", + url: "http://example.com/c", + }, + ], + }); + + // The public API doesn't let us insert invalid URLs (for good reason!), so + // we update them directly in Places. + info("Invalidate local URLs"); + await buf.db.executeTransaction(async function () { + const invalidURLs = [ + { + guid: "bookmarkAAAA", + invalidURL: "!@#$%", + }, + { + guid: "bookmarkBBBB", + invalidURL: "^&*(", + }, + { + guid: "bookmarkCCCC", + invalidURL: ")-+!@", + }, + ]; + for (let params of invalidURLs) { + await buf.db.execute( + `UPDATE moz_places SET + url = :invalidURL, + url_hash = hash(:invalidURL) + WHERE id = (SELECT fk FROM moz_bookmarks WHERE guid = :guid)`, + params + ); + } + }); + + info("Set up remote tree"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b", + }, + { + // C should be marked as `VALIDITY_REPLACE` in the mirror database. + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C (remote)", + bmkUri: ")(*&^", + }, + { + // D has an invalid URL remotely and doesn't exist locally, so we + // should replace it with a tombstone. + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D (remote)", + bmkUri: "^%$#@", + }, + ]); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + bookmarkCCCC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + deleted: true, + }, + }, + bookmarkDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkDDDD", + deleted: true, + }, + }, + }, + "Should reupload menu and tombstones for (C D)" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b", + }, + ], + }, + "Should replace B with remote and delete (A C)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual( + await buf.fetchUnmergedGuids(), + [], + "Should flag all items as merged after upload" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_deduping.js b/toolkit/components/places/tests/sync/test_bookmark_deduping.js new file mode 100644 index 0000000000..0c6c79496a --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_deduping.js @@ -0,0 +1,1290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_duping_local_newer() { + let mergeTelemetryCounts; + let buf = await openMirror("duping_local_newer", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + let localModified = new Date(); + + info("Start with empty local and mirror with merged items"); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAA5"], + dateAdded: localModified.getTime(), + }, + { + id: "bookmarkAAA5", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + dateAdded: localModified.getTime(), + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add newer local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + { + guid: "bookmarkAAA2", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + { + guid: "bookmarkAAA3", + title: "A", + url: "http://example.com/a", + dateAdded: localModified, + lastModified: localModified, + }, + ], + }); + + info("Add older remote dupes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkAAA4", "bookmarkAAA5"], + modified: localModified / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + keyword: "kw", + tags: ["remote", "tags"], + modified: localModified / 1000 - 5, + }, + { + id: "bookmarkAAA4", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/a", + title: "A", + modified: localModified / 1000 - 5, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: localModified / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA4", "bookmarkAAAA", PlacesUtils.bookmarks.menuGuid], + "Should leave A4, A, menu with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 9 }, + { name: "dupes", count: 2 }, + ], + "Should record telemetry with dupe counts" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: menuInfo.title, + children: [ + "bookmarkAAAA", + "bookmarkAAA4", + "bookmarkAAA3", + "bookmarkAAA5", + ], + }, + }, + // Note that we always reupload the deduped local item, because content + // matching doesn't account for attributes like keywords, synced annos, or + // tags. + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + // Unchanged from local. + bookmarkAAA4: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA4", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + bookmarkAAA3: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA3", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + bookmarkAAA5: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA5", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: localModified.getTime(), + title: "A", + bmkUri: "http://example.com/a", + }, + }, + }, + "Should uploaded newer deduped local items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA4", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA3", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkAAA5", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "Should dedupe local multiple bookmarks with similar contents" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_remote_newer() { + let buf = await openMirror("duping_remote_new"); + let localModified = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Shouldn't dupe to `folderA11111` because its sync status is "NORMAL". + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + // Shouldn't dupe to `bookmarkG111`. + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkGGGG"], + }, + { + id: "bookmarkGGGG", + parentid: "folderAAAAAA", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Insert local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Should dupe to `folderB11111`. + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: localModified, + lastModified: localModified, + children: [ + { + // Should dupe to `bookmarkC222`. + guid: "bookmarkC111", + url: "http://example.com/c", + title: "C", + dateAdded: localModified, + lastModified: localModified, + }, + { + // Should dupe to `separatorF11` because the positions are the same. + guid: "separatorFFF", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + dateAdded: localModified, + lastModified: localModified, + }, + ], + }, + { + // Shouldn't dupe to `separatorE11`, because the positions are different. + guid: "separatorEEE", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + dateAdded: localModified, + lastModified: localModified, + }, + { + // Shouldn't dupe to `bookmarkC222` because the parents are different. + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: localModified, + lastModified: localModified, + }, + { + // Should dupe to `queryD111111`. + guid: "queryDDDDDDD", + url: "place:maxResults=10&sort=8", + title: "Most Visited", + dateAdded: localModified, + lastModified: localModified, + }, + ], + }); + + // Make sure we still dedupe this even though it doesn't have SYNC_STATUS.NEW + PlacesTestUtils.setBookmarkSyncFields({ + guid: "folderBBBBBB", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + }); + + // Not a candidate for `bookmarkH111` because we didn't dupe `folderAAAAAA`. + await PlacesUtils.bookmarks.insert({ + parentGuid: "folderAAAAAA", + guid: "bookmarkHHHH", + url: "http://example.com/h", + title: "H", + dateAdded: localModified, + lastModified: localModified, + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "folderAAAAAA", + "folderB11111", + "folderA11111", + "separatorE11", + "queryD111111", + ], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "folderB11111", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkC222", "separatorF11"], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "bookmarkC222", + parentid: "folderB11111", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "separatorF11", + parentid: "folderB11111", + type: "separator", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "folderA11111", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkG111"], + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "bookmarkG111", + parentid: "folderA11111", + type: "bookmark", + bmkUri: "http://example.com/g", + title: "G", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "separatorE11", + parentid: "menu", + type: "separator", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + { + id: "queryD111111", + parentid: "menu", + type: "query", + bmkUri: "place:maxResults=10&sort=8", + title: "Most Visited", + dateAdded: localModified.getTime(), + modified: localModified / 1000 + 5, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: localModified / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkCCCC", + "bookmarkHHHH", + "folderAAAAAA", + "menu", + "separatorEEE", + ], + deleted: [], + }, + "Should not upload deduped local records" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "H", + url: "http://example.com/h", + }, + ], + }, + { + guid: "folderB11111", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "B", + children: [ + { + guid: "bookmarkC222", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "separatorF11", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 1, + title: "", + }, + ], + }, + { + guid: "folderA11111", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "A", + children: [ + { + guid: "bookmarkG111", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: "separatorE11", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 3, + title: "", + }, + { + guid: "queryD111111", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "Most Visited", + url: "place:maxResults=10&sort=8", + }, + { + guid: "separatorEEE", + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 5, + title: "", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 6, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should dedupe matching NEW bookmarks" + ); + + ok( + ( + await PlacesTestUtils.fetchBookmarkSyncFields( + "menu________", + "folderB11111", + "bookmarkC222", + "separatorF11", + "folderA11111", + "bookmarkG111", + "separatorE11", + "queryD111111" + ) + ).every(info => info.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL) + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_both() { + let buf = await openMirror("duping_both"); + let now = Date.now(); + + info("Start with empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add local dupes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // `folderAAAAA1` is older than `folderAAAAAA`, but we should still flag + // it for upload because it has a new structure (`bookmarkCCCC`). + guid: "folderAAAAA1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + children: [ + { + // Shouldn't upload, since `bookmarkBBBB` is newer. + guid: "bookmarkBBB1", + title: "B", + url: "http://example.com/b", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + { + // Should upload, since `bookmarkCCCC` doesn't exist on the server and + // has no content matches. + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + // `folderDDDDD1` should keep complete local structure, but we'll still + // flag it for reupload because it's newer than `folderDDDDDD`. + guid: "folderDDDDD1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 10000), + lastModified: new Date(now + 5000), + children: [ + { + guid: "bookmarkEEE1", + title: "E", + url: "http://example.com/e", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + // `folderFFFFF1` should keep complete remote value and structure, so + // we shouldn't upload it or its children. + guid: "folderFFFFF1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGG1", + title: "G", + url: "http://example.com/g", + dateAdded: new Date(now - 10000), + lastModified: new Date(now - 5000), + }, + ], + }, + ], + }); + + info("Add remote dupes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + dateAdded: now - 10000, + modified: now / 1000 + 5, + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + bmkUri: "http://example.com/b", + title: "B", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 10000, + modified: now / 1000 - 5, + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + bmkUri: "http://example.com/e", + title: "E", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F", + dateAdded: now - 10000, + modified: now / 1000 + 5, + children: ["bookmarkGGGG", "bookmarkHHHH"], + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + bmkUri: "http://example.com/g", + title: "G", + dateAdded: now - 10000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkHHHH", + parentid: "folderFFFFFF", + type: "bookmark", + bmkUri: "http://example.com/h", + title: "H", + dateAdded: now - 10000, + modified: now / 1000 + 5, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: now / 1000, + }); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: menuInfo.title, + children: ["folderAAAAAA", "folderDDDDDD", "folderFFFFFF"], + }, + }, + folderAAAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderAAAAAA", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: now - 10000, + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + }, + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "folderAAAAAA", + hasDupe: true, + parentName: "A", + dateAdded: now - 10000, + title: "C", + bmkUri: "http://example.com/c", + }, + }, + folderDDDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: menuInfo.title, + dateAdded: now - 10000, + title: "D", + children: ["bookmarkEEEE"], + }, + }, + }, + "Should upload new and newer locally deduped items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "F", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "H", + url: "http://example.com/h", + }, + ], + }, + ], + }, + "Should change local GUIDs for mixed older and newer items" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_applying_two_empty_folders_doesnt_smush() { + let buf = await openMirror("applying_two_empty_folders_doesnt_smush"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["emptyempty01", "emptyempty02"], + }, + { + id: "emptyempty01", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty02", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only value changes" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "mobile", + children: [ + { + guid: "emptyempty01", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Empty", + }, + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "Empty", + }, + ], + }, + "Should not smush 1 and 2" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_applying_two_empty_folders_matches_only_one() { + let buf = await openMirror("applying_two_empty_folders_doesnt_smush"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.mobileGuid, + children: [ + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Empty", + }, + { + guid: "emptyemptyL0", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Empty", + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["emptyempty01", "emptyempty02", "emptyempty03"], + }, + { + id: "emptyempty01", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty02", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + { + id: "emptyempty03", + parentid: "mobile", + type: "folder", + title: "Empty", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.mobileGuid], + "Should leave mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["mobile"], + deleted: [], + }, + "Should not upload records after applying empty folders" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "mobile", + children: [ + { + guid: "emptyempty01", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Empty", + }, + { + guid: "emptyempty02", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "Empty", + }, + { + guid: "emptyempty03", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "Empty", + }, + ], + }, + "Should apply 1 and dedupe L0 to 3" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Bug 747699. +add_task(async function test_duping_mobile_bookmarks() { + let buf = await openMirror("duping_mobile_bookmarks"); + + info("Set up empty mirror with localized mobile root title"); + let mobileInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.mobileGuid + ); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.mobileGuid, + title: "Favoritos do celular", + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAA1", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + title: "A", + url: "http://example.com/a", + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.mobileGuid], + "Should leave mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["mobile"], + deleted: [], + }, + "Should not upload records after applying deduped mobile bookmark" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.mobileGuid, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: "Favoritos do celular", + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "Should dedupe A1 to A with different parent title" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + // Restore the original mobile root title. + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.mobileGuid, + title: mobileInfo.title, + }); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duping_invalid() { + // To check if invalid items are prevented from deduping + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A", + url: "http://example.com/a", + }, + ], + }); + + let buf = await openMirror("duping_invalid"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAA2"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + ]); + + // Invalidate bookmarkAAA2 so that it does not dedupe to bookmarkAAA1 + await buf.db.execute( + `UPDATE items SET + validity = :validity + WHERE guid = :guid`, + { + validity: Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE, + guid: "bookmarkAAA2", + } + ); + + let changesToUpload = await buf.apply(); + deepEqual( + changesToUpload.menu.cleartext.children, + ["bookmarkAAA1"], + "Should upload A1 in menu" + ); + ok( + !changesToUpload.bookmarkAAA1.tombstone, + "Should not upload tombstone for A1" + ); + ok(changesToUpload.bookmarkAAA2.tombstone, "Should upload tombstone for A2"); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAA1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + "No deduping of invalid items" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_deletion.js b/toolkit/components/places/tests/sync/test_bookmark_deletion.js new file mode 100644 index 0000000000..fd29252e74 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_deletion.js @@ -0,0 +1,1602 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_complex_orphaning() { + let now = Date.now(); + + let mergeTelemetryCounts; + let buf = await openMirror("complex_orphaning", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + // On iOS, the mirror exists as a separate table. On Desktop, we have a + // shadow mirror of synced local bookmarks without new changes. + info("Set up mirror: ((Toolbar > A > B) (Menu > G > C > D))"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + }, + ]), + { needsMerge: false } + ); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderGGGGGG", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "G", + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderGGGGGG"], + }, + { + id: "folderGGGGGG", + parentid: "menu", + type: "folder", + title: "G", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "folderGGGGGG", + type: "folder", + title: "C", + children: ["folderDDDDDD"], + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + title: "D", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete D, add B > E"); + await PlacesUtils.bookmarks.remove("folderDDDDDD"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderBBBBBB", + title: "E", + url: "http://example.com/e", + }); + + info("Make remote changes: delete B, add D > F"); + await storeRecords( + buf, + shuffle([ + { + id: "folderBBBBBB", + deleted: true, + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + children: ["bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "folderAAAAAA", "folderDDDDDD"], + "Should leave deleted D; A and F with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 10 }, + { name: "localDeletes", count: 1 }, + { name: "remoteDeletes", count: 1 }, + ], + "Should record telemetry with structure change counts" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkEEEE", "bookmarkFFFF", "folderAAAAAA", "folderCCCCCC"], + deleted: ["folderDDDDDD"], + }, + "Should upload new records for (A > E), (C > F); tombstone for D" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderGGGGGG", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "G", + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "C", + children: [ + { + // D was deleted, so F moved to C, the closest surviving parent. + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + // B was deleted, so E moved to A. + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move orphans to closest surviving parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderDDDDDD"], + "Should store local tombstone for D" + ); + Assert.ok( + is_time_ordered(now, tombstones[0].dateRemoved.getTime()), + "Tombstone timestamp should be recent" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_locally_modified_remotely_deleted() { + let mergeTelemetryCounts; + let buf = await openMirror("locally_modified_remotely_deleted", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "folderDDDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: change A; B > ((D > F) G)"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkAAAA", + title: "A (local)", + url: "http://example.com/a-local", + }); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkFFFF", + parentGuid: "folderDDDDDD", + title: "F (local)", + url: "http://example.com/f-local", + }); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkGGGG", + parentGuid: "folderBBBBBB", + title: "G (local)", + url: "http://example.com/g-local", + }); + + info("Make remote changes: delete A, B"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "bookmarkAAAA", + deleted: true, + }, + { + id: "folderBBBBBB", + deleted: true, + }, + { + id: "bookmarkCCCC", + deleted: true, + }, + { + id: "folderDDDDDD", + deleted: true, + }, + { + id: "bookmarkEEEE", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA", PlacesUtils.bookmarks.menuGuid], + "Should leave revived A and menu with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 8 }, + { name: "localRevives", count: 1 }, + { name: "remoteDeletes", count: 2 }, + ], + "Should record telemetry for local item and remote folder deletions" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkAAAA", "bookmarkFFFF", "bookmarkGGGG", "menu"], + deleted: [], + }, + "Should upload A, relocated local orphans, and menu" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (local)", + url: "http://example.com/a-local", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F (local)", + url: "http://example.com/f-local", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G (local)", + url: "http://example.com/g-local", + }, + ], + }, + "Should restore A and relocate (F G) to menu" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_locally_deleted_remotely_modified() { + let now = Date.now(); + + let mergeTelemetryCounts; + let buf = await openMirror("locally_deleted_remotely_modified", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts.filter(({ count }) => count > 0); + } + }, + }); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "folderDDDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete A, B"); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + await PlacesUtils.bookmarks.remove("folderBBBBBB"); + + info("Make remote changes: change A; B > ((D > F) G)"); + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + bmkUri: "http://example.com/a-remote", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B (remote)", + children: ["bookmarkCCCC", "folderDDDDDD", "bookmarkGGGG"], + }, + { + id: "folderDDDDDD", + parentid: "folderBBBBBB", + type: "folder", + title: "D", + children: ["bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F (remote)", + bmkUri: "http://example.com/f-remote", + }, + { + id: "bookmarkGGGG", + parentid: "folderBBBBBB", + type: "bookmark", + title: "G (remote)", + bmkUri: "http://example.com/g-remote", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "bookmarkGGGG", "folderBBBBBB", "folderDDDDDD"], + "Should leave deleted B and D; relocated F and G unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [ + { name: "items", count: 8 }, + { name: "remoteRevives", count: 1 }, + { name: "localDeletes", count: 2 }, + ], + "Should record telemetry for remote item and local folder deletions" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkFFFF", "bookmarkGGGG", "menu"], + deleted: ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"], + }, + "Should upload relocated remote orphans and menu" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (remote)", + url: "http://example.com/a-remote", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F (remote)", + url: "http://example.com/f-remote", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G (remote)", + url: "http://example.com/g-remote", + }, + ], + }, + "Should restore A and relocate (F G) to menu" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkCCCC", "bookmarkEEEE", "folderBBBBBB", "folderDDDDDD"], + "Should store local tombstones for deleted items; remove for undeleted" + ); + Assert.ok( + tombstones.every(({ dateRemoved }) => + is_time_ordered(now, dateRemoved.getTime()) + ), + "Local tombstone timestamps should be recent" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_to_new_then_delete() { + let buf = await openMirror("move_to_new_then_delete"); + + info("Set up mirror: A > B > (C D)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["bookmarkCCCC", "bookmarkDDDD"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkDDDD", + parentid: "folderBBBBBB", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: E > A, delete E"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "E", + }); + await PlacesUtils.bookmarks.update({ + guid: "folderAAAAAA", + parentGuid: "folderEEEEEE", + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + // E isn't synced, so we shouldn't upload a tombstone. + await PlacesUtils.bookmarks.remove("folderEEEEEE"); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkCCCC", PlacesUtils.bookmarks.toolbarGuid], + "Should leave revived C and toolbar with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "menu", "toolbar"], + deleted: ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"], + }, + "Should upload records for Menu > C, Toolbar" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move C to closest surviving parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkDDDD", "folderAAAAAA", "folderBBBBBB"], + "Should store local tombstones for (D A B)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_nonexistent_on_one_side() { + let buf = await openMirror("nonexistent_on_one_side"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // A doesn't exist in the mirror. + info("Create local tombstone for nonexistent remote item A"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "A", + url: "http://example.com/a", + // Pretend a bookmark restore added A, so that we'll write a tombstone when + // we remove it. + source: PlacesUtils.bookmarks.SOURCES.RESTORE, + }); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + + // B doesn't exist in Places, and we don't currently persist tombstones (bug + // 1343103), so we should ignore it. + info("Create remote tombstone for nonexistent local item B"); + await storeRecords(buf, [ + { + id: "bookmarkBBBB", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + + // We should still upload a record for the menu, since we changed its + // children when we added then removed A. + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: BookmarksMenuTitle, + children: [], + }, + }, + }); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_clear_folder_then_delete() { + let buf = await openMirror("clear_folder_then_delete"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "bookmarkFFFF", + parentid: "folderDDDDDD", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: Menu > E, Mobile > F, delete D"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkEEEE", + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkFFFF", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + index: 0, + }); + await PlacesUtils.bookmarks.remove("folderDDDDDD"); + + info("Make remote changes: Menu > B, Unfiled > C, delete A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB", "folderDDDDDD"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkCCCC", + parentid: "unfiled", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.mobileGuid], + "Should leave menu and mobile with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkEEEE", "bookmarkFFFF", "menu", "mobile"], + deleted: ["folderDDDDDD"], + }, + "Should upload locally moved and deleted items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + "Should not orphan moved children of a deleted folder" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderDDDDDD"], + "Should store local tombstone for D" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_move_to_deleted() { + let buf = await openMirror("test_newer_move_to_deleted"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD"], + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let now = Date.now(); + + // A will have a newer local timestamp. However, we should *not* revert + // remotely moving B to the toolbar. (Locally, B exists in A, but we + // deleted the now-empty A remotely). + info("Make local changes: A > E, Toolbar > D, delete C"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + title: "E", + url: "http://example.com/e", + dateAdded: new Date(now), + lastModified: new Date(now), + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkDDDD", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + lastModified: new Date(now), + }); + await PlacesUtils.bookmarks.remove("folderCCCCCC"); + + // C will have a newer remote timestamp. However, we should *not* revert + // locally moving D to the toolbar. (Locally, D exists in C, but we + // deleted the now-empty C locally). + info("Make remote changes: C > F, Toolbar > B, delete A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "bookmarkFFFF"], + modified: now / 1000 + 5, + }, + { + id: "bookmarkFFFF", + parentid: "folderCCCCCC", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + modified: now / 1000 - 5, + }, + { + id: "bookmarkBBBB", + parentid: "toolbar", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: now / 1000 - 5, + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkFFFF", + "folderCCCCCC", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + ], + "Should leave deleted C; revived F and roots with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkDDDD", + "bookmarkEEEE", + "bookmarkFFFF", + "menu", + "toolbar", + ], + deleted: ["folderCCCCCC"], + }, + "Should upload new and moved items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not decide to keep newly moved items in deleted parents" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderCCCCCC"], + "Should store local tombstone for C" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remotely_deleted_also_removes_keyword() { + let buf = await openMirror("remotely_deleted_removes_keyword"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "keyworda", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "keywordb", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "keyworda", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "keywordb", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Validate the keywords exists + let has_keyword_a = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + Assert.equal(has_keyword_a.keyword, "keyworda"); + + let has_keyword_b = await PlacesUtils.keywords.fetch({ + url: "http://example.com/b", + }); + Assert.equal(has_keyword_b.keyword, "keywordb"); + + info("Make remote changes: delete A & B"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "bookmarkAAAA", + deleted: true, + }, + { + id: "bookmarkBBBB", + deleted: true, + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "No local changes done" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + }, + "Should've remove A & B from menu" + ); + + // Validate the keyword no longer exists after removing the bookmark + let no_keyword_a = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + Assert.equal(no_keyword_a, null); + + // Both keywords should've been removed after the sync + let no_keyword_b = await PlacesUtils.keywords.fetch({ + url: "http://example.com/b", + }); + Assert.equal(no_keyword_b, null); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_haschanges.js b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js new file mode 100644 index 0000000000..32cfd050aa --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_haschanges.js @@ -0,0 +1,228 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_no_changes() { + let buf = await openMirror("nochanges"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: [], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(!wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_remote() { + let buf = await openMirror("remote_changes"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await storeRecords( + buf, + [ + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "New Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ], + { needsMerge: true } + ); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_local() { + let buf = await openMirror("local_changes"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.update({ + guid: "mozBmk______", + title: "New Mozilla!", + }); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_deleted_bookmark() { + let buf = await openMirror("delete_bookmark"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid) + ); + await PlacesUtils.bookmarks.remove("mozBmk______"); + + await wait; + // Wait for everything to be finished + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_kinds.js b/toolkit/components/places/tests/sync/test_bookmark_kinds.js new file mode 100644 index 0000000000..3372757532 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_kinds.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_queries() { + let buf = await openMirror("queries"); + + info("Set up places"); + + // create a tag and grab the local folder ID. + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "a-tag", + }); + + await PlacesTestUtils.markBookmarksAsSynced(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // this entry has a tag= query param for a tag that exists. + guid: "queryAAAAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY query", + url: `place:tag=a-tag&&sort=14&maxResults=10`, + }, + { + // this entry has a tag= query param for a tag that doesn't exist. + guid: "queryBBBBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY query but invalid folder id", + url: `place:tag=b-tag&sort=14&maxResults=10`, + }, + { + // this entry has no tag= query param. + guid: "queryCCCCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY without a folder at all", + url: "place:sort=14&maxResults=10", + }, + { + // this entry has only a tag= query. + guid: "queryDDDDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "TAG_QUERY without a folder at all", + url: "place:tag=a-tag", + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [ + "queryEEEEEEE", + "queryFFFFFFF", + "queryGGGGGGG", + "queryHHHHHHH", + "queryIIIIIII", + ], + }, + { + // Legacy tag query. + id: "queryEEEEEEE", + parentid: "toolbar", + type: "query", + title: "E", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + // New tag query. + id: "queryFFFFFFF", + parentid: "toolbar", + type: "query", + title: "F", + bmkUri: "place:tag=a-tag", + folderName: "a-tag", + }, + { + // Legacy tag query referencing the same tag as the new query. + id: "queryGGGGGGG", + parentid: "toolbar", + type: "query", + title: "G", + bmkUri: "place:type=7&folder=111&something=else", + folderName: "a-tag", + }, + { + // Legacy folder lookup query. + id: "queryHHHHHHH", + parentid: "toolbar", + type: "query", + title: "H", + bmkUri: "place:folder=1", + }, + { + // Legacy tag query with invalid tag folder name. + id: "queryIIIIIII", + parentid: "toolbar", + type: "query", + title: "I", + bmkUri: "place:type=7&folder=222", + folderName: " ", + }, + ]) + ); + + info("Create records to upload"); + let changes = await buf.apply(); + deepEqual( + Object.keys(changes), + [ + "menu", + "toolbar", + "queryAAAAAAA", + "queryBBBBBBB", + "queryCCCCCCC", + "queryDDDDDDD", + "queryEEEEEEE", + "queryGGGGGGG", + "queryHHHHHHH", + "queryIIIIIII", + ], + "Should upload roots, new queries, and rewritten queries" + ); + Assert.strictEqual(changes.queryAAAAAAA.cleartext.folderName, tag.title); + Assert.strictEqual(changes.queryBBBBBBB.cleartext.folderName, "b-tag"); + Assert.strictEqual(changes.queryCCCCCCC.cleartext.folderName, undefined); + Assert.strictEqual(changes.queryDDDDDDD.cleartext.folderName, tag.title); + Assert.strictEqual(changes.queryIIIIIII.tombstone, true); + + await assertLocalTree( + PlacesUtils.bookmarks.toolbarGuid, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "queryEEEEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "place:tag=taggy", + }, + { + guid: "queryFFFFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "place:tag=a-tag", + }, + { + guid: "queryGGGGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "G", + url: "place:tag=a-tag", + }, + { + guid: "queryHHHHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "H", + url: "place:folder=1&excludeItems=1", + }, + ], + }, + "Should rewrite legacy remote queries" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_different_but_compatible_bookmark_types() { + let buf = await openMirror("partial_queries"); + try { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "not yet a query", + url: "about:blank", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "a query", + url: "place:foo", + }, + ], + }); + + let changes = await buf.apply(); + // We should have an outgoing record for bookmarkA with type=bookmark + // and bookmarkB with type=query. + Assert.equal(changes.bookmarkAAAA.cleartext.type, "bookmark"); + Assert.equal(changes.bookmarkBBBB.cleartext.type, "query"); + + // Now pretend that same records are already on the server. + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "not yet a query", + bmkUri: "about:blank", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "query", + title: "a query", + bmkUri: "place:foo", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // change the url of bookmarkA to be a "real" query and of bookmarkB to + // no longer be a query. + await PlacesUtils.bookmarks.update({ + guid: "bookmarkAAAA", + url: "place:type=6&sort=14&maxResults=10", + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkBBBB", + url: "about:robots", + }); + + changes = await buf.apply(); + // We should have an outgoing record for bookmarkA with type=query and + // for bookmarkB with type=bookmark + Assert.equal(changes.bookmarkAAAA.cleartext.type, "query"); + Assert.equal(changes.bookmarkBBBB.cleartext.type, "bookmark"); + } finally { + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); + } +}); + +add_task(async function test_incompatible_types() { + try { + let buf = await openMirror("incompatible_types"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "AAAAAAAAAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "a bookmark", + url: "about:blank", + }, + ], + }); + + await buf.apply(); + + // Now pretend that same records are already on the server with incompatible + // types. + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["AAAAAAAAAAAA"], + }, + { + id: "AAAAAAAAAAAA", + parentid: "menu", + type: "folder", + title: "conflicting folder", + }, + ], + { needsMerge: true } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await Assert.rejects( + buf.apply(), + /Can't merge local Bookmark <guid: AAAAAAAAAAAA> and remote Folder <guid: AAAAAAAAAAAA>/ + ); + } finally { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); + } +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js new file mode 100644 index 0000000000..6c475daab6 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_meta.js @@ -0,0 +1,193 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_highWaterMark() { + let buf = await openMirror("highWaterMark"); + + strictEqual( + await buf.getCollectionHighWaterMark(), + 0, + "High water mark should be 0 without items" + ); + + await buf.setCollectionLastModified(123.45); + equal( + await buf.getCollectionHighWaterMark(), + 123.45, + "High water mark should be last modified time without items" + ); + + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + modified: 50, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + modified: 123.95, + }, + ]); + equal( + await buf.getCollectionHighWaterMark(), + 123.45, + "High water mark should be last modified time if items are older" + ); + + await storeRecords(buf, [ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + modified: 125.45, + }, + ]); + equal( + await buf.getCollectionHighWaterMark(), + 124.45, + "High water mark should be modified time - 1s of newest record if exists" + ); + + await buf.finalize(); +}); + +add_task(async function test_ensureCurrentSyncId() { + let buf = await openMirror("ensureCurrentSyncId"); + + await buf.ensureCurrentSyncId("syncIdAAAAAA"); + equal( + await buf.getCollectionHighWaterMark(), + 0, + "High water mark should be 0 after setting sync ID" + ); + + info("Insert items and set collection last modified"); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + modified: 125.45, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + children: [], + }, + ], + { needsMerge: false } + ); + await buf.setCollectionLastModified(123.45); + + info("Set matching sync ID"); + await buf.ensureCurrentSyncId("syncIdAAAAAA"); + { + equal( + await buf.getSyncId(), + "syncIdAAAAAA", + "Should return existing sync ID" + ); + strictEqual( + await buf.getCollectionHighWaterMark(), + 124.45, + "Different sync ID should reset high water mark" + ); + + let itemRows = await buf.db.execute(` + SELECT guid, needsMerge FROM items + ORDER BY guid`); + let itemInfos = itemRows.map(row => ({ + guid: row.getResultByName("guid"), + needsMerge: !!row.getResultByName("needsMerge"), + })); + deepEqual( + itemInfos, + [ + { + guid: "folderAAAAAA", + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.menuGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.rootGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + needsMerge: true, + }, + ], + "Matching sync ID should not reset items" + ); + } + + info("Set different sync ID"); + await buf.ensureCurrentSyncId("syncIdBBBBBB"); + { + equal( + await buf.getSyncId(), + "syncIdBBBBBB", + "Should replace existing sync ID" + ); + strictEqual( + await buf.getCollectionHighWaterMark(), + 0, + "Different sync ID should reset high water mark" + ); + + let itemRows = await buf.db.execute(` + SELECT guid, needsMerge FROM items + ORDER BY guid`); + let itemInfos = itemRows.map(row => ({ + guid: row.getResultByName("guid"), + needsMerge: !!row.getResultByName("needsMerge"), + })); + deepEqual( + itemInfos, + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.rootGuid, + needsMerge: false, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + needsMerge: true, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + needsMerge: true, + }, + ], + "Different sync ID should reset items" + ); + } +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js new file mode 100644 index 0000000000..86cf45eb0f --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_mirror_migration.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Keep in sync with `SyncedBookmarksMirror.jsm`. +const CURRENT_MIRROR_SCHEMA_VERSION = 9; + +// The oldest schema version that we support. Any databases with schemas older +// than this will be dropped and recreated. +const OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION = 5; + +async function getIndexNames(db, table, schema = "mirror") { + let rows = await db.execute(`PRAGMA ${schema}.index_list(${table})`); + let names = []; + for (let row of rows) { + // Column 4 is `c` if the index was created via `CREATE INDEX`, `u` if + // via `UNIQUE`, and `pk` if via `PRIMARY KEY`. + let wasCreated = row.getResultByIndex(3) == "c"; + if (wasCreated) { + // Column 2 is the name of the index. + names.push(row.getResultByIndex(1)); + } + } + return names.sort(); +} + +add_task(async function test_migrate_after_downgrade() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let dbFile = await setupFixtureFile("mirror_v5.sqlite"); + let oldBuf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + info("Downgrade schema version to oldest supported"); + await oldBuf.db.setSchemaVersion( + OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION, + "mirror" + ); + await oldBuf.finalize(); + + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + // All migrations between `OLDEST_SUPPORTED_MIRROR_SCHEMA_VERSION` should + // be idempotent. When we downgrade, we roll back the schema version, but + // leave the schema changes in place, since we can't anticipate what a + // future version will change. + let schemaVersion = await buf.db.getSchemaVersion("mirror"); + equal( + schemaVersion, + CURRENT_MIRROR_SCHEMA_VERSION, + "Should upgrade downgraded mirror schema" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Migrations between 5 and 7 add three indexes. +add_task(async function test_migrate_from_5_to_current() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let dbFile = await setupFixtureFile("mirror_v5.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + let schemaVersion = await buf.db.getSchemaVersion("mirror"); + equal( + schemaVersion, + CURRENT_MIRROR_SCHEMA_VERSION, + "Should upgrade mirror schema to current version" + ); + + let itemsIndexNames = await getIndexNames(buf.db, "items"); + deepEqual( + itemsIndexNames, + ["itemKeywords", "itemURLs"], + "Should add two indexes on items" + ); + + let structureIndexNames = await getIndexNames(buf.db, "structure"); + deepEqual( + structureIndexNames, + ["structurePositions"], + "Should add an index on structure" + ); + + let changesToUpload = await buf.apply(); + deepEqual(changesToUpload, {}, "Shouldn't flag any items for reupload"); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + keyword: "hi", + }, + ], + }, + "Should apply mirror tree after migrating" + ); + + let keywordEntry = await PlacesUtils.keywords.fetch("hi"); + equal( + keywordEntry.url.href, + "http://example.com/b", + "Should apply keyword from migrated mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Migrations between 1 and 2 discard the entire database. +add_task(async function test_migrate_from_1_to_2() { + let dbFile = await setupFixtureFile("mirror_v1.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + }); + ok( + buf.wasCorrupt, + "Migrating from unsupported version should mark database as corrupt" + ); + await buf.finalize(); +}); + +add_task(async function test_database_corrupt() { + let corruptFile = await setupFixtureFile("mirror_corrupt.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: corruptFile.path, + }); + ok(buf.wasCorrupt, "Opening corrupt database should mark it as such"); + await buf.finalize(); +}); + +add_task(async function test_migrate_v7_v9() { + let buf = await openMirror("test_migrate_v7_v9"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + await buf.db.execute( + `UPDATE moz_bookmarks + SET syncChangeCounter = 0, + syncStatus = ${PlacesUtils.bookmarks.SYNC_STATUS.NEW}` + ); + + // setup the mirror. + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "menu", + parentid: "places", + type: "folder", + children: [], + }, + ]); + + await buf.db.setSchemaVersion(7, "mirror"); + await buf.finalize(); + + // reopen it. + buf = await openMirror("test_migrate_v7_v9"); + Assert.equal(await buf.db.getSchemaVersion("mirror"), 9, "did upgrade"); + + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + "bookmarkAAAA", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid + ); + let [fieldsA, fieldsB, fieldsMenu] = fields; + + // 'A' was in the mirror - should now be _NORMAL + Assert.equal(fieldsA.guid, "bookmarkAAAA"); + Assert.equal(fieldsA.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL); + // 'B' was not in the mirror so should be untouched. + Assert.equal(fieldsB.guid, "bookmarkBBBB"); + Assert.equal(fieldsB.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NEW); + // 'menu' was in the mirror - should now be _NORMAL + Assert.equal(fieldsMenu.guid, PlacesUtils.bookmarks.menuGuid); + Assert.equal(fieldsMenu.syncStatus, PlacesUtils.bookmarks.SYNC_STATUS.NORMAL); + await buf.finalize(); +}); + +add_task(async function test_migrate_v8_v9() { + let dbFile = await setupFixtureFile("mirror_v8.sqlite"); + let buf = await SyncedBookmarksMirror.open({ + path: dbFile.path, + recordStepTelemetry() {}, + recordValidationTelemetry() {}, + }); + + Assert.equal(await buf.db.getSchemaVersion("mirror"), 9, "did upgrade"); + + // Verify the new column is there + Assert.ok(await buf.db.execute("SELECT unknownFields FROM items")); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js new file mode 100644 index 0000000000..16d8ed746c --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_observer_recorder.js @@ -0,0 +1,670 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function promiseAllURLFrecencies() { + let frecencies = new Map(); + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT url, frecency, recalc_frecency + FROM moz_places + WHERE url_hash BETWEEN hash('http', 'prefix_lo') AND + hash('http', 'prefix_hi')`); + for (let row of rows) { + frecencies.set(row.getResultByName("url"), { + frecency: row.getResultByName("frecency"), + recalc: row.getResultByName("recalc_frecency"), + }); + } + return frecencies; +} + +function mapFilterIterator(iter, fn) { + let results = []; + for (let value of iter) { + let newValue = fn(value); + if (newValue) { + results.push(newValue); + } + } + return results; +} + +add_task(async function test_update_frecencies() { + let buf = await openMirror("update_frecencies"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Not modified in mirror; shouldn't recalculate frecency. + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + // URL changed to B1 in mirror; should recalculate frecency for B + // and B1, using existing frecency to determine order. + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + { + // URL changed to new URL in mirror, should recalculate frecency + // for new URL first, before B1. + guid: "bookmarkBBB1", + title: "B1", + url: "http://example.com/b1", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b1", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // Query; shouldn't recalculate frecency. + guid: "queryCCCCCCC", + title: "C", + url: "place:type=6&sort=14&maxResults=10", + }, + ], + }); + + info("Calculate frecencies for all local URLs"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkBBB1"], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBB2", + "bookmarkDDDD", + "bookmarkEEEE", + "queryFFFFFFF", + ], + }, + { + // Existing bookmark changed to existing URL. + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b1", + }, + { + // Existing bookmark with new URL; should recalculate frecency first. + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b11", + }, + { + id: "bookmarkBBB2", + parentid: "unfiled", + type: "bookmark", + title: "B2", + bmkUri: "http://example.com/b", + }, + { + // New bookmark with new URL; should recalculate frecency first. + id: "bookmarkDDDD", + parentid: "unfiled", + type: "bookmark", + title: null, + bmkUri: "http://example.com/d", + }, + { + // New bookmark with new URL. + id: "bookmarkEEEE", + parentid: "unfiled", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + // New query; shouldn't count against limit. + id: "queryFFFFFFF", + parentid: "unfiled", + type: "query", + title: "F", + bmkUri: `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + }, + ]); + + info("Apply new items and recalculate 3 frecencies"); + await buf.apply(); + await PlacesFrecencyRecalculator.recalculateSomeFrecencies({ chunkSize: 3 }); + + { + let frecencies = await promiseAllURLFrecencies(); + let urlsWithFrecency = mapFilterIterator( + frecencies.entries(), + ([href, { frecency, recalc }]) => (recalc == 0 ? href : null) + ); + + // A is unchanged, and we should recalculate frecency for three more + // random URLs. + equal( + urlsWithFrecency.length, + 4, + "Should keep unchanged frecency and recalculate 3" + ); + let unexpectedURLs = CommonUtils.difference( + urlsWithFrecency, + new Set([ + // A is unchanged. + "http://example.com/a", + + // B11, D, and E are new URLs. + "http://example.com/b11", + "http://example.com/d", + "http://example.com/e", + + // B and B1 are existing, changed URLs. + "http://example.com/b", + "http://example.com/b1", + ]) + ); + ok( + !unexpectedURLs.size, + "Should recalculate frecency for new and changed URLs only" + ); + } + + info("Change non-URL property of D"); + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "unfiled", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d", + }, + ]); + + info("Apply new item and recalculate remaining frecencies"); + await buf.apply(); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + { + let frecencies = await promiseAllURLFrecencies(); + let urlsWithoutFrecency = mapFilterIterator( + frecencies.entries(), + ([href, { frecency, recalc }]) => (recalc == 1 ? href : null) + ); + deepEqual( + urlsWithoutFrecency, + [], + "Should finish calculating remaining frecencies" + ); + } + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +async function setupLocalTree(localTimeSeconds) { + let dateAdded = new Date(localTimeSeconds * 1000); + let lastModified = new Date(localTimeSeconds * 1000); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded, + lastModified, + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + dateAdded, + lastModified, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded, + lastModified, + }, + ], + }, + { + guid: "bookmarkDDDD", + title: null, + url: "http://example.com/d", + dateAdded, + lastModified, + }, + ], + }); +} + +// This test ensures we clean up the temp tables between merges, and don't throw +// constraint errors recording observer notifications. +add_task(async function test_apply_then_revert() { + let buf = await openMirror("apply_then_revert"); + + let now = Date.now() / 1000; + let localTimeSeconds = now - 180; + + info("Set up initial local tree and mirror"); + await setupLocalTree(localTimeSeconds); + let recordsToUpload = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + }); + await storeChangesInMirror(buf, recordsToUpload); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEE1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "E", + url: "http://example.com/e", + dateAdded: new Date(localTimeSeconds * 1000), + lastModified: new Date(localTimeSeconds * 1000), + }); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "bookmarkFFFF"], + modified: now, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + modified: now, + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A (remote)", + children: ["bookmarkCCCC", "bookmarkBBBB"], + modified: now, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b-remote", + modified: now, + }, + { + id: "bookmarkDDDD", + deleted: true, + modified: now, + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + modified: now, + }, + { + id: "bookmarkFFFF", + parentid: "menu", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + modified: now, + }, + ]); + + info("Apply remote changes, first time"); + let firstTimeRecords = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged after first time" + ); + + info("Revert local tree"); + let dateAdded = new Date(localTimeSeconds * 1000); + await PlacesSyncUtils.bookmarks.wipe(); + await setupLocalTree(localTimeSeconds); + await PlacesTestUtils.markBookmarksAsSynced(); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEE1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "E", + url: "http://example.com/e", + dateAdded, + lastModified: new Date(localTimeSeconds * 1000), + }); + let localIdForD = await PlacesTestUtils.promiseItemId("bookmarkDDDD"); + + info("Apply remote changes, second time"); + await buf.db.execute( + ` + UPDATE items SET + needsMerge = 1 + WHERE guid <> :rootGuid`, + { rootGuid: PlacesUtils.bookmarks.rootGuid } + ); + let observer = expectBookmarkChangeNotifications(); + let secondTimeRecords = await buf.apply({ + localTimeSeconds, + remoteTimeSeconds: now, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged after second time" + ); + deepEqual( + secondTimeRecords, + firstTimeRecords, + "Should stage identical records to upload, first and second time" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "bookmarkFFFF", + "bookmarkEEEE", + "folderAAAAAA", + "bookmarkCCCC", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid, + ]); + observer.check([ + { + name: "bookmark-removed", + params: { + itemId: localIdForD, + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/d", + title: "", // null titles get turned into empty strings. + guid: "bookmarkDDDD", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }, + }, + { + name: "bookmark-guid-changed", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "", + guid: "bookmarkEEEE", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkFFFF"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/f", + title: "F", + guid: "bookmarkFFFF", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 2, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + title: "E", + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("folderAAAAAA"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "folderAAAAAA", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + title: "A (remote)", + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c", + isTagging: false, + title: "C", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b-remote", + isTagging: false, + title: "B", + tags: "", + frecency: -1, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("folderAAAAAA"), + title: "A (remote)", + guid: "folderAAAAAA", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }, + }, + { + name: "bookmark-url-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/b-remote", + guid: "bookmarkBBBB", + parentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A (remote)", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should apply new structure, second time" + ); + + await storeChangesInMirror(buf, secondTimeRecords); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_reconcile.js b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js new file mode 100644 index 0000000000..218e84beb6 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_reconcile.js @@ -0,0 +1,191 @@ +// Get bookmarks which aren't marked as normally syncing and with no pending +// changes. +async function getBookmarksNotMarkedAsSynced() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + ` + SELECT guid, syncStatus, syncChangeCounter FROM moz_bookmarks + WHERE syncChangeCounter > 1 OR syncStatus != :syncStatus + ORDER BY guid + `, + { syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL } + ); + return rows.map(row => { + return { + guid: row.getResultByName("guid"), + syncStatus: row.getResultByName("syncStatus"), + syncChangeCounter: row.getResultByName("syncChangeCounter"), + }; + }); +} + +add_task(async function test_reconcile_metadata() { + let buf = await openMirror("test_reconcile_metadata"); + + let olderDate = new Date(Date.now() - 100000); + info("Set up local tree"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // this folder is going to reconcile exactly + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + { + // this folder's existing child isn't on the server (so will be + // outgoing) and also will take a new child from the server. + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + // This bookmark is going to take the remote title. + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "f", + dateAdded: olderDate, + lastModified: olderDate, + }, + ], + }); + // And a single, local-only bookmark in the toolbar. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkTTTT", + url: "http://example.com/t", + title: "in the toolbar", + dateAdded: olderDate, + lastModified: olderDate, + }, + ], + }); + // Reset to prepare for our reconciled sync. + await PlacesSyncUtils.bookmarks.reset(); + // setup the mirror. + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC", "bookmarkFFFF"], + modified: Date.now() / 1000 - 60, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: Date.now() / 1000 - 60, + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkFFFF", + parentid: "menu", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + dateAdded: olderDate, + modified: Date.now() / 1000 + 60, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: [], + index: 1, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: [], + index: 3, + }, + ]) + ); + info("Applying"); + let changesToUpload = await buf.apply(); + // We need to upload a bookmark and the parent as they didn't exist on the + // server. Since we always use the local state for roots (bug 1472241), we'll + // reupload them too. + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkEEEE", + "bookmarkTTTT", + "folderCCCCCC", + "menu", + "mobile", + "toolbar", + "unfiled", + ], + deleted: [], + }, + "Should upload the 2 local-only bookmarks and their parents" + ); + // Check it took the remote thing we were expecting. + Assert.equal((await PlacesUtils.bookmarks.fetch("bookmarkFFFF")).title, "F"); + // Most things should be synced and have no change counter. + let badGuids = await getBookmarksNotMarkedAsSynced(); + Assert.deepEqual(badGuids, [ + { + // The bookmark that was only on the server. Still have SYNC_STATUS_NEW + // as it's yet to be uploaded. + guid: "bookmarkEEEE", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + syncChangeCounter: 1, + }, + { + // This bookmark is local only so is yet to be uploaded. + guid: "bookmarkTTTT", + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + syncChangeCounter: 1, + }, + ]); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js new file mode 100644 index 0000000000..cde4d5e751 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_structure_changes.js @@ -0,0 +1,2966 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_value_structure_conflict() { + let buf = await openMirror("value_structure_conflict"); + + info("Set up mirror"); + let dateAdded = new Date(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded, + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded, + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + dateAdded, + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderDDDDDD"], + modified: Date.now() / 1000 - 60, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + modified: Date.now() / 1000 - 60, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkEEEE"], + modified: Date.now() / 1000 - 60, + }, + { + id: "bookmarkEEEE", + parentid: "folderDDDDDD", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + modified: Date.now() / 1000 - 60, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local value change"); + await PlacesUtils.bookmarks.update({ + guid: "folderAAAAAA", + title: "A (local)", + }); + + info("Make local structure change"); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkBBBB", + parentGuid: "folderDDDDDD", + index: 0, + }); + + info("Make remote value change"); + await storeRecords(buf, [ + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D (remote)", + children: ["bookmarkEEEE"], + modified: Date.now() / 1000 + 60, + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: Date.now() / 1000, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderDDDDDD"], + "Should leave D with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "folderAAAAAA", "folderDDDDDD"], + deleted: [], + }, + "Should upload records for merged and new local items" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "folderAAAAAA", + "bookmarkEEEE", + "bookmarkBBBB", + "folderDDDDDD", + ]); + observer.check([ + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: "folderDDDDDD", + newParentGuid: "folderDDDDDD", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + title: "E", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderDDDDDD", + newParentGuid: "folderDDDDDD", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + title: "B", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("folderDDDDDD"), + title: "D (remote)", + guid: "folderDDDDDD", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A (local)", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "D (remote)", + children: [ + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }, + "Should reconcile structure and value changes" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move() { + let buf = await openMirror("move"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "devFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Dev", + children: [ + { + guid: "mdnBmk______", + title: "MDN", + url: "https://developer.mozilla.org", + }, + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "mozFolder___", + title: "Mozilla", + children: [ + { + guid: "fxBmk_______", + title: "Get Firefox!", + url: "http://getfirefox.com/", + }, + { + guid: "nightlyBmk__", + title: "Nightly", + url: "https://nightly.mozilla.org", + }, + ], + }, + { + guid: "wmBmk_______", + title: "Webmaker", + url: "https://webmaker.org", + }, + ], + }, + { + guid: "bzBmk_______", + title: "Bugzilla", + url: "https://bugzilla.mozilla.org", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + await storeRecords( + buf, + shuffle([ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["mozFolder___"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["devFolder___"], + }, + { + // Moving to toolbar. + id: "devFolder___", + parentid: "toolbar", + type: "folder", + title: "Dev", + children: ["bzBmk_______", "wmBmk_______"], + }, + { + // Moving to "Mozilla". + id: "mdnBmk______", + parentid: "mozFolder___", + type: "bookmark", + title: "MDN", + bmkUri: "https://developer.mozilla.org", + }, + { + // Rearranging children and moving to unfiled. + id: "mozFolder___", + parentid: "unfiled", + type: "folder", + title: "Mozilla", + children: ["nightlyBmk__", "mdnBmk______", "fxBmk_______"], + }, + { + id: "fxBmk_______", + parentid: "mozFolder___", + type: "bookmark", + title: "Get Firefox!", + bmkUri: "http://getfirefox.com/", + }, + { + id: "nightlyBmk__", + parentid: "mozFolder___", + type: "bookmark", + title: "Nightly", + bmkUri: "https://nightly.mozilla.org", + }, + { + id: "wmBmk_______", + parentid: "devFolder___", + type: "bookmark", + title: "Webmaker", + bmkUri: "https://webmaker.org", + }, + { + id: "bzBmk_______", + parentid: "devFolder___", + type: "bookmark", + title: "Bugzilla", + bmkUri: "https://bugzilla.mozilla.org", + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remotely moved items" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "devFolder___", + "mozFolder___", + "bzBmk_______", + "wmBmk_______", + "nightlyBmk__", + "mdnBmk______", + "fxBmk_______", + ]); + observer.check([ + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("devFolder___"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "devFolder___", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + title: "Dev", + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("mozFolder___"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "mozFolder___", + oldParentGuid: "devFolder___", + newParentGuid: PlacesUtils.bookmarks.unfiledGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + title: "Mozilla", + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bzBmk_______"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bzBmk_______", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: "devFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://bugzilla.mozilla.org/", + isTagging: false, + title: "Bugzilla", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("wmBmk_______"), + oldIndex: 2, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "wmBmk_______", + oldParentGuid: "devFolder___", + newParentGuid: "devFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://webmaker.org/", + isTagging: false, + title: "Webmaker", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("nightlyBmk__"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "nightlyBmk__", + oldParentGuid: "mozFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://nightly.mozilla.org/", + isTagging: false, + title: "Nightly", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("mdnBmk______"), + oldIndex: 0, + newIndex: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "mdnBmk______", + oldParentGuid: "devFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://developer.mozilla.org/", + isTagging: false, + title: "MDN", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("fxBmk_______"), + oldIndex: 0, + newIndex: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "fxBmk_______", + oldParentGuid: "mozFolder___", + newParentGuid: "mozFolder___", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://getfirefox.com/", + isTagging: false, + title: "Get Firefox!", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "devFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Dev", + children: [ + { + guid: "bzBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "Bugzilla", + url: "https://bugzilla.mozilla.org/", + }, + { + guid: "wmBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "Webmaker", + url: "https://webmaker.org/", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "mozFolder___", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "Mozilla", + children: [ + { + guid: "nightlyBmk__", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "Nightly", + url: "https://nightly.mozilla.org/", + }, + { + guid: "mdnBmk______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "MDN", + url: "https://developer.mozilla.org/", + }, + { + guid: "fxBmk_______", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "Get Firefox!", + url: "http://getfirefox.com/", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move and reorder bookmarks to match remote" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_into_parent_sibling() { + // This test moves a bookmark that exists locally into a new folder that only + // exists remotely, and is a later sibling of the local parent. This ensures + // we set up the local structure before applying structure changes. + let buf = await openMirror("move_into_parent_sibling"); + + info("Set up mirror: Menu > A > B"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: Menu > (A (B > C))"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderCCCCCC"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "folderCCCCCC", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only structure changes" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "folderCCCCCC", + "bookmarkBBBB", + "folderAAAAAA", + PlacesUtils.bookmarks.menuGuid, + ]); + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("folderCCCCCC"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + urlHref: "", + title: "C", + guid: "folderCCCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: "folderAAAAAA", + newParentGuid: "folderCCCCCC", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + title: "B", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "C", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }, + "Should set up local structure correctly" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_complex_move_with_additions() { + let mergeTelemetryCounts; + let buf = await openMirror("complex_move_with_additions", { + recordStepTelemetry(name, took, counts) { + if (name == "merge") { + mergeTelemetryCounts = counts; + } + }, + }); + + info("Set up mirror: Menu > A > (B C)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local change: Menu > A > (B C D)"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkDDDD", + parentGuid: "folderAAAAAA", + title: "D (local)", + url: "http://example.com/d-local", + }); + + info("Make remote change: ((Menu > C) (Toolbar > A > (B E)))"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "toolbar", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkEEEE"], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderAAAAAA"], + "Should leave A with new remote structure unmerged" + ); + deepEqual( + mergeTelemetryCounts, + [{ name: "items", count: 10 }], + "Should record telemetry with structure change counts" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkDDDD", "folderAAAAAA"], + deleted: [], + }, + "Should upload new records for (A D)" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "bookmarkEEEE", + "folderAAAAAA", + "bookmarkCCCC", + ]); + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + parentId: localItemIds.get("folderAAAAAA"), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/e", + title: "E", + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: "folderAAAAAA", + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c", + isTagging: false, + title: "C", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("folderAAAAAA"), + oldIndex: 0, + newIndex: 0, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + guid: "folderAAAAAA", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "", + isTagging: false, + title: "A", + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + dateAdded: 0, + lastVisitDate: null, + }, + }, + ]); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + // We can guarantee child order (B E D), since we always walk remote + // children first, and the remote folder A record is newer than the + // local folder. If the local folder were newer, the order would be + // (D B E). + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "D (local)", + url: "http://example.com/d-local", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should take remote order and preserve local children" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_reorder_and_insert() { + let buf = await openMirror("reorder_and_insert"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkDDDD", "bookmarkEEEE", "bookmarkFFFF"], + }, + { + id: "bookmarkDDDD", + parentid: "toolbar", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "bookmarkFFFF", + parentid: "toolbar", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + let now = Date.now(); + + info("Make local changes: Reorder Menu, Toolbar > (G H)"); + await PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, [ + "bookmarkCCCC", + "bookmarkAAAA", + "bookmarkBBBB", + ]); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now), + lastModified: new Date(now), + }, + { + guid: "bookmarkHHHH", + url: "http://example.com/h", + title: "H", + dateAdded: new Date(now), + lastModified: new Date(now), + }, + ], + }); + + info("Make remote changes: Reorder Toolbar, Menu > (I J)"); + await storeRecords( + buf, + shuffle([ + { + // The server has a newer toolbar, so we should use the remote order (F D E) + // as the base, then append (G H). + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkFFFF", "bookmarkDDDD", "bookmarkEEEE"], + modified: now / 1000 + 5, + }, + { + // The server has an older menu, so we should use the local order (C A B) + // as the base, then append (I J). + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkIIII", + "bookmarkJJJJ", + ], + modified: now / 1000 - 5, + }, + { + id: "bookmarkIIII", + parentid: "menu", + type: "bookmark", + title: "I", + bmkUri: "http://example.com/i", + }, + { + id: "bookmarkJJJJ", + parentid: "menu", + type: "bookmark", + title: "J", + bmkUri: "http://example.com/j", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + remoteTimeSeconds: now / 1000, + localTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid, PlacesUtils.bookmarks.toolbarGuid], + "Should leave roots with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkGGGG", "bookmarkHHHH", "menu", "toolbar"], + deleted: [], + }, + "Should upload records for merged and new local items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + url: "http://example.com/c", + title: "C", + }, + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + url: "http://example.com/i", + title: "I", + }, + { + guid: "bookmarkJJJJ", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + url: "http://example.com/j", + title: "J", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + url: "http://example.com/f", + title: "F", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + url: "http://example.com/e", + title: "E", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + url: "http://example.com/g", + title: "G", + }, + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + url: "http://example.com/h", + title: "H", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should use timestamps to decide base folder order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_remote_moves() { + let now = Date.now(); + let buf = await openMirror("newer_remote_moves"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info( + "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G" + ); + let localMoves = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + guid: "folderBBBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + }, + { + guid: "bookmarkCCCC", + parentGuid: "folderDDDDDD", + }, + { + guid: "bookmarkGGGG", + parentGuid: "folderHHHHHH", + }, + ]; + for (let { guid, parentGuid } of localMoves) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid, + index: 0, + lastModified: new Date(now - 2500), + }); + } + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.toolbarGuid, + ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + { lastModified: new Date(now - 2500) } + ); + + info( + "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C" + ); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // This is similar to H > C, explained below, except we'll always reupload + // the mobile root, because we always prefer the local state for roots. + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderBBBBBB"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderBBBBBB", + parentid: "unfiled", + type: "folder", + title: "B", + children: [], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // Reparenting an item uploads records for the item and its parent. + // The merger would still work if we only marked H as unmerged; we'd + // then use the remote state for H, and local state for C. Since C was + // changed locally, we'll reupload it, even though it didn't actually + // change. + id: "bookmarkCCCC", + parentid: "folderHHHHHH", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000, + children: ["bookmarkGGGG"], + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: [], + dateAdded: now - 5000, + modified: now / 1000, + }, + { + // Same as C above. + id: "bookmarkGGGG", + parentid: "folderDDDDDD", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave roots with new remote structure unmerged" + ); + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + // We took the remote structure for the roots, but they're still flagged as + // changed locally. Since we always use the local state for roots + // (bug 1472241), and can't distinguish between value and structure changes + // in Places (see the comment for F below), we'll reupload them. + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + children: ["folderDDDDDD"], + title: BookmarksMenuTitle, + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + children: ["bookmarkAAAA"], + title: MobileBookmarksTitle, + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + children: ["folderBBBBBB"], + title: UnfiledBookmarksTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + title: BookmarksToolbarTitle, + }, + }, + }, + "Should only reupload local roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "D", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "F", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "H", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "B", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + ], + }, + "Should use newer remote parents and order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_newer_local_moves() { + let now = Date.now(); + let buf = await openMirror("newer_local_moves"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + dateAdded: new Date(now - 5000), + lastModified: new Date(now - 5000), + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB", "folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkEEEE", "folderFFFFFF", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + dateAdded: now - 5000, + modified: now / 1000 - 5, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info( + "Make local changes: Unfiled > A, Mobile > B; Toolbar > (H F E); D > C; H > G" + ); + let localMoves = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + { + guid: "folderBBBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + }, + { + guid: "bookmarkCCCC", + parentGuid: "folderDDDDDD", + }, + { + guid: "bookmarkGGGG", + parentGuid: "folderHHHHHH", + }, + ]; + for (let { guid, parentGuid } of localMoves) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid, + index: 0, + lastModified: new Date(now), + }); + } + await PlacesUtils.bookmarks.reorder( + PlacesUtils.bookmarks.toolbarGuid, + ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + { lastModified: new Date(now) } + ); + + info( + "Make remote changes: Mobile > A, Unfiled > B; Toolbar > (F E H); D > G; H > C" + ); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderDDDDDD"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "mobile", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkAAAA", + parentid: "mobile", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderBBBBBB"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderBBBBBB", + parentid: "unfiled", + type: "folder", + title: "B", + children: [], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderFFFFFF", "bookmarkEEEE", "folderHHHHHH"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderHHHHHH", + parentid: "toolbar", + type: "folder", + title: "H", + children: ["bookmarkCCCC"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkCCCC", + parentid: "folderHHHHHH", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderDDDDDD", + parentid: "menu", + type: "folder", + title: "D", + children: ["bookmarkGGGG"], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "folderFFFFFF", + parentid: "toolbar", + type: "folder", + title: "F", + children: [], + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + { + id: "bookmarkGGGG", + parentid: "folderDDDDDD", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + dateAdded: now - 5000, + modified: now / 1000 - 2.5, + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkGGGG", + "folderBBBBBB", + "folderDDDDDD", + "folderFFFFFF", + "folderHHHHHH", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave items with new remote structure unmerged" + ); + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + // Reupload roots with new children. + menu: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + children: ["folderDDDDDD"], + title: BookmarksMenuTitle, + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + children: ["folderBBBBBB"], + title: MobileBookmarksTitle, + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + children: ["bookmarkAAAA"], + title: UnfiledBookmarksTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + children: ["folderHHHHHH", "folderFFFFFF", "bookmarkEEEE"], + title: BookmarksToolbarTitle, + }, + }, + // G moved to H from F, so F and H have new children, and we need + // to upload G for the new `parentid`. + folderFFFFFF: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderFFFFFF", + type: "folder", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: now - 5000, + children: [], + title: "F", + }, + }, + folderHHHHHH: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderHHHHHH", + type: "folder", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: now - 5000, + children: ["bookmarkGGGG"], + title: "H", + }, + }, + bookmarkGGGG: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkGGGG", + type: "bookmark", + parentid: "folderHHHHHH", + hasDupe: true, + parentName: "H", + dateAdded: now - 5000, + bmkUri: "http://example.com/g", + title: "G", + }, + }, + // C moved to D, so we need to reupload D (for `children`) and C + // (for `parentid`). + folderDDDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now - 5000, + children: ["bookmarkCCCC"], + title: "D", + }, + }, + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "folderDDDDDD", + hasDupe: true, + parentName: "D", + dateAdded: now - 5000, + bmkUri: "http://example.com/c", + title: "C", + }, + }, + // Reupload A with the new `parentid`. B moved to mobile *and* has + // new children` so we should upload it, anyway. + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: now - 5000, + bmkUri: "http://example.com/a", + title: "A", + }, + }, + folderBBBBBB: { + tombstone: false, + counter: 2, + synced: false, + cleartext: { + id: "folderBBBBBB", + type: "folder", + parentid: "mobile", + hasDupe: true, + parentName: MobileBookmarksTitle, + dateAdded: now - 5000, + children: [], + title: "B", + }, + }, + }, + "Should reupload new local structure" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "D", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "H", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "F", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "B", + }, + ], + }, + ], + }, + "Should use newer local parents and order" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_unchanged_newer_changed_older() { + let buf = await openMirror("unchanged_newer_changed_older"); + let modified = new Date(Date.now() - 5000); + + info("Set up mirror"); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.menuGuid, + dateAdded: new Date(modified.getTime() - 5000), + }); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.toolbarGuid, + dateAdded: new Date(modified.getTime() - 5000), + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + dateAdded: new Date(modified.getTime() - 5000), + lastModified: modified, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "bookmarkBBBB"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["folderCCCCCC", "bookmarkDDDD"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "folderCCCCCC", + parentid: "toolbar", + type: "folder", + title: "C", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + { + id: "bookmarkDDDD", + parentid: "toolbar", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000, + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Even though the local menu is newer (local = 5s, remote = 9s; adding E + // updated the modified times of A and the menu), it's not *changed* locally, + // so we should merge remote children first. + info("Add A > E locally with newer time; delete A remotely with older time"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkEEEE", + parentGuid: "folderAAAAAA", + url: "http://example.com/e", + title: "E", + index: 0, + dateAdded: new Date(modified.getTime() + 5000), + lastModified: new Date(modified.getTime() + 5000), + }); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 1, + }, + { + id: "folderAAAAAA", + deleted: true, + }, + ]); + + // Even though the remote toolbar is newer (local = 15s, remote = 10s), it's + // not changed remotely, so we should merge local children first. + info("Add C > F remotely with newer time; delete C locally with older time"); + await storeRecords( + buf, + shuffle([ + { + id: "folderCCCCCC", + parentid: "toolbar", + type: "folder", + title: "C", + children: ["bookmarkFFFF"], + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 5, + }, + { + id: "bookmarkFFFF", + parentid: "folderCCCCCC", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + dateAdded: modified.getTime() - 5000, + modified: modified.getTime() / 1000 + 5, + }, + ]) + ); + await PlacesUtils.bookmarks.remove("folderCCCCCC"); + await PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.toolbarGuid, + lastModified: new Date(modified.getTime() - 5000), + // Use `SOURCES.SYNC` to avoid bumping the change counter and flagging the + // local toolbar as modified. + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: modified.getTime() / 1000 + 10, + remoteTimeSeconds: modified.getTime() / 1000 + 10, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkFFFF", "folderCCCCCC", PlacesUtils.bookmarks.menuGuid], + "Should leave deleted C; F and menu with new remote structure unmerged" + ); + + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: modified.getTime() - 5000, + children: ["bookmarkBBBB", "bookmarkEEEE"], + title: BookmarksMenuTitle, + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: modified.getTime() - 5000, + children: ["bookmarkDDDD", "bookmarkFFFF"], + title: BookmarksToolbarTitle, + }, + }, + // Upload E and F with new `parentid`. + bookmarkEEEE: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkEEEE", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: modified.getTime() + 5000, + bmkUri: "http://example.com/e", + title: "E", + }, + }, + bookmarkFFFF: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkFFFF", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: modified.getTime() - 5000, + bmkUri: "http://example.com/f", + title: "F", + }, + }, + folderCCCCCC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderCCCCCC", + deleted: true, + }, + }, + }, + "Should reupload menu, toolbar, E, F with new structure; tombstone for C" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "F", + url: "http://example.com/f", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should merge children of changed side first, even if they're older" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["folderCCCCCC"], + "Should store local tombstone for C" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js b/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js new file mode 100644 index 0000000000..e5e1d4e078 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_unknown_fields.js @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_bookmark_unknown_fields() { + let buf = await openMirror("unknown_fields"); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + unknownStr: "an unknown field", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + await storeRecords( + buf, + [ + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "New Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + unknownStr: "a new unknown field", + }, + ], + { needsMerge: true } + ); + + let controller = new AbortController(); + const wasMerged = await buf.merge(controller.signal); + Assert.ok(wasMerged); + + let itemRows = await buf.db.execute(`SELECT guid, unknownFields FROM items`); + + let updatedBookmark = itemRows.find( + row => row.getResultByName("guid") == "mozBmk______" + ); + deepEqual(JSON.parse(updatedBookmark.getResultByName("unknownFields")), { + unknownStr: "a new unknown field", + }); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_unknown_fields_all_types() { + let buf = await openMirror("unknown_fields_all"); + + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + title: "menu", + children: ["bookmarkAAAA", "separatorAAA", "queryAAAAAAA"], + unknownFolderField: "an unknown folder field", + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "Mozilla2", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + unknownStrField: "an unknown bookmark field", + unknownStrObj: { newField: "unknown pt deux" }, + }, + { + id: "separatorAAA", + parentid: "menu", + type: "separator", + unknownSepField: "an unknown separator field", + }, + { + id: "queryAAAAAAA", + parentid: "menu", + type: "bookmark", + title: "a query", + bmkUri: "place:foo", + unknownQueryField: "an unknown query field", + }, + ], + { needsMerge: true } + ); + + await PlacesTestUtils.markBookmarksAsSynced(); + + let changesToUpload = await buf.apply(); + // Should be no local changes needing to be uploaded + deepEqual(changesToUpload, {}); + + // Make updates to all the type of bookmarks + await PlacesUtils.bookmarks.update({ + guid: "menu________", + title: "updated menu", + }); + await PlacesUtils.bookmarks.update({ + guid: "bookmarkAAAA", + title: "Mozilla3", + }); + await PlacesUtils.bookmarks.update({ guid: "separatorAAA", index: 2 }); + await PlacesUtils.bookmarks.update({ + guid: "queryAAAAAAA", + title: "an updated query", + }); + + // We should now have a bunch of changes to upload + changesToUpload = await buf.apply(); + const { menu, bookmarkAAAA, separatorAAA, queryAAAAAAA } = changesToUpload; + + // Validate we have the updated title as well as the unknown fields + Assert.equal(menu.cleartext.title, "updated menu"); + Assert.equal(menu.cleartext.unknownFolderField, "an unknown folder field"); + + // Test bookmark unknown fields + Assert.equal(bookmarkAAAA.cleartext.title, "Mozilla3"); + Assert.equal( + bookmarkAAAA.cleartext.unknownStrField, + "an unknown bookmark field" + ); + deepEqual(bookmarkAAAA.cleartext.unknownStrObj, { + newField: "unknown pt deux", + }); + + // Test separator unknown fields + Assert.equal( + separatorAAA.cleartext.unknownSepField, + "an unknown separator field" + ); + + // Test query unknown fields + Assert.equal(queryAAAAAAA.cleartext.title, "an updated query"); + Assert.equal( + queryAAAAAAA.cleartext.unknownQueryField, + "an unknown query field" + ); + + let itemRows = await buf.db.execute(`SELECT guid, unknownFields FROM items`); + + // Test bookmark correctly JSON'd in the mirror + let remoteBookmark = itemRows.find( + row => row.getResultByName("guid") == "bookmarkAAAA" + ); + deepEqual(JSON.parse(remoteBookmark.getResultByName("unknownFields")), { + unknownStrField: "an unknown bookmark field", + unknownStrObj: { newField: "unknown pt deux" }, + }); + + // Test folder correctly JSON'd in the mirror + let remoteFolder = itemRows.find( + row => row.getResultByName("guid") == "menu________" + ); + deepEqual(JSON.parse(remoteFolder.getResultByName("unknownFields")), { + unknownFolderField: "an unknown folder field", + }); + // Test query correctly JSON'd in the mirror + let remoteQuery = itemRows.find( + row => row.getResultByName("guid") == "queryAAAAAAA" + ); + deepEqual(JSON.parse(remoteQuery.getResultByName("unknownFields")), { + unknownQueryField: "an unknown query field", + }); + // Test separator correctly JSON'd in the mirror + let remoteSeparator = itemRows.find( + row => row.getResultByName("guid") == "separatorAAA" + ); + deepEqual(JSON.parse(remoteSeparator.getResultByName("unknownFields")), { + unknownSepField: "an unknown separator field", + }); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_bookmark_value_changes.js b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js new file mode 100644 index 0000000000..be20a59c68 --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_value_changes.js @@ -0,0 +1,2639 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_value_combo() { + let buf = await openMirror("value_combo"); + let now = Date.now(); + + info("Set up mirror with existing bookmark to update"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "mozBmk______", + url: "https://mozilla.org", + title: "Mozilla", + tags: ["moz", "dot", "org"], + dateAdded: new Date(now), + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["mozBmk______"], + }, + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla", + bmkUri: "https://mozilla.org", + tags: ["moz", "dot", "org"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Insert new local bookmark to upload"); + let [bzBmk] = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bzBmk_______", + url: "https://bugzilla.mozilla.org", + title: "Bugzilla", + tags: ["new", "tag"], + }, + ], + }); + + info("Insert remote bookmarks and folder to apply"); + await storeRecords( + buf, + shuffle([ + { + id: "mozBmk______", + parentid: "menu", + type: "bookmark", + title: "Mozilla home page", + bmkUri: "https://mozilla.org", + tags: ["browsers"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["fxBmk_______", "tFolder_____"], + }, + { + id: "fxBmk_______", + parentid: "toolbar", + type: "bookmark", + title: "Get Firefox", + bmkUri: "http://getfirefox.com", + tags: ["taggy", "browsers"], + dateAdded: now, + }, + { + id: "tFolder_____", + parentid: "toolbar", + type: "folder", + title: "Mail", + children: ["tbBmk_______"], + dateAdded: now, + }, + { + id: "tbBmk_______", + parentid: "tFolder_____", + type: "bookmark", + title: "Get Thunderbird", + bmkUri: "http://getthunderbird.com", + keyword: "tb", + dateAdded: now, + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications({ + skipTags: true, + ignoreDates: false, + }); + let localTimeSeconds = Math.floor(now / 1000); + let changesToUpload = await buf.apply({ + localTimeSeconds, + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.toolbarGuid], + "Should leave toolbar with new remote structure unmerged" + ); + + let menuInfo = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + changesToUpload, + { + bzBmk_______: { + tombstone: false, + counter: 3, + synced: false, + cleartext: { + id: "bzBmk_______", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: bzBmk.dateAdded.getTime(), + bmkUri: "https://bugzilla.mozilla.org/", + title: "Bugzilla", + tags: ["new", "tag"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: menuInfo.dateAdded.getTime(), + title: BookmarksToolbarTitle, + children: ["fxBmk_______", "tFolder_____", "bzBmk_______"], + }, + }, + }, + "Should upload new local bookmarks and parents" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "fxBmk_______", + "tFolder_____", + "tbBmk_______", + "bzBmk_______", + "mozBmk______", + PlacesUtils.bookmarks.toolbarGuid, + ]); + + observer.check([ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("fxBmk_______"), + parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://getfirefox.com/", + title: "Get Firefox", + guid: "fxBmk_______", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + tags: "browsers,taggy", + frecency: 1, + hidden: false, + visitCount: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("tFolder_____"), + parentId: localItemIds.get(PlacesUtils.bookmarks.toolbarGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + urlHref: "", + title: "Mail", + guid: "tFolder_____", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + tags: "", + frecency: 0, + hidden: false, + visitCount: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("tbBmk_______"), + parentId: localItemIds.get("tFolder_____"), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://getthunderbird.com/", + title: "Get Thunderbird", + guid: "tbBmk_______", + parentGuid: "tFolder_____", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + dateAdded: now, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bzBmk_______"), + oldIndex: 0, + newIndex: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bzBmk_______", + oldParentGuid: PlacesUtils.bookmarks.toolbarGuid, + newParentGuid: PlacesUtils.bookmarks.toolbarGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "https://bugzilla.mozilla.org/", + isTagging: false, + title: "Bugzilla", + tags: "new,tag", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: bzBmk.dateAdded.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("mozBmk______"), + title: "Mozilla home page", + guid: "mozBmk______", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + let fxBmk = await PlacesUtils.bookmarks.fetch("fxBmk_______"); + ok(fxBmk, "New Firefox bookmark should exist"); + equal( + fxBmk.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Should add Firefox bookmark to toolbar" + ); + let fxTags = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://getfirefox.com") + ); + deepEqual(fxTags, ["browsers", "taggy"], "Should tag new Firefox bookmark"); + + let folder = await PlacesUtils.bookmarks.fetch("tFolder_____"); + ok(folder, "New folder should exist"); + equal( + folder.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Should add new folder to toolbar" + ); + + let tbBmk = await PlacesUtils.bookmarks.fetch("tbBmk_______"); + ok(tbBmk, "Should insert Thunderbird child bookmark"); + equal( + tbBmk.parentGuid, + folder.guid, + "Should add Thunderbird bookmark to new folder" + ); + let keywordInfo = await PlacesUtils.keywords.fetch("tb"); + equal( + keywordInfo.url.href, + "http://getthunderbird.com/", + "Should set keyword for Thunderbird bookmark" + ); + + let updatedBmk = await PlacesUtils.bookmarks.fetch("mozBmk______"); + equal( + updatedBmk.title, + "Mozilla home page", + "Should rename Mozilla bookmark" + ); + equal( + updatedBmk.parentGuid, + PlacesUtils.bookmarks.menuGuid, + "Should not move Mozilla bookmark" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_value_only_changes() { + let buf = await openMirror("value_only_changes"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + { + guid: "folderJJJJJJ", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "J", + children: [ + { + guid: "bookmarkKKKK", + url: "http://example.com/k", + title: "K", + }, + ], + }, + { + guid: "bookmarkDDDD", + url: "http://example.com/d", + title: "D", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "F", + children: [ + { + guid: "bookmarkGGGG", + url: "http://example.com/g", + title: "G", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "H", + children: [ + { + guid: "bookmarkIIII", + url: "http://example.com/i", + title: "I", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA", "folderFFFFFF"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "folderJJJJJJ", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "folderJJJJJJ", + parentid: "folderAAAAAA", + type: "folder", + title: "J", + children: ["bookmarkKKKK"], + }, + { + id: "bookmarkKKKK", + parentid: "folderJJJJJJ", + type: "bookmark", + title: "K", + bmkUri: "http://example.com/k", + }, + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F", + children: ["bookmarkGGGG", "folderHHHHHH"], + }, + { + id: "bookmarkGGGG", + parentid: "folderFFFFFF", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + { + id: "folderHHHHHH", + parentid: "folderFFFFFF", + type: "folder", + title: "H", + children: ["bookmarkIIII"], + }, + { + id: "bookmarkIIII", + parentid: "folderHHHHHH", + type: "bookmark", + title: "I", + bmkUri: "http://example.com/i", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E (remote)", + bmkUri: "http://example.com/e-remote", + }, + { + id: "bookmarkIIII", + parentid: "folderHHHHHH", + type: "bookmark", + title: "I (remote)", + bmkUri: "http://example.com/i-remote", + }, + { + id: "folderFFFFFF", + parentid: "menu", + type: "folder", + title: "F (remote)", + children: ["bookmarkGGGG", "folderHHHHHH"], + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not upload records for remote-only value changes" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "folderJJJJJJ", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "J", + children: [ + { + guid: "bookmarkKKKK", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "K", + url: "http://example.com/k", + }, + ], + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D", + url: "http://example.com/d", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "E (remote)", + url: "http://example.com/e-remote", + }, + ], + }, + { + guid: "folderFFFFFF", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "F (remote)", + children: [ + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "G", + url: "http://example.com/g", + }, + { + guid: "folderHHHHHH", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "H", + children: [ + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "I (remote)", + url: "http://example.com/i-remote", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not change structure for value-only changes" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_conflicting_keywords() { + let buf = await openMirror("conflicting_keywords"); + let dateAdded = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "one", + dateAdded, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: dateAdded.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + { + let entryByKeyword = await PlacesUtils.keywords.fetch("one"); + equal( + entryByKeyword.url.href, + "http://example.com/a", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "one", "Should return new entry by keyword"); + } + + info("Insert new bookmark with same URL and different keyword"); + { + await storeRecords( + buf, + shuffle([ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkAAA1"], + }, + { + id: "bookmarkAAA1", + parentid: "toolbar", + type: "bookmark", + title: "A1", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: dateAdded.getTime(), + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA1"], + "Should leave A1 with conflicting keyword unmerged" + ); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + keyword: "two", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A1", + keyword: "two", + }, + }, + }, + "Should reupload bookmarks with different keyword" + ); + await storeChangesInMirror(buf, changesToUpload); + + let entryByOldKeyword = await PlacesUtils.keywords.fetch("one"); + ok( + !entryByOldKeyword, + "Should remove old entry when inserting bookmark with different keyword" + ); + let entryByNewKeyword = await PlacesUtils.keywords.fetch("two"); + equal( + entryByNewKeyword.url.href, + "http://example.com/a", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "two", "Should return new entry by URL"); + } + + info("Update bookmark with different keyword"); + { + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "three", + dateAdded: dateAdded.getTime(), + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA"], + "Should leave A with conflicting keyword unmerged" + ); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + keyword: "three", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A1", + keyword: "three", + }, + }, + }, + "Should reupload A and A1 with updated keyword" + ); + await storeChangesInMirror(buf, changesToUpload); + + let entryByOldKeyword = await PlacesUtils.keywords.fetch("two"); + ok( + !entryByOldKeyword, + "Should remove old entry when updating bookmark keyword" + ); + let entryByNewKeyword = await PlacesUtils.keywords.fetch("three"); + equal( + entryByNewKeyword.url.href, + "http://example.com/a", + "Should return updated keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://example.com/a", + }); + equal(entryByURL.keyword, "three", "Should return updated entry by URL"); + } + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_keywords() { + let buf = await openMirror("keywords"); + let now = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + keyword: "one", + dateAdded: now, + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "two", + dateAdded: now, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + dateAdded: now, + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + keyword: "three", + dateAdded: now, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now.getTime(), + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + keyword: "three", + dateAdded: now.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Change keywords remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: now.getTime(), + }, + ]) + ); + + info("Change keywords locally"); + await PlacesUtils.keywords.insert({ + keyword: "four", + url: "http://example.com/c", + }); + await PlacesUtils.keywords.remove("three"); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + deepEqual( + changesToUpload, + { + bookmarkCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/c", + title: "C", + keyword: "four", + }, + }, + bookmarkDDDD: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkDDDD", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/d", + title: "D", + }, + }, + }, + "Should upload C with new keyword, D with keyword removed" + ); + + let entryForOne = await PlacesUtils.keywords.fetch("one"); + ok(!entryForOne, "Should remove existing keyword from A"); + + let entriesForTwo = await fetchAllKeywords("two"); + deepEqual( + entriesForTwo.map(entry => ({ + keyword: entry.keyword, + url: entry.url.href, + })), + [ + { + keyword: "two", + url: "http://example.com/a", + }, + ], + "Should move keyword for B to A" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_keywords_complex() { + let buf = await openMirror("keywords_complex"); + let now = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + keyword: "four", + dateAdded: now, + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + keyword: "five", + dateAdded: now, + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + dateAdded: now, + }, + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + keyword: "three", + dateAdded: now, + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + keyword: "four", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + keyword: "five", + dateAdded: now.getTime(), + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + dateAdded: now.getTime(), + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + keyword: "three", + dateAdded: now.getTime(), + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkAAA1", + "bookmarkBBB1", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + keyword: "one", + dateAdded: now.getTime(), + }, + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A (copy)", + bmkUri: "http://example.com/a", + keyword: "two", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + dateAdded: now.getTime(), + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + keyword: "six", + dateAdded: now.getTime(), + }, + ]) + ); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAA1", "bookmarkAAAA", "bookmarkBBB1"], + "Should leave A1, A, B with conflicting keywords unmerged" + ); + + let expectedChangesToUpload = { + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkBBB1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBB1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkAAA1: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAA1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A (copy)", + }, + }, + }; + + // We'll take the keyword of either "bookmarkAAAA" or "bookmarkAAA1", + // depending on which we see first, and reupload the other. + let entriesForOne = await fetchAllKeywords("one"); + let entriesForTwo = await fetchAllKeywords("two"); + if (entriesForOne.length) { + ok(!entriesForTwo.length, "Should drop conflicting keyword from A1"); + deepEqual( + entriesForOne.map(keyword => keyword.url.href), + ["http://example.com/a"], + "Should use A keyword for A and A1" + ); + expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "one"; + expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "one"; + } else { + ok(!entriesForOne.length, "Should drop conflicting keyword from A"); + deepEqual( + entriesForTwo.map(keyword => keyword.url.href), + ["http://example.com/a"], + "Should use A1 keyword for A and A1" + ); + expectedChangesToUpload.bookmarkAAAA.cleartext.keyword = "two"; + expectedChangesToUpload.bookmarkAAA1.cleartext.keyword = "two"; + } + deepEqual( + changesToUpload, + expectedChangesToUpload, + "Should reupload all local records with corrected keywords" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkAAA1", + "bookmarkBBB1", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + PlacesUtils.bookmarks.menuGuid, + ]); + let expectedNotifications = [ + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 0, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/a", + title: "A", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkAAA1"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 1, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/a", + title: "A (copy)", + guid: "bookmarkAAA1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + }, + }, + { + name: "bookmark-added", + params: { + itemId: localItemIds.get("bookmarkBBB1"), + parentId: localItemIds.get(PlacesUtils.bookmarks.menuGuid), + index: 2, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/b", + title: "B", + guid: "bookmarkBBB1", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + }, + }, + { + // These `bookmark-moved` notifications aren't necessary: we only moved + // (B C D E) to accomodate (A A1 B1), and Places doesn't usually fire move + // notifications for repositioned siblings. However, detecting and filtering + // these out complicates `noteObserverChanges`, so, for simplicity, we + // record and fire the extra notifications. + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + oldIndex: 0, + newIndex: 3, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkBBBB", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/b", + isTagging: false, + title: "B", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: now.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + oldIndex: 1, + newIndex: 4, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkCCCC", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/c-remote", + isTagging: false, + title: "C (remote)", + tags: "", + frecency: -1, + hidden: false, + visitCount: 0, + dateAdded: now.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkDDDD"), + oldIndex: 2, + newIndex: 5, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkDDDD", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/d", + isTagging: false, + title: "D", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: now.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-moved", + params: { + itemId: localItemIds.get("bookmarkEEEE"), + oldIndex: 3, + newIndex: 6, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + guid: "bookmarkEEEE", + oldParentGuid: PlacesUtils.bookmarks.menuGuid, + newParentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + urlHref: "http://example.com/e", + isTagging: false, + title: "E", + tags: "", + frecency: 1, + hidden: false, + visitCount: 0, + dateAdded: now.getTime(), + lastVisitDate: null, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + title: "C (remote)", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-url-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + urlHref: "http://example.com/c-remote", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + isTagging: false, + }, + }, + ]; + observer.check(expectedNotifications); + + let entriesForFour = await fetchAllKeywords("four"); + ok(!entriesForFour.length, "Should remove all keywords for B"); + + let entriesForOldC = await fetchAllKeywords({ + url: "http://example.com/c", + }); + ok(!entriesForOldC.length, "Should remove all keywords from old C URL"); + let entriesForNewC = await fetchAllKeywords({ + url: "http://example.com/c-remote", + }); + deepEqual( + entriesForNewC.map(entry => entry.keyword), + ["six"], + "Should add new keyword to new C URL" + ); + + let entriesForD = await fetchAllKeywords("http://example.com/d"); + ok(!entriesForD.length, "Should not add keywords to D"); + + let entriesForThree = await fetchAllKeywords("three"); + deepEqual( + entriesForThree.map(keyword => keyword.url.href), + ["http://example.com/e"], + "Should not change keywords for E" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tags_complex() { + let buf = await openMirror("tags_complex"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAA1", + title: "A1", + url: "http://example.com/a", + tags: ["one", "two"], + }, + { + guid: "bookmarkAAA2", + title: "A2", + url: "http://example.com/a", + tags: ["one", "two"], + }, + { + guid: "bookmarkBBB1", + title: "B1", + url: "http://example.com/b", + tags: ["one"], + }, + { + guid: "bookmarkBBB2", + title: "B2", + url: "http://example.com/b", + tags: ["one"], + }, + { + guid: "bookmarkCCC1", + title: "C1", + url: "http://example.com/c", + tags: ["two", "three"], + }, + { + guid: "bookmarkCCC2", + title: "C2", + url: "http://example.com/c", + tags: ["two", "three"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAA1", + "bookmarkAAA2", + "bookmarkBBB1", + "bookmarkBBB2", + "bookmarkCCC1", + "bookmarkCCC2", + ], + }, + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two"], + }, + { + id: "bookmarkBBB1", + parentid: "menu", + type: "bookmark", + title: "B1", + bmkUri: "http://example.com/b", + tags: ["one"], + }, + { + id: "bookmarkBBB2", + parentid: "menu", + type: "bookmark", + title: "B2", + bmkUri: "http://example.com/b", + tags: ["one"], + }, + { + id: "bookmarkCCC1", + parentid: "menu", + type: "bookmark", + title: "C1", + bmkUri: "http://example.com/c", + tags: ["two", "three"], + }, + { + id: "bookmarkCCC2", + parentid: "menu", + type: "bookmark", + title: "C2", + bmkUri: "http://example.com/c", + tags: ["two", "three"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add tags for B locally"); + PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/b"), [ + "four", + "five", + ]); + + info("Remove tag from C locally"); + PlacesUtils.tagging.untagURI(Services.io.newURI("http://example.com/c"), [ + "two", + ]); + + info("Update tags for A remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAA1", + parentid: "menu", + type: "bookmark", + title: "A1", + bmkUri: "http://example.com/a", + tags: ["one", "two", "four", "six"], + }, + { + id: "bookmarkAAA2", + parentid: "menu", + type: "bookmark", + title: "A2", + bmkUri: "http://example.com/a", + tags: ["one", "two", "four", "six"], + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + + let datesAdded = await promiseManyDatesAdded([ + "bookmarkBBB1", + "bookmarkBBB2", + "bookmarkCCC1", + "bookmarkCCC2", + ]); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + deepEqual( + changesToUpload, + { + bookmarkBBB1: { + counter: 2, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkBBB1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBB1"), + bmkUri: "http://example.com/b", + title: "B1", + tags: ["five", "four", "one"], + }, + }, + bookmarkBBB2: { + counter: 2, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkBBB2", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBB2"), + bmkUri: "http://example.com/b", + title: "B2", + tags: ["five", "four", "one"], + }, + }, + bookmarkCCC1: { + counter: 1, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkCCC1", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkCCC1"), + bmkUri: "http://example.com/c", + title: "C1", + tags: ["three"], + }, + }, + bookmarkCCC2: { + counter: 1, + synced: false, + tombstone: false, + cleartext: { + id: "bookmarkCCC2", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkCCC2"), + bmkUri: "http://example.com/c", + title: "C2", + tags: ["three"], + }, + }, + }, + "Should upload local records with new tags" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAA1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A1", + url: "http://example.com/a", + tags: ["four", "one", "six", "two"], + }, + { + guid: "bookmarkAAA2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "A2", + url: "http://example.com/a", + tags: ["four", "one", "six", "two"], + }, + { + guid: "bookmarkBBB1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B1", + url: "http://example.com/b", + tags: ["five", "four", "one"], + }, + { + guid: "bookmarkBBB2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "B2", + url: "http://example.com/b", + tags: ["five", "four", "one"], + }, + { + guid: "bookmarkCCC1", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 4, + title: "C1", + url: "http://example.com/c", + tags: ["three"], + }, + { + guid: "bookmarkCCC2", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 5, + title: "C2", + url: "http://example.com/c", + tags: ["three"], + }, + ], + }, + "Should update local items with new tags" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tags() { + let buf = await openMirror("tags"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + tags: ["one", "two", "three", "four"], + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + tags: ["five", "six"], + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + tags: ["seven", "eight", "nine"], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + ], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two", "three", "four"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + tags: ["five", "six"], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + tags: ["seven", "eight", "nine"], + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Change tags remotely"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + tags: ["one", "two", "ten"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + tags: [], + }, + ]) + ); + + info("Change tags locally"); + PlacesUtils.tagging.tagURI(Services.io.newURI("http://example.com/c"), [ + "eleven", + "twelve", + ]); + + let wait = PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some(event => event.parentGuid == PlacesUtils.bookmarks.tagsGuid) + ); + + PlacesUtils.tagging.untagURI( + Services.io.newURI("http://example.com/d"), + null + ); + + await wait; + + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "bookmarkDDDD"], + deleted: [], + }, + "Should upload local records with new tags" + ); + + deepEqual( + changesToUpload.bookmarkCCCC.cleartext.tags.sort(), + ["eleven", "twelve"], + "Should upload record with new tags for C" + ); + ok( + !changesToUpload.bookmarkDDDD.cleartext.tags, + "Should upload record for D with tags removed" + ); + + let tagsForA = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/a") + ); + deepEqual(tagsForA, ["one", "ten", "two"], "Should change tags for A"); + + let tagsForB = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/b") + ); + deepEqual(tagsForB, [], "Should remove all tags from B"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_rewrite_tag_queries() { + let buf = await openMirror("rewrite_tag_queries"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + tags: ["kitty"], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkDDDD"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + tags: ["kitty"], + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Add tag queries for new and existing tags"); + await storeRecords(buf, [ + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["queryBBBBBBB", "queryCCCCCCC", "bookmarkEEEE"], + }, + { + id: "queryBBBBBBB", + parentid: "toolbar", + type: "query", + title: "Tagged stuff", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + id: "queryCCCCCCC", + parentid: "toolbar", + type: "query", + title: "Cats", + bmkUri: "place:type=7&folder=888", + folderName: "kitty", + }, + { + id: "bookmarkEEEE", + parentid: "toolbar", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + tags: ["taggy"], + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["queryBBBBBBB", "queryCCCCCCC"], + "Should leave rewritten queries unmerged" + ); + + deepEqual( + changesToUpload, + { + queryBBBBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryBBBBBBB", + type: "query", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: undefined, + bmkUri: "place:tag=taggy", + title: "Tagged stuff", + folderName: "taggy", + }, + }, + queryCCCCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryCCCCCCC", + type: "query", + parentid: "toolbar", + hasDupe: true, + parentName: BookmarksToolbarTitle, + dateAdded: undefined, + bmkUri: "place:tag=kitty", + title: "Cats", + folderName: "kitty", + }, + }, + }, + "Should reupload (E C) with rewritten URLs" + ); + + let bmWithTaggy = await PlacesUtils.bookmarks.fetch({ tags: ["taggy"] }); + equal( + bmWithTaggy.url.href, + "http://example.com/e", + "Should insert bookmark with new tag" + ); + + let bmWithKitty = await PlacesUtils.bookmarks.fetch({ tags: ["kitty"] }); + equal( + bmWithKitty.url.href, + "http://example.com/d", + "Should retain existing tag" + ); + + let { root: toolbarContainer } = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid, + false, + true + ); + equal( + toolbarContainer.childCount, + 3, + "Should add queries and bookmark to toolbar" + ); + + let containerForB = PlacesUtils.asContainer(toolbarContainer.getChild(0)); + containerForB.containerOpen = true; + for (let i = 0; i < containerForB.childCount; ++i) { + let child = containerForB.getChild(i); + equal( + child.uri, + "http://example.com/e", + `Rewritten tag query B should have tagged child node at ${i}` + ); + } + containerForB.containerOpen = false; + + let containerForC = PlacesUtils.asContainer(toolbarContainer.getChild(1)); + containerForC.containerOpen = true; + for (let i = 0; i < containerForC.childCount; ++i) { + let child = containerForC.getChild(i); + equal( + child.uri, + "http://example.com/d", + `Rewritten tag query C should have tagged child node at ${i}` + ); + } + containerForC.containerOpen = false; + + toolbarContainer.containerOpen = false; + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_date_added() { + let buf = await openMirror("date_added"); + + let aDateAdded = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); + let bDateAdded = new Date(); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + dateAdded: aDateAdded, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + dateAdded: bDateAdded, + title: "B", + url: "http://example.com/b", + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + dateAdded: aDateAdded.getTime(), + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + dateAdded: bDateAdded.getTime(), + bmkUri: "http://example.com/b", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + let bNewDateAdded = new Date(bDateAdded.getTime() - 1 * 60 * 60 * 1000); + await storeRecords(buf, [ + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + dateAdded: Date.now(), + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B (remote)", + dateAdded: bNewDateAdded.getTime(), + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkAAAA"], + deleted: [], + }, + "Should flag A for weak reupload" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkBBBB", + ]); + observer.check([ + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + title: "A (remote)", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + title: "B (remote)", + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + ]); + + let changeCounter = changesToUpload.bookmarkAAAA.counter; + strictEqual(changeCounter, 0, "Should not bump change counter for A"); + + let aInfo = await PlacesUtils.bookmarks.fetch("bookmarkAAAA"); + equal(aInfo.title, "A (remote)", "Should change local title for A"); + deepEqual( + aInfo.dateAdded, + aDateAdded, + "Should not change date added for A to newer remote date" + ); + + let bInfo = await PlacesUtils.bookmarks.fetch("bookmarkBBBB"); + equal(bInfo.title, "B (remote)", "Should change local title for B"); + deepEqual( + bInfo.dateAdded, + bNewDateAdded, + "Should take older date added for B" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// Bug 1472435. +add_task(async function test_duplicate_url_rows() { + let buf = await openMirror("test_duplicate_url_rows"); + + let placesToInsert = [ + { + guid: "placeAAAAAAA", + href: "http://example.com", + }, + { + guid: "placeBBBBBBB", + href: "http://example.com", + }, + { + guid: "placeCCCCCCC", + href: "http://example.com/c", + }, + ]; + + let itemsToInsert = [ + { + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + placeGuid: "placeAAAAAAA", + localTitle: "A", + remoteTitle: "A (remote)", + }, + { + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + placeGuid: "placeBBBBBBB", + localTitle: "B", + remoteTitle: "B (remote)", + }, + { + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + placeGuid: "placeCCCCCCC", + localTitle: "C", + remoteTitle: "C (remote)", + }, + ]; + + info("Manually insert local and remote items with duplicate URLs"); + await buf.db.executeTransaction(async function () { + for (let { guid, href } of placesToInsert) { + let url = new URL(href); + await buf.db.executeCached( + ` + INSERT INTO moz_places(url, url_hash, rev_host, hidden, frecency, guid) + VALUES(:url, hash(:url), :revHost, 0, -1, :guid)`, + { url: url.href, revHost: PlacesUtils.getReversedHost(url), guid } + ); + + await buf.db.executeCached( + ` + INSERT INTO urls(guid, url, hash, revHost) + VALUES(:guid, :url, hash(:url), :revHost)`, + { guid, url: url.href, revHost: PlacesUtils.getReversedHost(url) } + ); + } + + for (let { + guid, + parentGuid, + placeGuid, + localTitle, + remoteTitle, + } of itemsToInsert) { + await buf.db.executeCached( + ` + INSERT INTO moz_bookmarks(guid, parent, fk, position, type, title, + syncStatus, syncChangeCounter) + VALUES(:guid, (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + (SELECT id FROM moz_places WHERE guid = :placeGuid), + (SELECT count(*) FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE p.guid = :parentGuid), :type, :localTitle, + :syncStatus, 1)`, + { + guid, + parentGuid, + placeGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + localTitle, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + } + ); + + await buf.db.executeCached( + ` + INSERT INTO items(guid, parentGuid, needsMerge, kind, title, urlId) + VALUES(:guid, :parentGuid, 1, :kind, :remoteTitle, + (SELECT id FROM urls WHERE guid = :placeGuid))`, + { + guid, + parentGuid, + placeGuid, + kind: Ci.mozISyncedBookmarksMerger.KIND_BOOKMARK, + remoteTitle, + } + ); + + await buf.db.executeCached( + ` + INSERT INTO structure(guid, parentGuid, position) + VALUES(:guid, :parentGuid, + IFNULL((SELECT count(*) FROM structure + WHERE parentGuid = :parentGuid), 0))`, + { guid, parentGuid } + ); + } + }); + + info("Apply mirror"); + let observer = expectBookmarkChangeNotifications(); + let changesToUpload = await buf.apply({ + notifyInStableOrder: true, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave roots unmerged" + ); + deepEqual( + Object.keys(changesToUpload).sort(), + ["menu", "mobile", "toolbar", "unfiled"], + "Should upload roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A (remote)", + url: "http://example.com/", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should update titles for items with duplicate URLs" + ); + + let localItemIds = await PlacesTestUtils.promiseManyItemIds([ + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkCCCC", + ]); + observer.check([ + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkAAAA"), + title: "A (remote)", + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkBBBB"), + title: "B (remote)", + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }, + }, + { + name: "bookmark-title-changed", + params: { + itemId: localItemIds.get("bookmarkCCCC"), + title: "C (remote)", + guid: "bookmarkCCCC", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }, + }, + ]); + + info("Remove duplicate URLs from Places to avoid tripping debug asserts"); + await buf.db.executeTransaction(async function () { + for (let { guid } of placesToInsert) { + await buf.db.executeCached( + ` + DELETE FROM moz_places WHERE guid = :guid`, + { guid } + ); + } + }); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_duplicate_local_tags() { + let buf = await openMirror("duplicate_local_tags"); + let now = new Date(); + + info("Insert A"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "A", + url: "http://example.com/a", + dateAdded: now, + }); + + // Each tag folder should have unique tag entries, but the tagging service + // doesn't enforce this. We should still sync the correct set of tags, + // though, even if there are duplicates for the same URL. + info("Manually insert local tags for A"); + for (let [tag, dupes] of [ + ["one", 2], + ["two", 1], + ["three", 2], + ]) { + let tagFolderInfo = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: tag, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + for (let i = 0; i < dupes; ++i) { + await PlacesUtils.bookmarks.insert({ + parentGuid: tagFolderInfo.guid, + url: "http://example.com/a", + }); + } + } + + let tagsForA = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI("http://example.com/a") + ); + deepEqual( + tagsForA, + ["one", "one", "three", "three", "two"], + "Tagging service should return duplicate tags" + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + changesToUpload.bookmarkAAAA.cleartext, + { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/a", + title: "A", + tags: ["one", "three", "two"], + }, + "Should upload A with tags" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); diff --git a/toolkit/components/places/tests/sync/test_sync_utils.js b/toolkit/components/places/tests/sync/test_sync_utils.js new file mode 100644 index 0000000000..8396ac2f0d --- /dev/null +++ b/toolkit/components/places/tests/sync/test_sync_utils.js @@ -0,0 +1,3130 @@ +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +var makeGuid = PlacesUtils.history.makeGuid; + +function shuffle(array) { + let results = []; + for (let i = 0; i < array.length; ++i) { + let randomIndex = Math.floor(Math.random() * (i + 1)); + results[i] = results[randomIndex]; + results[randomIndex] = array[i]; + } + return results; +} + +async function assertTagForURLs(tag, urls, message) { + let taggedURLs = new Set(); + await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => + taggedURLs.add(b.url.href) + ); + deepEqual( + Array.from(taggedURLs).sort(compareAscending), + urls.sort(compareAscending), + message + ); +} + +function assertURLHasTags(url, tags, message) { + let actualTags = PlacesUtils.tagging.getTagsForURI(uri(url)); + deepEqual(actualTags.sort(compareAscending), tags, message); +} + +var populateTree = async function populate(parentGuid, ...items) { + let guids = {}; + + for (let index = 0; index < items.length; index++) { + let item = items[index]; + let guid = makeGuid(); + + switch (item.kind) { + case "bookmark": + case "query": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: item.url, + title: item.title, + parentGuid, + guid, + index, + }); + break; + + case "separator": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid, + guid, + }); + break; + + case "folder": + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.title, + parentGuid, + guid, + }); + if (item.children) { + Object.assign(guids, await populate(guid, ...item.children)); + } + break; + + default: + throw new Error(`Unsupported item type: ${item.type}`); + } + + guids[item.title] = guid; + } + + return guids; +}; + +var moveSyncedBookmarksToUnsyncedParent = async function () { + info("Insert synced bookmarks"); + let syncedGuids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "folder", + title: "folder", + children: [ + { + kind: "bookmark", + title: "childBmk", + url: "https://example.org", + }, + ], + }, + { + kind: "bookmark", + title: "topBmk", + url: "https://example.com", + } + ); + // Pretend we've synced each bookmark at least once. + await PlacesTestUtils.setBookmarkSyncFields( + ...Object.values(syncedGuids).map(guid => ({ + guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + })) + ); + + info("Make new folder"); + let unsyncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "unsyncedFolder", + }); + + info("Move synced bookmarks into unsynced new folder"); + for (let guid of Object.values(syncedGuids)) { + await PlacesUtils.bookmarks.update({ + guid, + parentGuid: unsyncedFolder.guid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + } + + return { syncedGuids, unsyncedFolder }; +}; + +var setChangesSynced = async function (changes) { + for (let recordId in changes) { + changes[recordId].synced = true; + } + await PlacesSyncUtils.bookmarks.pushChanges(changes); +}; + +var ignoreChangedRoots = async function () { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + let expectedRoots = ["menu", "mobile", "toolbar", "unfiled"]; + if (!ObjectUtils.deepEqual(Object.keys(changes).sort(), expectedRoots)) { + // Make sure the previous test cleaned up. + throw new Error( + `Unexpected changes at start of test: ${JSON.stringify(changes)}` + ); + } + await setChangesSynced(changes); +}; + +add_task(async function test_fetchURLFrecency() { + // Add visits to the following URLs and then check if frecency for those URLs is not -1. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com", + "http://getthunderbird.com", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + for (let url of arrayOfURLsToVisit) { + let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url); + equal(typeof frecency, "number", "The frecency should be of type: number"); + notEqual( + frecency, + -1, + "The frecency of this url should be different than -1" + ); + } + // Do not add visits to the following URLs, and then check if frecency for those URLs is -1. + let arrayOfURLsNotVisited = ["https://bugzilla.org", "https://example.org"]; + for (let url of arrayOfURLsNotVisited) { + let frecency = await PlacesSyncUtils.history.fetchURLFrecency(url); + equal(frecency, -1, "The frecency of this url should be -1"); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_determineNonSyncableGuids() { + // Add visits to the following URLs with different transition types. + let arrayOfVisits = [ + { uri: "https://www.mozilla.org/en-US/", transition: TRANSITION_TYPED }, + { uri: "http://getfirefox.com/", transition: TRANSITION_LINK }, + { uri: "http://getthunderbird.com/", transition: TRANSITION_FRAMED_LINK }, + { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD }, + ]; + for (let visit of arrayOfVisits) { + await PlacesTestUtils.addVisits(visit); + } + + // Fetch the guid for each visit. + let guids = []; + let dictURLGuid = {}; + for (let visit of arrayOfVisits) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri); + guids.push(guid); + dictURLGuid[visit.uri] = guid; + } + + // Filter the visits. + let filteredGuids = await PlacesSyncUtils.history.determineNonSyncableGuids( + guids + ); + + let filtered = [TRANSITION_FRAMED_LINK, TRANSITION_DOWNLOAD]; + // Check if the filtered visits are of type TRANSITION_FRAMED_LINK. + for (let visit of arrayOfVisits) { + if (filtered.includes(visit.transition)) { + ok( + filteredGuids.includes(dictURLGuid[visit.uri]), + "This url should be one of the filtered guids." + ); + } else { + ok( + !filteredGuids.includes(dictURLGuid[visit.uri]), + "This url should not be one of the filtered guids." + ); + } + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_changeGuid() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + for (let url of arrayOfURLsToVisit) { + let originalGuid = await PlacesSyncUtils.history.fetchGuidForURL(url); + let newGuid = makeGuid(); + + // Change the original GUID for the new GUID. + await PlacesSyncUtils.history.changeGuid(url, newGuid); + + // Fetch the GUID for this URL. + let newGuidFetched = await PlacesSyncUtils.history.fetchGuidForURL(url); + + // Check that the URL has the new GUID as its GUID and not the original one. + equal( + newGuid, + newGuidFetched, + "These should be equal since we changed the guid for the visit." + ); + notEqual( + originalGuid, + newGuidFetched, + "These should be different since we changed the guid for the visit." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchVisitsForURL() { + // Get the date for this moment and a date for a minute ago. + let now = new Date(); + let aMinuteAgo = new Date(now.getTime() - 1 * 60000); + + // Add some visits of the following URLs, specifying the transition and the visit date. + let arrayOfVisits = [ + { + uri: "https://www.mozilla.org/en-US/", + transition: TRANSITION_TYPED, + visitDate: aMinuteAgo, + }, + { + uri: "http://getfirefox.com/", + transition: TRANSITION_LINK, + visitDate: aMinuteAgo, + }, + { + uri: "http://getthunderbird.com/", + transition: TRANSITION_LINK, + visitDate: aMinuteAgo, + }, + ]; + for (let elem of arrayOfVisits) { + await PlacesTestUtils.addVisits(elem); + } + + for (let elem of arrayOfVisits) { + // Fetch all the visits for this URL. + let visits = await PlacesSyncUtils.history.fetchVisitsForURL(elem.uri); + // Since the visit we added will be the last one in the collection of visits, we get the index of it. + let iLast = visits.length - 1; + + // The date is saved in _micro_seconds, here we change it to milliseconds. + let dateInMilliseconds = visits[iLast].date * 0.001; + + // Check that the info we provided for this URL is the same one retrieved. + equal( + dateInMilliseconds, + elem.visitDate.getTime(), + "The date we provided should be the same we retrieved." + ); + equal( + visits[iLast].type, + elem.transition, + "The transition type we provided should be the same we retrieved." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchGuidForURL() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // This tries to test fetchGuidForURL in two ways: + // 1- By fetching the GUID, and then using that GUID to retrieve the info of the visit. + // It then compares the URL with the URL that is on the visits info. + // 2- By creating a new GUID, changing the GUID for the visit, fetching the GUID and comparing them. + for (let url of arrayOfURLsToVisit) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(url); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + let newGuid = makeGuid(); + await PlacesSyncUtils.history.changeGuid(url, newGuid); + let newGuid2 = await PlacesSyncUtils.history.fetchGuidForURL(url); + + equal( + url, + info.url, + "The url provided and the url retrieved should be the same." + ); + equal( + newGuid, + newGuid2, + "The changed guid and the retrieved guid should be the same." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_fetchURLInfoForGuid() { + // Add some visits of the following URLs. specifying the title. + let visits = [ + { uri: "https://www.mozilla.org/en-US/", title: "mozilla" }, + { uri: "http://getfirefox.com/", title: "firefox" }, + { uri: "http://getthunderbird.com/", title: "thunderbird" }, + { uri: "http://quantum.mozilla.com/", title: null }, + ]; + for (let visit of visits) { + await PlacesTestUtils.addVisits(visit); + } + + for (let visit of visits) { + let guid = await PlacesSyncUtils.history.fetchGuidForURL(visit.uri); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + // Compare the info returned by fetchURLInfoForGuid, + // URL and title should match while frecency must be different than -1. + equal( + info.url, + visit.uri, + "The url provided should be the same as the url retrieved." + ); + equal( + info.title, + visit.title || "", + "The title provided should be the same as the title retrieved." + ); + notEqual( + info.frecency, + -1, + "The frecency of the visit should be different than -1." + ); + } + + // Create a "fake" GUID and check that the result of fetchURLInfoForGuid is null. + let guid = makeGuid(); + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(guid); + + equal( + info, + null, + "The information object of a non-existent guid should be null." + ); + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_getAllURLs() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + "http://getfirefox.com/", + "http://getthunderbird.com/", + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // Get all URLs. + let allURLs = await PlacesSyncUtils.history.getAllURLs({ + since: new Date(Date.now() - 2592000000), + limit: 5000, + }); + + // The amount of URLs must be the same in both collections. + equal( + allURLs.length, + arrayOfURLsToVisit.length, + "The amount of urls retrived should match the amount of urls provided." + ); + + // Check that the correct URLs were retrived. + for (let url of arrayOfURLsToVisit) { + ok( + allURLs.includes(url), + "The urls retrieved should match the ones used in this test." + ); + } + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_getAllURLs_skips_downloads() { + // Add some visits of the following URLs. + let arrayOfURLsToVisit = [ + "https://www.mozilla.org/en-US/", + { uri: "http://downloads.com/", transition: TRANSITION_DOWNLOAD }, + ]; + for (let url of arrayOfURLsToVisit) { + await PlacesTestUtils.addVisits(url); + } + + // Get all URLs. + let allURLs = await PlacesSyncUtils.history.getAllURLs({ + since: new Date(Date.now() - 2592000000), + limit: 5000, + }); + + // Should be only the non-download + equal(allURLs.length, 1, "Should only get one URL back."); + + // Check that the correct URLs were retrived. + equal(allURLs[0], arrayOfURLsToVisit[0], "Should get back our non-download."); + + // Remove the visits added during this test. + await PlacesUtils.history.clear(); +}); + +add_task(async function test_order() { + info("Insert some bookmarks"); + let guids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "bookmark", + title: "childBmk", + url: "http://getfirefox.com", + }, + { + kind: "bookmark", + title: "siblingBmk", + url: "http://getthunderbird.com", + }, + { + kind: "folder", + title: "siblingFolder", + }, + { + kind: "separator", + title: "siblingSep", + } + ); + + info("Reorder inserted bookmarks"); + { + let order = [ + guids.siblingFolder, + guids.siblingSep, + guids.childBmk, + guids.siblingBmk, + ]; + await PlacesSyncUtils.bookmarks.order( + PlacesUtils.bookmarks.menuGuid, + order + ); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + order, + "New bookmarks should be reordered according to array" + ); + } + + info("Same order with unspecified children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.siblingSep, + guids.siblingBmk, + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.siblingFolder, guids.siblingSep, guids.childBmk, guids.siblingBmk], + "Current order should be respected if possible" + ); + } + + info("New order with unspecified children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.siblingBmk, + guids.siblingSep, + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.siblingBmk, guids.siblingSep, guids.siblingFolder, guids.childBmk], + "Unordered children should be moved to end if current order can't be respected" + ); + } + + info("Reorder with nonexistent children"); + { + await PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [ + guids.childBmk, + makeGuid(), + guids.siblingBmk, + guids.siblingSep, + makeGuid(), + guids.siblingFolder, + makeGuid(), + ]); + let childRecordIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + childRecordIds, + [guids.childBmk, guids.siblingBmk, guids.siblingSep, guids.siblingFolder], + "Nonexistent children should be ignored" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_order_roots() { + let oldOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.rootGuid + ); + await PlacesSyncUtils.bookmarks.order( + PlacesUtils.bookmarks.rootGuid, + shuffle(oldOrder) + ); + let newOrder = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.rootGuid + ); + deepEqual(oldOrder, newOrder, "Should ignore attempts to reorder roots"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_tags() { + await ignoreChangedRoots(); + + info("Insert untagged items with same URL"); + let firstItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let secondItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let untaggedItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://bugzilla.org", + }); + let taggedItem = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://mozilla.org", + }); + + info("Create tag"); + PlacesUtils.tagging.tagURI(uri("https://example.org"), ["taggy"]); + + let tagBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.tagsGuid, + index: 0, + }); + let tagFolderGuid = tagBm.guid; + let tagFolderId = await PlacesTestUtils.promiseItemId(tagFolderGuid); + + info("Tagged bookmarks should be in changeset"); + { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks in changeset" + ); + await setChangesSynced(changes); + } + + info("Change tag case"); + { + PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["TaGgY"]); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, taggedItem.recordId].sort(), + "Should include tagged bookmarks after changing case" + ); + await assertTagForURLs( + "TaGgY", + ["https://example.org/", "https://mozilla.org/"], + "Should add tag for new URL" + ); + await setChangesSynced(changes); + } + + // These tests change a tag item directly, without going through the tagging + // service. This behavior isn't supported, but the tagging service registers + // an observer to handle these cases, so we make sure we handle them + // correctly. + + info("Rename tag folder using Bookmarks.setItemTitle"); + { + PlacesUtils.bookmarks.setItemTitle(tagFolderId, "sneaky"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["sneaky"], + "Tagging service should update cache with new title" + ); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks after renaming tag folder" + ); + await setChangesSynced(changes); + } + + info("Rename tag folder using Bookmarks.update"); + { + await PlacesUtils.bookmarks.update({ + guid: tagFolderGuid, + title: "tricky", + }); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["tricky"], + "Tagging service should update cache after updating tag folder" + ); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId].sort(), + "Should include tagged bookmarks after updating tag folder" + ); + await setChangesSynced(changes); + } + + info("Change tag entry URL using Bookmarks.update"); + { + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: tagFolderGuid, + index: 0, + }); + bm.url = "https://bugzilla.org/"; + await PlacesUtils.bookmarks.update(bm); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(), + "Should include tagged bookmarks after changing tag entry URI" + ); + await assertTagForURLs( + "tricky", + ["https://bugzilla.org/", "https://mozilla.org/"], + "Should remove tag entry for old URI" + ); + await setChangesSynced(changes); + + bm.url = "https://example.org/"; + await PlacesUtils.bookmarks.update(bm); + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [firstItem.recordId, secondItem.recordId, untaggedItem.recordId].sort(), + "Should include tagged bookmarks after changing tag entry URL" + ); + await assertTagForURLs( + "tricky", + ["https://example.org/", "https://mozilla.org/"], + "Should remove tag entry for old URL" + ); + await setChangesSynced(changes); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_conflicting_keywords() { + await ignoreChangedRoots(); + + info("Insert bookmark with new keyword"); + let tbBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "unfiled", + url: "http://getthunderbird.com", + keyword: "tbird", + }); + { + let entryByKeyword = await PlacesUtils.keywords.fetch("tbird"); + equal( + entryByKeyword.url.href, + "http://getthunderbird.com/", + "Should return new keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://getthunderbird.com", + }); + equal(entryByURL.keyword, "tbird", "Should return new entry by keyword"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + changes, + {}, + "Should not bump change counter for new keyword entry" + ); + } + + info("Insert bookmark with same URL and different keyword"); + let dupeTbBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "http://getthunderbird.com", + keyword: "tb", + }); + { + let oldKeywordByURL = await PlacesUtils.keywords.fetch("tbird"); + ok( + !oldKeywordByURL, + "Should remove old entry when inserting bookmark with different keyword" + ); + let entryByKeyword = await PlacesUtils.keywords.fetch("tb"); + equal( + entryByKeyword.url.href, + "http://getthunderbird.com/", + "Should return different keyword entry by URL" + ); + let entryByURL = await PlacesUtils.keywords.fetch({ + url: "http://getthunderbird.com", + }); + equal(entryByURL.keyword, "tb", "Should return different entry by keyword"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [tbBmk.recordId, dupeTbBmk.recordId].sort(), + "Should bump change counter for bookmarks with different keyword" + ); + await setChangesSynced(changes); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert() { + info("Insert bookmark"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + recordId: makeGuid(), + parentRecordId: "menu", + url: "https://example.org", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Bookmark should have correct type" + ); + } + + info("Insert query"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "menu", + url: "place:terms=term&folder=TOOLBAR&queryType=1", + folder: "Saved search", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Queries should be stored as bookmarks" + ); + } + + info("Insert folder"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + recordId: makeGuid(), + parentRecordId: "menu", + title: "New folder", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Folder should have correct type" + ); + } + + info("Insert separator"); + { + let item = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "separator", + recordId: makeGuid(), + parentRecordId: "menu", + }); + let { type } = await PlacesUtils.bookmarks.fetch({ guid: item.recordId }); + equal( + type, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + "Separator should have correct type" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tags() { + await Promise.all( + [ + { + kind: "bookmark", + url: "https://example.com", + recordId: makeGuid(), + parentRecordId: "menu", + tags: ["foo", "bar"], + }, + { + kind: "bookmark", + url: "https://example.org", + recordId: makeGuid(), + parentRecordId: "toolbar", + tags: ["foo", "baz"], + }, + { + kind: "query", + url: "place:queryType=1&sort=12&maxResults=10", + recordId: makeGuid(), + parentRecordId: "toolbar", + folder: "bar", + tags: ["baz", "qux"], + title: "bar", + }, + ].map(info => PlacesSyncUtils.test.bookmarks.insert(info)) + ); + + await assertTagForURLs( + "foo", + ["https://example.com/", "https://example.org/"], + "2 URLs with new tag" + ); + await assertTagForURLs( + "bar", + ["https://example.com/"], + "1 URL with existing tag" + ); + await assertTagForURLs( + "baz", + ["https://example.org/", "place:queryType=1&sort=12&maxResults=10"], + "Should support tagging URLs and tag queries" + ); + await assertTagForURLs( + "qux", + ["place:queryType=1&sort=12&maxResults=10"], + "Should support tagging tag queries" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tags_whitespace() { + info("Untrimmed and blank tags"); + let taggedBlanks = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + url: "https://example.org", + recordId: makeGuid(), + parentRecordId: "menu", + tags: [" untrimmed ", " ", "taggy"], + }); + deepEqual( + taggedBlanks.tags, + ["untrimmed", "taggy"], + "Should not return empty tags" + ); + assertURLHasTags( + "https://example.org/", + ["taggy", "untrimmed"], + "Should set trimmed tags and ignore dupes" + ); + + info("Dupe tags"); + let taggedDupes = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + url: "https://example.net", + recordId: makeGuid(), + parentRecordId: "toolbar", + tags: [" taggy", "taggy ", " taggy ", "taggy"], + }); + deepEqual( + taggedDupes.tags, + ["taggy", "taggy", "taggy", "taggy"], + "Should return trimmed and dupe tags" + ); + assertURLHasTags( + "https://example.net/", + ["taggy"], + "Should ignore dupes when setting tags" + ); + + await assertTagForURLs( + "taggy", + ["https://example.net/", "https://example.org/"], + "Should exclude falsy tags" + ); + + PlacesUtils.tagging.untagURI(uri("https://example.org"), [ + "untrimmed", + "taggy", + ]); + PlacesUtils.tagging.untagURI(uri("https://example.net"), ["taggy"]); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + [], + "Should clean up all tags" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_keyword() { + info("Insert item with new keyword"); + { + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + url: "https://example.com", + keyword: "moz", + recordId: makeGuid(), + }); + let entry = await PlacesUtils.keywords.fetch("moz"); + equal( + entry.url.href, + "https://example.com/", + "Should add keyword for item" + ); + } + + info("Insert item with existing keyword"); + { + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + url: "https://mozilla.org", + keyword: "moz", + recordId: makeGuid(), + }); + let entry = await PlacesUtils.keywords.fetch("moz"); + equal( + entry.url.href, + "https://mozilla.org/", + "Should reassign keyword to new item" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_insert_tag_query() { + info("Use the public tagging API to ensure we added the tag correctly"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "https://mozilla.org", + title: "Mozilla", + }); + PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["taggy"]); + assertURLHasTags( + "https://mozilla.org/", + ["taggy"], + "Should set tags using the tagging API" + ); + + info("Insert tag query for non existing tag"); + { + let query = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "place:type=7&folder=90", + folder: "nonexisting", + title: "Tagged stuff", + }); + let params = new URLSearchParams(query.url.pathname); + ok(!params.has("type"), "Should not preserve query type"); + ok(!params.has("folder"), "Should not preserve folder"); + equal(params.get("tag"), "nonexisting", "Should add tag"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["taggy"], + "The nonexisting tag should not be added" + ); + } + + info("Insert tag query for existing tag"); + { + let url = "place:type=7&folder=90&maxResults=15"; + let query = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "menu", + url, + folder: "taggy", + title: "Sorted and tagged", + }); + let params = new URLSearchParams(query.url.pathname); + ok(!params.get("type"), "Should not preserve query type"); + ok(!params.has("folder"), "Should not preserve folder"); + equal(params.get("maxResults"), "15", "Should preserve additional params"); + equal(params.get("tag"), "taggy", "Should add tag"); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + ["taggy"], + "Should not duplicate existing tags" + ); + } + + info("Removing the tag should clean up the tag folder"); + PlacesUtils.tagging.untagURI(uri("https://mozilla.org"), null); + deepEqual( + (await PlacesUtils.bookmarks.fetchTags()).map(t => t.name), + [], + "Should remove tag folder once last item is untagged" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_fetch() { + let folder = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: "menu", + kind: "folder", + }); + let bmk = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: "menu", + kind: "bookmark", + url: "https://example.com", + tags: ["taggy"], + }); + let folderBmk = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: folder.recordId, + kind: "bookmark", + url: "https://example.org", + keyword: "kw", + }); + let folderSep = await PlacesSyncUtils.test.bookmarks.insert({ + recordId: makeGuid(), + parentRecordId: folder.recordId, + kind: "separator", + }); + let tagQuery = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "query", + recordId: makeGuid(), + parentRecordId: "toolbar", + url: "place:tag=taggy", + folder: "taggy", + title: "Tagged stuff", + }); + + info("Fetch empty folder"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folder.recordId); + deepEqual( + item, + { + recordId: folder.recordId, + kind: "folder", + parentRecordId: "menu", + childRecordIds: [folderBmk.recordId, folderSep.recordId], + parentTitle: "menu", + dateAdded: item.dateAdded, + title: "", + }, + "Should include children, title, and parent title in folder" + ); + } + + info("Fetch bookmark with tags"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(bmk.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "tags", + "parentTitle", + "title", + "dateAdded", + ].sort(), + "Should include bookmark-specific properties" + ); + equal(item.recordId, bmk.recordId, "Sync ID should match"); + equal(item.url.href, "https://example.com/", "Should return URL"); + equal(item.parentRecordId, "menu", "Should return parent sync ID"); + deepEqual(item.tags, ["taggy"], "Should return tags"); + equal(item.parentTitle, "menu", "Should return parent title"); + strictEqual(item.title, "", "Should return empty title"); + } + + info("Fetch bookmark with keyword; without parent title"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folderBmk.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "keyword", + "tags", + "parentTitle", + "title", + "dateAdded", + ].sort(), + "Should omit blank bookmark-specific properties" + ); + deepEqual(item.tags, [], "Tags should be empty"); + equal(item.keyword, "kw", "Should return keyword"); + strictEqual( + item.parentTitle, + "", + "Should include parent title even if empty" + ); + strictEqual(item.title, "", "Should include bookmark title even if empty"); + } + + info("Fetch separator"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(folderSep.recordId); + strictEqual(item.index, 1, "Should return separator position"); + } + + info("Fetch tag query"); + { + let item = await PlacesSyncUtils.bookmarks.fetch(tagQuery.recordId); + deepEqual( + Object.keys(item).sort(), + [ + "recordId", + "kind", + "parentRecordId", + "url", + "title", + "folder", + "parentTitle", + "dateAdded", + ].sort(), + "Should include query-specific properties" + ); + equal( + item.url.href, + `place:tag=taggy`, + "Should not rewrite outgoing tag queries" + ); + equal(item.folder, "taggy", "Should return tag name for tag queries"); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_new_parent() { + await ignoreChangedRoots(); + + let { syncedGuids, unsyncedFolder } = + await moveSyncedBookmarksToUnsyncedParent(); + + info("Unsynced parent and synced items should be tracked"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + syncedGuids.folder, + syncedGuids.topBmk, + syncedGuids.childBmk, + unsyncedFolder.guid, + "menu", + ].sort(), + "Should return change records for moved items and new parent" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_deleted_folder() { + await ignoreChangedRoots(); + + let { syncedGuids, unsyncedFolder } = + await moveSyncedBookmarksToUnsyncedParent(); + + info("Remove unsynced new folder"); + await PlacesUtils.bookmarks.remove(unsyncedFolder.guid); + + info("Deleted synced items should be tracked; unsynced folder should not"); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + syncedGuids.folder, + syncedGuids.topBmk, + syncedGuids.childBmk, + "menu", + ].sort(), + "Should return change records for all deleted items" + ); + for (let guid of Object.values(syncedGuids)) { + strictEqual( + changes[guid].tombstone, + true, + `Tombstone flag should be set for deleted item ${guid}` + ); + equal( + changes[guid].counter, + 1, + `Change counter should be 1 for deleted item ${guid}` + ); + equal( + changes[guid].status, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + `Sync status should be normal for deleted item ${guid}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_import_html() { + await ignoreChangedRoots(); + + info("Add unsynced bookmark"); + let unsyncedBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://example.com", + }); + + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Unsynced bookmark statuses should match" + ); + } + + info("Import new bookmarks from HTML"); + let { path } = do_get_file("./sync_utils_bookmarks.html"); + await BookmarkHTMLUtils.importFromFile(path); + + // Bookmarks.html doesn't store IDs, so we need to look these up. + let mozBmk = await PlacesUtils.bookmarks.fetch({ + url: "https://www.mozilla.org/", + }); + let fxBmk = await PlacesUtils.bookmarks.fetch({ + url: "https://www.mozilla.org/en-US/firefox/", + }); + // All Bookmarks.html bookmarks are stored under the menu. For toolbar + // bookmarks, this means they're imported into a "Bookmarks Toolbar" + // subfolder under the menu, instead of the real toolbar root. + let toolbarSubfolder = ( + await PlacesUtils.bookmarks.search({ + title: "Bookmarks Toolbar", + }) + ).find(item => item.guid != PlacesUtils.bookmarks.toolbarGuid); + let importedFields = await PlacesTestUtils.fetchBookmarkSyncFields( + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + importedFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Sync statuses should match for HTML imports" + ); + + info("Fetch new HTML imports"); + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(newChanges).sort(), + [ + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid, + "menu", + unsyncedBmk.guid, + ].sort(), + "Should return new IDs imported from HTML file" + ); + let newFields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + newFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Pulling new HTML imports should not mark them as syncing" + ); + + info("Mark new HTML imports as syncing"); + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + mozBmk.guid, + fxBmk.guid, + toolbarSubfolder.guid + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Marking new HTML imports as syncing should update their statuses" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_import_json() { + await ignoreChangedRoots(); + + info("Add synced folder"); + let syncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "syncedFolder", + }); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: syncedFolder.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }); + + info("Import new bookmarks from JSON"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + deepEqual( + fields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + ], + "Sync statuses should match for JSON imports" + ); + } + + info("Fetch new JSON imports"); + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(newChanges).sort(), + [ + "NnvGl3CRA4hC", + "APzP8MupzA8l", + "menu", + "toolbar", + syncedFolder.guid, + ].sort(), + "Should return items imported from JSON backup" + ); + let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + deepEqual( + existingFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + ], + "Pulling new JSON imports should not mark them as syncing" + ); + + info("Mark new JSON imports as syncing"); + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(newChanges); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + syncedFolder.guid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Marking new JSON imports as syncing should update their statuses" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_restore_json_tracked() { + await ignoreChangedRoots(); + + let unsyncedBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://example.com", + }); + info(`Unsynced bookmark GUID: ${unsyncedBmk.guid}`); + let syncedFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "syncedFolder", + }); + info(`Synced folder GUID: ${syncedFolder.guid}`); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: syncedFolder.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unsyncedBmk.guid, + syncedFolder.guid + ); + deepEqual( + fields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + ], + "Sync statuses should match before restoring from JSON" + ); + } + + info("Restore from JSON, replacing existing items"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { replace: true }); + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "All bookmarks should be NEW after restoring from JSON" + ); + } + + info("Fetch new items restored from JSON"); + { + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [ + "menu", + "toolbar", + "unfiled", + "mobile", + "NnvGl3CRA4hC", + "APzP8MupzA8l", + ].sort(), + "Should restore items from JSON backup" + ); + + let existingFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + existingFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Items restored from JSON backup should not be marked as syncing" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Tombstones should not exist after restoring from JSON backup" + ); + + await PlacesSyncUtils.bookmarks.markChangesAsSyncing(changes); + let normalFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "NnvGl3CRA4hC", + "APzP8MupzA8l" + ); + ok( + normalFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Roots and NEW items restored from JSON backup should be marked as NORMAL" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pullChanges_tombstones() { + await ignoreChangedRoots(); + + info("Insert new bookmarks"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + info("Manually insert conflicting tombstone for new bookmark"); + await PlacesUtils.withConnectionWrapper( + "test_pullChanges_tombstones", + async function (db) { + await db.executeCached( + ` + INSERT INTO moz_bookmarks_deleted(guid) + VALUES(:guid)`, + { guid: "bookmarkAAAA" } + ); + } + ); + + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + ["bookmarkAAAA", "bookmarkBBBB", "menu"], + "Should handle undeleted items when returning changes" + ); + strictEqual( + changes.bookmarkAAAA.tombstone, + false, + "Should replace tombstone for A with undeleted item" + ); + strictEqual( + changes.bookmarkBBBB.tombstone, + false, + "Should not report B as deleted" + ); + + await setChangesSynced(changes); + + let newChanges = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + newChanges, + {}, + "Should not return changes after marking undeleted items as synced" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_pushChanges() { + await ignoreChangedRoots(); + + info("Populate test bookmarks"); + let guids = await populateTree( + PlacesUtils.bookmarks.menuGuid, + { + kind: "bookmark", + title: "unknownBmk", + url: "https://example.org", + }, + { + kind: "bookmark", + title: "syncedBmk", + url: "https://example.com", + }, + { + kind: "bookmark", + title: "newBmk", + url: "https://example.info", + }, + { + kind: "bookmark", + title: "deletedBmk", + url: "https://example.edu", + }, + { + kind: "bookmark", + title: "unchangedBmk", + url: "https://example.systems", + } + ); + + info("Update sync statuses"); + await PlacesTestUtils.setBookmarkSyncFields( + { + guid: guids.syncedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }, + { + guid: guids.unknownBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + }, + { + guid: guids.deletedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }, + { + guid: guids.unchangedBmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + syncChangeCounter: 0, + } + ); + + info("Change synced bookmark; should bump change counter"); + await PlacesUtils.bookmarks.update({ + guid: guids.syncedBmk, + url: "https://example.ninja", + }); + + info("Remove synced bookmark"); + { + await PlacesUtils.bookmarks.remove(guids.deletedBmk); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + ok( + tombstones.some(({ guid }) => guid == guids.deletedBmk), + "Should write tombstone for deleted synced bookmark" + ); + } + + info("Pull changes"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + { + let actualChanges = Object.entries(changes).map(([recordId, change]) => ({ + recordId, + syncChangeCounter: change.counter, + })); + let expectedChanges = [ + { + recordId: guids.unknownBmk, + syncChangeCounter: 1, + }, + { + // Parent of changed bookmarks. + recordId: "menu", + syncChangeCounter: 6, + }, + { + recordId: guids.syncedBmk, + syncChangeCounter: 2, + }, + { + recordId: guids.newBmk, + syncChangeCounter: 1, + }, + { + recordId: guids.deletedBmk, + syncChangeCounter: 1, + }, + ]; + deepEqual( + sortBy(actualChanges, "recordId"), + sortBy(expectedChanges, "recordId"), + "Should return deleted, new, and unknown bookmarks" + ); + } + + info("Modify changed bookmark to bump its counter"); + await PlacesUtils.bookmarks.update({ + guid: guids.newBmk, + url: "https://example.club", + }); + + info("Mark some bookmarks as synced"); + for (let title of ["unknownBmk", "newBmk", "deletedBmk"]) { + let guid = guids[title]; + strictEqual( + changes[guid].synced, + false, + "All bookmarks should not be marked as synced yet" + ); + changes[guid].synced = true; + } + + await PlacesSyncUtils.bookmarks.pushChanges(changes); + equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 4); + + { + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + guids.newBmk, + guids.unknownBmk + ); + ok( + fields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Should update sync statuses for synced bookmarks" + ); + } + + { + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + ok( + !tombstones.some(({ guid }) => guid == guids.deletedBmk), + "Should remove tombstone after syncing" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + guids.unknownBmk, + guids.syncedBmk, + guids.newBmk + ); + { + let info = syncFields.find(field => field.guid == guids.unknownBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing an UNKNOWN bookmark should set its sync status to NORMAL" + ); + strictEqual( + info.syncChangeCounter, + 0, + "Syncing an UNKNOWN bookmark should reduce its change counter" + ); + } + { + let info = syncFields.find(field => field.guid == guids.syncedBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing a NORMAL bookmark should not update its sync status" + ); + equal( + info.syncChangeCounter, + 2, + "Should not reduce counter for NORMAL bookmark not marked as synced" + ); + } + { + let info = syncFields.find(field => field.guid == guids.newBmk); + equal( + info.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + "Syncing a NEW bookmark should update its sync status" + ); + strictEqual( + info.syncChangeCounter, + 1, + "Updating new bookmark after pulling changes should bump change counter" + ); + } + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_changes_between_pull_and_push() { + await ignoreChangedRoots(); + + info("Populate test bookmarks"); + let guids = await populateTree(PlacesUtils.bookmarks.menuGuid, { + kind: "bookmark", + title: "bmk", + url: "https://example.info", + }); + + info("Update sync statuses"); + await PlacesTestUtils.setBookmarkSyncFields({ + guid: guids.bmk, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + syncChangeCounter: 1, + }); + + info("Pull changes"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + Assert.equal(changes[guids.bmk].counter, 1); + Assert.equal(changes[guids.bmk].tombstone, false); + + // delete the bookmark. + await PlacesUtils.bookmarks.remove(guids.bmk); + + info("Push changes"); + await PlacesSyncUtils.bookmarks.pushChanges(changes); + equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + + // we should have a tombstone. + let ts = await PlacesTestUtils.fetchSyncTombstones(); + Assert.equal(ts.length, 1); + Assert.equal(ts[0].guid, guids.bmk); + + // there should be no record for the item we deleted. + Assert.strictEqual(await PlacesUtils.bookmarks.fetch(guids.bmk), null); + + // and re-fetching changes should list it as a tombstone. + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + Assert.equal(changes[guids.bmk].counter, 1); + Assert.equal(changes[guids.bmk].tombstone, true); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_separator() { + await ignoreChangedRoots(); + + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://example.com", + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://foo.bar", + }); + let separatorRecordId = makeGuid(); + let separator = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "separator", + parentRecordId: "menu", + recordId: separatorRecordId, + }); + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://bar.foo", + }); + + let child2Guid = await PlacesSyncUtils.bookmarks.recordIdToGuid( + childBmk.recordId + ); + let parentGuid = await await PlacesSyncUtils.bookmarks.recordIdToGuid("menu"); + let separatorGuid = + PlacesSyncUtils.bookmarks.recordIdToGuid(separatorRecordId); + + info("Move a bookmark around the separator"); + await PlacesUtils.bookmarks.update({ + guid: child2Guid, + parentGuid, + index: 2, + }); + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await setChangesSynced(changes); + + info("Move a separator around directly"); + await PlacesUtils.bookmarks.update({ + guid: separatorGuid, + parentGuid, + index: 0, + }); + + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await setChangesSynced(changes); + + info("Move a separator around directly using update"); + await PlacesUtils.bookmarks.update({ guid: separatorGuid, index: 2 }); + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual(Object.keys(changes).sort(), [separator.recordId, "menu"].sort()); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove() { + await ignoreChangedRoots(); + + info("Insert subtree for removal"); + let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: "menu", + recordId: makeGuid(), + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.com", + }); + let childFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + }); + let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: childFolder.recordId, + recordId: makeGuid(), + url: "https://example.edu", + }); + + info("Remove entire subtree"); + await PlacesSyncUtils.bookmarks.remove([ + parentFolder.recordId, + childFolder.recordId, + childBmk.recordId, + grandChildBmk.recordId, + ]); + + /** + * Even though we've removed the entire subtree, we still track the menu + * because we 1) removed `parentFolder`, 2) reparented `childFolder` to + * `menu`, and 3) removed `childFolder`. + * + * This depends on the order of the folders passed to `remove`. If we + * removed `childFolder` *before* `parentFolder`, we wouldn't reparent + * anything to `menu`. + * + * `deleteSyncedFolder` could check if it's reparenting an item that will + * eventually be removed, and avoid bumping the new parent's change counter. + * Unfortunately, that introduces inconsistencies if `deleteSyncedFolder` is + * interrupted by shutdown. If the server changes before the next sync, + * we'll never upload records for the reparented item or the new parent. + * + * Another alternative: we can try to remove folders in level order, instead + * of the order passed to `remove`. But that means we need a recursive query + * to determine the order. This is already enough of an edge case that + * occasionally reuploading the closest living ancestor is the simplest + * solution. + */ + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes), + ["menu"], + "Should track closest living ancestor of removed subtree" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove_partial() { + await ignoreChangedRoots(); + + info("Insert subtree for partial removal"); + let parentFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: PlacesUtils.bookmarks.menuGuid, + recordId: makeGuid(), + }); + let prevSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.net", + }); + let childBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.com", + }); + let nextSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.org", + }); + let childFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + }); + let grandChildBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://example.edu", + }); + let grandChildSiblingBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: parentFolder.recordId, + recordId: makeGuid(), + url: "https://mozilla.org", + }); + let grandChildFolder = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "folder", + parentRecordId: childFolder.recordId, + recordId: makeGuid(), + }); + let greatGrandChildPrevSiblingBmk = + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: grandChildFolder.recordId, + recordId: makeGuid(), + url: "http://getfirefox.com", + }); + let greatGrandChildNextSiblingBmk = + await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: grandChildFolder.recordId, + recordId: makeGuid(), + url: "http://getthunderbird.com", + }); + let menuBmk = await PlacesSyncUtils.test.bookmarks.insert({ + kind: "bookmark", + parentRecordId: "menu", + recordId: makeGuid(), + url: "https://example.info", + }); + + info("Remove subset of folders and items in subtree"); + let changes = await PlacesSyncUtils.bookmarks.remove([ + parentFolder.recordId, + childBmk.recordId, + grandChildFolder.recordId, + grandChildBmk.recordId, + childFolder.recordId, + ]); + deepEqual( + Object.keys(changes).sort(), + [ + // Closest living ancestor. + "menu", + // Reparented bookmarks. + prevSiblingBmk.recordId, + nextSiblingBmk.recordId, + grandChildSiblingBmk.recordId, + greatGrandChildPrevSiblingBmk.recordId, + greatGrandChildNextSiblingBmk.recordId, + ].sort(), + "Should track reparented bookmarks and their closest living ancestor" + ); + + /** + * Reparented bookmarks should maintain their order relative to their + * siblings: `prevSiblingBmk` (0) should precede `nextSiblingBmk` (2) in the + * menu, and `greatGrandChildPrevSiblingBmk` (0) should precede + * `greatGrandChildNextSiblingBmk` (1). + */ + let menuChildren = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + PlacesUtils.bookmarks.menuGuid + ); + deepEqual( + menuChildren, + [ + // Existing bookmark. + menuBmk.recordId, + // 1) Moved out of `parentFolder` to `menu`. + prevSiblingBmk.recordId, + nextSiblingBmk.recordId, + // 3) Moved out of `childFolder` to `menu`. After this step, `childFolder` + // is deleted. + grandChildSiblingBmk.recordId, + // 2) Moved out of `grandChildFolder` to `childFolder`, because we remove + // `grandChildFolder` *before* `childFolder`. After this step, + // `grandChildFolder` is deleted and `childFolder`'s children are + // `[grandChildSiblingBmk, greatGrandChildPrevSiblingBmk, + // greatGrandChildNextSiblingBmk]`. + greatGrandChildPrevSiblingBmk.recordId, + greatGrandChildNextSiblingBmk.recordId, + ], + "Should move descendants to closest living ancestor" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_migrateOldTrackerEntries() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let unknownBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let newBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + let normalBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + + await PlacesTestUtils.setBookmarkSyncFields( + { + guid: unknownBmk.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + syncChangeCounter: 0, + }, + { + guid: normalBmk.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + } + ); + PlacesUtils.tagging.tagURI(uri("http://getfirefox.com"), ["taggy"]); + + let tombstoneRecordId = makeGuid(); + await PlacesSyncUtils.bookmarks.migrateOldTrackerEntries([ + { + recordId: normalBmk.guid, + modified: Date.now(), + }, + { + recordId: tombstoneRecordId, + modified: 1479162463976, + }, + ]); + + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [normalBmk.guid, tombstoneRecordId].sort(), + "Should return change records for migrated bookmark and tombstone" + ); + + let fields = await PlacesTestUtils.fetchBookmarkSyncFields( + unknownBmk.guid, + newBmk.guid, + normalBmk.guid + ); + for (let field of fields) { + if (field.guid == normalBmk.guid) { + Assert.greater( + field.lastModified, + normalBmk.lastModified, + `Should bump last modified date for migrated bookmark ${field.guid}` + ); + equal( + field.syncChangeCounter, + 1, + `Should bump change counter for migrated bookmark ${field.guid}` + ); + } else { + strictEqual( + field.syncChangeCounter, + 0, + `Should not bump change counter for ${field.guid}` + ); + } + equal( + field.syncStatus, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + `Should set sync status for ${field.guid} to NORMAL` + ); + } + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [ + { + guid: tombstoneRecordId, + dateRemoved: new Date(1479162463976), + }, + ], + "Should write tombstone for nonexistent migrated item" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_ensureMobileQuery() { + info("Ensure we correctly set the showMobileBookmarks preference"); + const mobilePref = "browser.bookmarks.showMobileBookmarks"; + Services.prefs.clearUserPref(mobilePref); + + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://example.com/a", + title: "A", + }); + + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://example.com/b", + title: "B", + }); + + await PlacesSyncUtils.bookmarks.ensureMobileQuery(); + + Assert.ok( + Services.prefs.getBoolPref(mobilePref), + "Pref should be true where there are bookmarks in the folder." + ); + + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + + await PlacesSyncUtils.bookmarks.ensureMobileQuery(); + + Assert.ok( + !Services.prefs.getBoolPref(mobilePref), + "Pref should be false where there are no bookmarks in the folder." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_remove_stale_tombstones() { + info("Insert and delete synced bookmark"); + { + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://example.com/a", + title: "A", + source: PlacesUtils.bookmarks.SOURCES.SYNC, + }); + await PlacesUtils.bookmarks.remove("bookmarkAAAA"); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkAAAA"], + "Should store tombstone for deleted synced bookmark" + ); + } + + info("Reinsert deleted bookmark"); + { + // Different parent, URL, and title, but same GUID. + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkAAAA", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/a-restored", + title: "A (Restored)", + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should remove tombstone for reinserted bookmark" + ); + } + + info("Insert tree and erase everything"); + { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + await PlacesUtils.bookmarks.eraseEverything(); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid).sort(), + ["bookmarkBBBB", "bookmarkCCCC"], + "Should store tombstones after erasing everything" + ); + } + + info("Reinsert tree"); + { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.mobileGuid, + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid).sort(), + [], + "Should remove tombstones after reinserting tree" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_resetSyncId() { + let syncId = await PlacesSyncUtils.bookmarks.getSyncId(); + strictEqual(syncId, "", "Should start with empty bookmarks sync ID"); + + // Add a tree with a NORMAL bookmark (A), tombstone (B), NEW bookmark (C), + // and UNKNOWN bookmark (D). + info("Set up local tree before resetting bookmarks sync ID"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Assign new bookmarks sync ID for first time"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + syncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + newSyncId, + syncId, + "Should assign new bookmarks sync ID for first time" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should change all sync statuses to NEW after resetting bookmarks sync ID" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should remove all tombstones after resetting bookmarks sync ID" + ); + + info("Set bookmarks last sync time"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + equal( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should record bookmarks last sync time" + ); + + newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + notEqual( + newSyncId, + syncId, + "Should set new bookmarks sync ID if one already exists" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync time after resetting sync ID" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_wipe() { + info("Add Sync metadata before wipe"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + existingSyncId, + newSyncId, + "Ensure bookmarks sync ID was recorded before wipe" + ); + + info("Set up local tree before wipe"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Wipe bookmarks"); + await PlacesSyncUtils.bookmarks.wipe(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after wipe" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after wipe" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Wiping bookmarks locally should not wipe server" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should drop tombstones after wipe"); + + deepEqual( + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("menu"), + [], + "Should wipe menu children" + ); + deepEqual( + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("toolbar"), + [], + "Should wipe toolbar children" + ); + + let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid + ); + ok( + rootSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset all sync statuses to NEW after wipe" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_eraseEverything() { + info("Add Sync metadata before erase"); + let newSyncId = await PlacesSyncUtils.bookmarks.resetSyncId(); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + + info("Set up local tree before reset"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Erase all bookmarks"); + await PlacesUtils.bookmarks.eraseEverything(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + newSyncId, + "Should not reset bookmarks sync ID after erase" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should not reset bookmarks last sync after erase" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Erasing everything should not wipe server" + ); + + deepEqual( + (await PlacesTestUtils.fetchSyncTombstones()).map(info => info.guid), + ["bookmarkAAAA", "bookmarkBBBB"], + "Should keep tombstones after erasing everything" + ); + + let rootSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid + ); + ok( + rootSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NORMAL + ), + "Should not reset sync statuses after erasing everything" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_reset() { + info("Add Sync metadata before reset"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + info("Set up local tree before reset"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + info("Reset Sync metadata for bookmarks"); + await PlacesSyncUtils.bookmarks.reset(); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after reset" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after reset" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Resetting Sync metadata should not wipe server" + ); + + deepEqual( + await PlacesTestUtils.fetchSyncTombstones(), + [], + "Should drop tombstones after reset" + ); + + let itemSyncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + "bookmarkAAAA", + "bookmarkCCCC" + ); + ok( + itemSyncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset sync statuses for existing items to NEW after reset" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_restore() { + info("Add Sync metadata before manual restore"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server before manual restore" + ); + + info("Manually restore"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { replace: true }); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after manual restore" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after manual restore" + ); + ok( + await PlacesSyncUtils.bookmarks.shouldWipeRemote(), + "Should wipe server after manual restore" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + "NnvGl3CRA4hC", + PlacesUtils.bookmarks.toolbarGuid, + "APzP8MupzA8l" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.NEW + ), + "Should reset all sync stauses to NEW after manual restore" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_meta_restore_on_startup() { + info("Add Sync metadata before simulated automatic restore"); + await PlacesSyncUtils.bookmarks.resetSyncId(); + await PlacesSyncUtils.bookmarks.setLastSync(Date.now() / 1000); + + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server before automatic restore" + ); + + info("Simulate automatic restore on startup"); + let { path } = do_get_file("./sync_utils_bookmarks.json"); + await BookmarkJSONUtils.importFromFile(path, { + replace: true, + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + strictEqual( + await PlacesSyncUtils.bookmarks.getSyncId(), + "", + "Should reset bookmarks sync ID after automatic restore" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync after automatic restore" + ); + ok( + !(await PlacesSyncUtils.bookmarks.shouldWipeRemote()), + "Should not wipe server after manual restore" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + "NnvGl3CRA4hC", + PlacesUtils.bookmarks.toolbarGuid, + "APzP8MupzA8l" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN + ), + "Should reset all sync stauses to UNKNOWN after automatic restore" + ); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_bookmarks_ensureCurrentSyncId() { + info("Set up local tree"); + await ignoreChangedRoots(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }); + await PlacesUtils.bookmarks.remove("bookmarkBBBB"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + }); + + let existingSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + strictEqual(existingSyncId, "", "Should start without bookmarks sync ID"); + + info("Assign new bookmarks sync ID"); + { + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA"); + + let newSyncId = await PlacesSyncUtils.bookmarks.getSyncId(); + equal( + newSyncId, + "syncIdAAAAAA", + "Should assign bookmarks sync ID if one doesn't exist" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkBBBB"], + "Should keep tombstones after assigning new bookmarks sync ID" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + deepEqual( + syncFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + ], + "Should not reset sync statuses after assigning new bookmarks sync ID" + ); + } + + info("Ensure existing bookmarks sync ID matches"); + { + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdAAAAAA"); + + equal( + await PlacesSyncUtils.bookmarks.getSyncId(), + "syncIdAAAAAA", + "Should keep existing bookmarks sync ID on match" + ); + equal( + await PlacesSyncUtils.bookmarks.getLastSync(), + lastSync, + "Should keep existing bookmarks last sync time on sync ID match" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkBBBB"], + "Should keep tombstones if bookmarks sync IDs match" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + deepEqual( + syncFields.map(field => field.syncStatus), + [ + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + PlacesUtils.bookmarks.SYNC_STATUS.NEW, + PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN, + ], + "Should not reset sync statuses if bookmarks sync IDs match" + ); + } + + info("Replace existing bookmarks sync ID with new ID"); + { + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId("syncIdBBBBBB"); + + equal( + await PlacesSyncUtils.bookmarks.getSyncId(), + "syncIdBBBBBB", + "Should replace existing bookmarks sync ID on mismatch" + ); + strictEqual( + await PlacesSyncUtils.bookmarks.getLastSync(), + 0, + "Should reset bookmarks last sync time on sync ID mismatch" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones, + [], + "Should drop tombstones after bookmarks sync ID mismatch" + ); + + let syncFields = await PlacesTestUtils.fetchBookmarkSyncFields( + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkAAAA", + "bookmarkCCCC", + "bookmarkDDDD" + ); + ok( + syncFields.every( + field => field.syncStatus == PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN + ), + "Should reset all sync statuses to UNKNOWN after bookmarks sync ID mismatch" + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_history_resetSyncId() { + let syncId = await PlacesSyncUtils.history.getSyncId(); + strictEqual(syncId, "", "Should start with empty history sync ID"); + + info("Assign new history sync ID for first time"); + let newSyncId = await PlacesSyncUtils.history.resetSyncId(); + syncId = await PlacesSyncUtils.history.getSyncId(); + equal(newSyncId, syncId, "Should assign new history sync ID for first time"); + + info("Set history last sync time"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.history.setLastSync(lastSync); + equal( + await PlacesSyncUtils.history.getLastSync(), + lastSync, + "Should record history last sync time" + ); + + newSyncId = await PlacesSyncUtils.history.resetSyncId(); + notEqual( + newSyncId, + syncId, + "Should set new history sync ID if one already exists" + ); + strictEqual( + await PlacesSyncUtils.history.getLastSync(), + 0, + "Should reset history last sync time after resetting sync ID" + ); + + await PlacesSyncUtils.history.reset(); +}); + +add_task(async function test_history_ensureCurrentSyncId() { + info("Assign new history sync ID"); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA"); + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdAAAAAA", + "Should assign history sync ID if one doesn't exist" + ); + + info("Ensure existing history sync ID matches"); + let lastSync = Date.now() / 1000; + await PlacesSyncUtils.history.setLastSync(lastSync); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdAAAAAA"); + + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdAAAAAA", + "Should keep existing history sync ID on match" + ); + equal( + await PlacesSyncUtils.history.getLastSync(), + lastSync, + "Should keep existing history last sync time on sync ID match" + ); + + info("Replace existing history sync ID with new ID"); + await PlacesSyncUtils.history.ensureCurrentSyncId("syncIdBBBBBB"); + + equal( + await PlacesSyncUtils.history.getSyncId(), + "syncIdBBBBBB", + "Should replace existing history sync ID on mismatch" + ); + strictEqual( + await PlacesSyncUtils.history.getLastSync(), + 0, + "Should reset history last sync time on sync ID mismatch" + ); + + await PlacesSyncUtils.history.reset(); +}); + +add_task(async function test_updateUnknownFieldsBatch() { + // We're just validating we have something where placeId = 1, mainly as a sanity + // since moz_places_extra needs a valid foreign key + let placeId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + id: 1, + }); + + // an example of json with multiple fields in it to test updateUnknownFields + // will update ONLY unknown_sync_fields and not override any others + const test_json = JSON.stringify({ + unknown_sync_fields: { unknownStrField: "an old str field " }, + extra_str_field: "another field within the json", + extra_obj_field: { inner: "hi" }, + }); + + // Manually put the inital json in the DB + await PlacesUtils.withConnectionWrapper( + "test_update_moz_places_extra", + async function (db) { + await db.executeCached( + ` + INSERT INTO moz_places_extra(place_id, sync_json) + VALUES(:placeId, :sync_json)`, + { placeId, sync_json: test_json } + ); + } + ); + + // call updateUnknownFieldsBatch to validate it ONLY updates + // the unknown_sync_fields in the sync_json + let update = { + placeId, + unknownFields: JSON.stringify({ unknownStrField: "a new unknownStrField" }), + }; + await PlacesSyncUtils.history.updateUnknownFieldsBatch([update]); + + let updated_sync_json = await PlacesTestUtils.getDatabaseValue( + "moz_places_extra", + "sync_json", + { + place_id: placeId, + } + ); + + let updated_data = JSON.parse(updated_sync_json); + + // unknown_sync_fields has been updated + deepEqual(JSON.parse(updated_data.unknown_sync_fields), { + unknownStrField: "a new unknownStrField", + }); + + // we didn't override any other fields within + deepEqual(updated_data.extra_str_field, "another field within the json"); +}); diff --git a/toolkit/components/places/tests/sync/xpcshell.toml b/toolkit/components/places/tests/sync/xpcshell.toml new file mode 100644 index 0000000000..9d04b8aaad --- /dev/null +++ b/toolkit/components/places/tests/sync/xpcshell.toml @@ -0,0 +1,40 @@ +[DEFAULT] +head = "head_sync.js" +support-files = [ + "sync_utils_bookmarks.html", + "sync_utils_bookmarks.json", + "mirror_corrupt.sqlite", + "mirror_v1.sqlite", + "mirror_v5.sqlite", + "mirror_v8.sqlite", +] + +["test_bookmark_abort_merging.js"] + +["test_bookmark_chunking.js"] + +["test_bookmark_corruption.js"] + +["test_bookmark_deduping.js"] + +["test_bookmark_deletion.js"] + +["test_bookmark_haschanges.js"] + +["test_bookmark_kinds.js"] + +["test_bookmark_mirror_meta.js"] + +["test_bookmark_mirror_migration.js"] + +["test_bookmark_observer_recorder.js"] + +["test_bookmark_reconcile.js"] + +["test_bookmark_structure_changes.js"] + +["test_bookmark_unknown_fields.js"] + +["test_bookmark_value_changes.js"] + +["test_sync_utils.js"] |