/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
ChromeUtils.import("resource://services-sync/bookmark_repair.js");
function makeClientRecord(id, fields = {}) {
return {
id,
version: fields.version || "54.0a1",
type: fields.type || "desktop",
stale: fields.stale || false,
serverLastModified: fields.serverLastModified || 0,
};
}
class MockClientsEngine {
constructor(clientList) {
this._clientList = clientList;
this._sentCommands = {};
}
get remoteClients() {
return Object.values(this._clientList);
}
remoteClient(id) {
return this._clientList[id];
}
async sendCommand(command, args, clientID) {
let cc = this._sentCommands[clientID] || [];
cc.push({ command, args });
this._sentCommands[clientID] = cc;
}
async getClientCommands(clientID) {
return this._sentCommands[clientID] || [];
}
}
class MockIdentity {
hashedDeviceID(did) {
return did; // don't hash it to make testing easier.
}
}
class MockService {
constructor(clientList) {
this.clientsEngine = new MockClientsEngine(clientList);
this.identity = new MockIdentity();
this._recordedEvents = [];
}
recordTelemetryEvent(object, method, value, extra = undefined) {
this._recordedEvents.push({ method, object, value, extra });
}
}
function checkState(expected) {
equal(Services.prefs.getCharPref("services.sync.repairs.bookmarks.state"), expected);
}
function checkRepairFinished() {
try {
let state = Services.prefs.getCharPref("services.sync.repairs.bookmarks.state");
ok(false, state);
} catch (ex) {
ok(true, "no repair preference exists");
}
}
function checkOutgoingCommand(service, clientID) {
let sent = service.clientsEngine._sentCommands;
deepEqual(Object.keys(sent), [clientID]);
equal(sent[clientID].length, 1);
equal(sent[clientID][0].command, "repairRequest");
}
function NewBookmarkRepairRequestor(mockService) {
let req = new BookmarkRepairRequestor(mockService);
req._now = () => Date.now() / 1000; // _now() is seconds.
return req;
}
add_task(async function test_requestor_no_clients() {
let mockService = new MockService({ });
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// there are no clients, so we should end up in "finished" (which we need to
// check via telemetry)
deepEqual(mockService._recordedEvents, [
{ object: "repair",
method: "started",
value: undefined,
extra: { flowID, numIDs: 4 },
},
{ object: "repair",
method: "finished",
value: undefined,
extra: { flowID, numIDs: 4 },
}
]);
});
add_task(async function test_requestor_one_client_no_response() {
let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// the command should now be outgoing.
checkOutgoingCommand(mockService, "client-a");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
// asking it to continue stays in that state until we timeout or the command
// is removed.
await requestor.continueRepairs();
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
// now pretend that client synced.
mockService.clientsEngine._sentCommands = {};
await requestor.continueRepairs();
checkState(BookmarkRepairRequestor.STATE.SENT_SECOND_REQUEST);
// the command should be outgoing again.
checkOutgoingCommand(mockService, "client-a");
// pretend that client synced again without writing a command.
mockService.clientsEngine._sentCommands = {};
await requestor.continueRepairs();
// There are no more clients, so we've given up.
checkRepairFinished();
deepEqual(mockService._recordedEvents, [
{ object: "repair",
method: "started",
value: undefined,
extra: { flowID, numIDs: 4 },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-a" },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-a" },
},
{ object: "repair",
method: "finished",
value: undefined,
extra: { flowID, numIDs: 4 },
}
]);
});
add_task(async function test_requestor_one_client_no_sync() {
let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// the command should now be outgoing.
checkOutgoingCommand(mockService, "client-a");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
// pretend we are now in the future.
let theFuture = Date.now() + 300000000;
requestor._now = () => theFuture;
await requestor.continueRepairs();
// We should be finished as we gave up in disgust.
checkRepairFinished();
deepEqual(mockService._recordedEvents, [
{ object: "repair",
method: "started",
value: undefined,
extra: { flowID, numIDs: 4 },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-a" },
},
{ object: "repair",
method: "abandon",
value: "silent",
extra: { flowID, deviceID: "client-a" },
},
{ object: "repair",
method: "finished",
value: undefined,
extra: { flowID, numIDs: 4 },
}
]);
});
add_task(async function test_requestor_latest_client_used() {
let mockService = new MockService({
"client-early": makeClientRecord("client-early", { serverLastModified: Date.now() - 10 }),
"client-late": makeClientRecord("client-late", { serverLastModified: Date.now() }),
});
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{ parent: "x", child: "a" },
],
orphans: [],
}
};
await requestor.startRepairs(validationInfo, Utils.makeGUID());
// the repair command should be outgoing to the most-recent client.
checkOutgoingCommand(mockService, "client-late");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
// and this test is done - reset the repair.
requestor.prefs.resetBranch();
});
add_task(async function test_requestor_client_vanishes() {
let mockService = new MockService({
"client-a": makeClientRecord("client-a"),
"client-b": makeClientRecord("client-b"),
});
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// the command should now be outgoing.
checkOutgoingCommand(mockService, "client-a");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
mockService.clientsEngine._sentCommands = {};
// Now let's pretend the client vanished.
delete mockService.clientsEngine._clientList["client-a"];
await requestor.continueRepairs();
// We should have moved on to client-b.
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
checkOutgoingCommand(mockService, "client-b");
// Now let's pretend client B wrote all missing IDs.
let response = {
collection: "bookmarks",
request: "upload",
flowID: requestor._flowID,
clientID: "client-b",
ids: ["a", "b", "c", "x"],
};
await requestor.continueRepairs(response);
// We should be finished as we got all our IDs.
checkRepairFinished();
deepEqual(mockService._recordedEvents, [
{ object: "repair",
method: "started",
value: undefined,
extra: { flowID, numIDs: 4 },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-a" },
},
{ object: "repair",
method: "abandon",
value: "missing",
extra: { flowID, deviceID: "client-a" },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-b" },
},
{ object: "repair",
method: "response",
value: "upload",
extra: { flowID, deviceID: "client-b", numIDs: 4 },
},
{ object: "repair",
method: "finished",
value: undefined,
extra: { flowID, numIDs: 0 },
}
]);
});
add_task(async function test_requestor_success_responses() {
let mockService = new MockService({
"client-a": makeClientRecord("client-a"),
"client-b": makeClientRecord("client-b"),
});
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// the command should now be outgoing.
checkOutgoingCommand(mockService, "client-a");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
mockService.clientsEngine._sentCommands = {};
// Now let's pretend the client wrote a response.
let response = {
collection: "bookmarks",
request: "upload",
clientID: "client-a",
flowID: requestor._flowID,
ids: ["a", "b"],
};
await requestor.continueRepairs(response);
// We should have moved on to client 2.
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
checkOutgoingCommand(mockService, "client-b");
// Now let's pretend client B write the missing ID.
response = {
collection: "bookmarks",
request: "upload",
clientID: "client-b",
flowID: requestor._flowID,
ids: ["c", "x"],
};
await requestor.continueRepairs(response);
// We should be finished as we got all our IDs.
checkRepairFinished();
deepEqual(mockService._recordedEvents, [
{ object: "repair",
method: "started",
value: undefined,
extra: { flowID, numIDs: 4 },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 4, deviceID: "client-a" },
},
{ object: "repair",
method: "response",
value: "upload",
extra: { flowID, deviceID: "client-a", numIDs: 2 },
},
{ object: "repair",
method: "request",
value: "upload",
extra: { flowID, numIDs: 2, deviceID: "client-b" },
},
{ object: "repair",
method: "response",
value: "upload",
extra: { flowID, deviceID: "client-b", numIDs: 2 },
},
{ object: "repair",
method: "finished",
value: undefined,
extra: { flowID, numIDs: 0 },
}
]);
});
add_task(async function test_client_suitability() {
let mockService = new MockService({
"client-a": makeClientRecord("client-a"),
"client-b": makeClientRecord("client-b", { type: "mobile" }),
"client-c": makeClientRecord("client-c", { version: "52.0a1" }),
"client-d": makeClientRecord("client-c", { version: "54.0a1" }),
});
let requestor = NewBookmarkRepairRequestor(mockService);
ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-a")));
ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-b")));
ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-c")));
ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-d")));
});
add_task(async function test_requestor_already_repairing_at_start() {
let mockService = new MockService({ });
let requestor = NewBookmarkRepairRequestor(mockService);
requestor.anyClientsRepairing = () => true;
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
ok(!(await requestor.startRepairs(validationInfo, flowID)),
"Shouldn't start repairs");
equal(mockService._recordedEvents.length, 1);
equal(mockService._recordedEvents[0].method, "aborted");
});
add_task(async function test_requestor_already_repairing_continue() {
let clientB = makeClientRecord("client-b");
let mockService = new MockService({
"client-a": makeClientRecord("client-a"),
"client-b": clientB
});
let requestor = NewBookmarkRepairRequestor(mockService);
let validationInfo = {
problems: {
missingChildren: [
{parent: "x", child: "a"},
{parent: "x", child: "b"},
{parent: "x", child: "c"}
],
orphans: [],
}
};
let flowID = Utils.makeGUID();
await requestor.startRepairs(validationInfo, flowID);
// the command should now be outgoing.
checkOutgoingCommand(mockService, "client-a");
checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
mockService.clientsEngine._sentCommands = {};
// Now let's pretend the client wrote a response (it doesn't matter what's in here)
let response = {
collection: "bookmarks",
request: "upload",
clientID: "client-a",
flowID: requestor._flowID,
ids: ["a", "b"],
};
// and another client also started a request
clientB.commands = [{
args: [{ collection: "bookmarks", flowID: "asdf" }],
command: "repairRequest",
}];
await requestor.continueRepairs(response);
// We should have aborted now
checkRepairFinished();
const expected = [
{ method: "started",
object: "repair",
value: undefined,
extra: { flowID, numIDs: "4" },
},
{ method: "request",
object: "repair",
value: "upload",
extra: { flowID, numIDs: "4", deviceID: "client-a" },
},
{ method: "aborted",
object: "repair",
value: undefined,
extra: { flowID, numIDs: "4", reason: "other clients repairing" },
}
];
deepEqual(mockService._recordedEvents, expected);
});