mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-04 18:29:29 +02:00
This patch adds the schema for table creation for messaging system's multiprofile tables, and creates an API to store and retrieve data with those tables. Differential Revision: https://phabricator.services.mozilla.com/D251747
572 lines
18 KiB
JavaScript
572 lines
18 KiB
JavaScript
import { ASRouterStorage } from "modules/ASRouterStorage.sys.mjs";
|
|
import { GlobalOverrider } from "tests/unit/utils";
|
|
|
|
let overrider = new GlobalOverrider();
|
|
|
|
describe("ASRouterStorage", () => {
|
|
let sandbox;
|
|
let indexedDB;
|
|
let storage;
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox();
|
|
indexedDB = {
|
|
open: sandbox.stub().resolves({}),
|
|
deleteDatabase: sandbox.stub().resolves(),
|
|
};
|
|
overrider.set({ IndexedDB: indexedDB });
|
|
storage = new ASRouterStorage({
|
|
storeNames: ["storage_test"],
|
|
telemetry: { handleUndesiredEvent: sandbox.stub() },
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
it("should throw if required arguments not provided", () => {
|
|
assert.throws(() => new ASRouterStorage({ telemetry: true }));
|
|
});
|
|
describe(".db", () => {
|
|
it("should not throw an error when accessing db", async () => {
|
|
assert.ok(storage.db);
|
|
});
|
|
|
|
it("should delete and recreate the db if opening db fails", async () => {
|
|
const newDb = {};
|
|
indexedDB.open.onFirstCall().rejects(new Error("fake error"));
|
|
indexedDB.open.onSecondCall().resolves(newDb);
|
|
|
|
const db = await storage.db;
|
|
assert.calledOnce(indexedDB.deleteDatabase);
|
|
assert.calledTwice(indexedDB.open);
|
|
assert.equal(db, newDb);
|
|
});
|
|
});
|
|
describe("#getDbTable", () => {
|
|
let testStorage;
|
|
let storeStub;
|
|
beforeEach(() => {
|
|
storeStub = {
|
|
getAll: sandbox.stub().resolves(),
|
|
getAllKeys: sandbox.stub().resolves(),
|
|
get: sandbox.stub().resolves(),
|
|
put: sandbox.stub().resolves(),
|
|
};
|
|
sandbox.stub(storage, "_getStore").resolves(storeStub);
|
|
testStorage = storage.getDbTable("storage_test");
|
|
});
|
|
it("should reverse key value parameters for put", async () => {
|
|
await testStorage.set("key", "value");
|
|
|
|
assert.calledOnce(storeStub.put);
|
|
assert.calledWith(storeStub.put, "value", "key");
|
|
});
|
|
it("should return the correct value for get", async () => {
|
|
storeStub.get.withArgs("foo").resolves("foo");
|
|
|
|
const result = await testStorage.get("foo");
|
|
|
|
assert.calledOnce(storeStub.get);
|
|
assert.equal(result, "foo");
|
|
});
|
|
it("should return the correct value for getAll", async () => {
|
|
storeStub.getAll.resolves(["bar"]);
|
|
|
|
const result = await testStorage.getAll();
|
|
|
|
assert.calledOnce(storeStub.getAll);
|
|
assert.deepEqual(result, ["bar"]);
|
|
});
|
|
it("should return the correct value for getAllKeys", async () => {
|
|
storeStub.getAllKeys.resolves(["key1", "key2", "key3"]);
|
|
|
|
const result = await testStorage.getAllKeys();
|
|
|
|
assert.calledOnce(storeStub.getAllKeys);
|
|
assert.deepEqual(result, ["key1", "key2", "key3"]);
|
|
});
|
|
it("should query the correct object store", async () => {
|
|
await testStorage.get();
|
|
|
|
assert.calledOnce(storage._getStore);
|
|
assert.calledWithExactly(storage._getStore, "storage_test");
|
|
});
|
|
it("should throw if table is not found", () => {
|
|
assert.throws(() => storage.getDbTable("undefined_store"));
|
|
});
|
|
});
|
|
it("should get the correct objectStore when calling _getStore", async () => {
|
|
const objectStoreStub = sandbox.stub();
|
|
indexedDB.open.resolves({ objectStore: objectStoreStub });
|
|
|
|
await storage._getStore("foo");
|
|
|
|
assert.calledOnce(objectStoreStub);
|
|
assert.calledWithExactly(objectStoreStub, "foo", "readwrite");
|
|
});
|
|
it("should create a db with the correct store name", async () => {
|
|
const dbStub = {
|
|
createObjectStore: sandbox.stub(),
|
|
objectStoreNames: { contains: sandbox.stub().returns(false) },
|
|
};
|
|
await storage.db;
|
|
|
|
// call the cb with a stub
|
|
indexedDB.open.args[0][2](dbStub);
|
|
|
|
assert.calledOnce(dbStub.createObjectStore);
|
|
assert.calledWithExactly(dbStub.createObjectStore, "storage_test");
|
|
});
|
|
it("should handle an array of object store names", async () => {
|
|
storage = new ASRouterStorage({
|
|
storeNames: ["store1", "store2"],
|
|
telemetry: {},
|
|
});
|
|
const dbStub = {
|
|
createObjectStore: sandbox.stub(),
|
|
objectStoreNames: { contains: sandbox.stub().returns(false) },
|
|
};
|
|
await storage.db;
|
|
|
|
// call the cb with a stub
|
|
indexedDB.open.args[0][2](dbStub);
|
|
|
|
assert.calledTwice(dbStub.createObjectStore);
|
|
assert.calledWith(dbStub.createObjectStore, "store1");
|
|
assert.calledWith(dbStub.createObjectStore, "store2");
|
|
});
|
|
it("should skip creating existing stores", async () => {
|
|
storage = new ASRouterStorage({
|
|
storeNames: ["store1", "store2"],
|
|
telemetry: {},
|
|
});
|
|
const dbStub = {
|
|
createObjectStore: sandbox.stub(),
|
|
objectStoreNames: { contains: sandbox.stub().returns(true) },
|
|
};
|
|
await storage.db;
|
|
|
|
// call the cb with a stub
|
|
indexedDB.open.args[0][2](dbStub);
|
|
|
|
assert.notCalled(dbStub.createObjectStore);
|
|
});
|
|
describe("#_requestWrapper", () => {
|
|
it("should return a successful result", async () => {
|
|
const result = await storage._requestWrapper(() =>
|
|
Promise.resolve("foo")
|
|
);
|
|
|
|
assert.equal(result, "foo");
|
|
assert.notCalled(storage.telemetry.handleUndesiredEvent);
|
|
});
|
|
it("should report failures", async () => {
|
|
try {
|
|
await storage._requestWrapper(() => Promise.reject(new Error()));
|
|
} catch (e) {
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
function assertMessageBlockedTransaction(mockConnection, expectedMessageId) {
|
|
assert.callCount(mockConnection.executeCached, 2);
|
|
|
|
const [call1, call2] = [
|
|
mockConnection.executeCached.getCall(0),
|
|
mockConnection.executeCached.getCall(1),
|
|
];
|
|
|
|
assert.match(call1.args[0], /INSERT INTO MessagingSystemMessageBlocklist/);
|
|
assert.match(call2.args[0], /DELETE FROM MessagingSystemMessageImpressions/);
|
|
|
|
assert.deepEqual(call1.args[1], { messageId: expectedMessageId });
|
|
assert.deepEqual(call2.args[1], { messageId: expectedMessageId });
|
|
}
|
|
|
|
describe("Shared database methods", () => {
|
|
let sandbox;
|
|
let mockConnection;
|
|
let storage;
|
|
let errorStub;
|
|
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox();
|
|
errorStub = sandbox.stub();
|
|
|
|
storage = new ASRouterStorage({
|
|
storeNames: ["storage_test"],
|
|
telemetry: { handleUndesiredEvent: sandbox.stub() },
|
|
});
|
|
|
|
mockConnection = {
|
|
executeCached: sandbox.stub(),
|
|
executeBeforeShutdown: sandbox.stub(),
|
|
executeTransaction: sandbox.stub(),
|
|
};
|
|
|
|
mockConnection.executeBeforeShutdown.callsFake(async (label, fn) => {
|
|
return await fn();
|
|
});
|
|
mockConnection.executeTransaction.callsFake(async fn => {
|
|
return await fn();
|
|
});
|
|
|
|
overrider.set({
|
|
ASRouterPreferences: {
|
|
console: {
|
|
error: errorStub,
|
|
},
|
|
},
|
|
IndexedDB: indexedDB,
|
|
ProfilesDatastoreService: {
|
|
getConnection: sandbox.stub().resolves(mockConnection),
|
|
notify: sandbox.stub(),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
overrider.restore();
|
|
});
|
|
|
|
describe("#getSharedMessageImpressions", () => {
|
|
it("should return message impressions data when records exist", async () => {
|
|
const mockRows = [
|
|
{
|
|
getResultByName: columnName => {
|
|
if (columnName === "messageId") {
|
|
return "message1";
|
|
}
|
|
if (columnName === "impressions") {
|
|
return JSON.stringify([123, 456]);
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
{
|
|
getResultByName: columnName => {
|
|
if (columnName === "messageId") {
|
|
return "message2";
|
|
}
|
|
if (columnName === "impressions") {
|
|
return JSON.stringify([123, 456, 789]);
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
];
|
|
|
|
mockConnection.executeCached.resolves(mockRows);
|
|
|
|
const result = await storage.getSharedMessageImpressions();
|
|
|
|
// Execute should be called with expected SQL
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
assert.calledWith(
|
|
mockConnection.executeCached,
|
|
"SELECT messageId, json(impressions) AS impressions FROM MessagingSystemMessageImpressions;"
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
message1: [123, 456],
|
|
message2: [123, 456, 789],
|
|
});
|
|
});
|
|
|
|
it("should return null when no records exist", async () => {
|
|
mockConnection.executeCached.resolves([]);
|
|
|
|
const result = await storage.getSharedMessageImpressions();
|
|
|
|
assert.ok(result === null);
|
|
});
|
|
|
|
it("should handle database errors and call telemetry", async () => {
|
|
const error = new Error("Database connection failed");
|
|
mockConnection.executeCached.rejects(error);
|
|
|
|
const result = await storage.getSharedMessageImpressions();
|
|
|
|
assert.equal(result, null);
|
|
assert.calledOnce(errorStub);
|
|
assert.calledWith(
|
|
errorStub,
|
|
"ASRouterStorage: Failed reading from MessagingSystemMessageImpressions",
|
|
error
|
|
);
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
assert.calledWith(storage.telemetry.handleUndesiredEvent, {
|
|
event: "SHARED_DB_READ_FAILED",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("#setSharedMessageImpressions", () => {
|
|
it("should return a true success state when the transaction succeeds", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
const result = await storage.setSharedMessageImpressions(
|
|
"test_message",
|
|
[123, 456]
|
|
);
|
|
|
|
assert.equal(result, true);
|
|
assert.calledOnce(mockConnection.executeBeforeShutdown);
|
|
assert.calledWith(
|
|
mockConnection.executeBeforeShutdown,
|
|
"ASRouter: setSharedMessageImpressions"
|
|
);
|
|
});
|
|
|
|
it("should return a false success state when the transaction fails", async () => {
|
|
const error = new Error("Database write failed");
|
|
mockConnection.executeCached.rejects(error);
|
|
|
|
const result = await storage.setSharedMessageImpressions(
|
|
"test_message",
|
|
[123, 456]
|
|
);
|
|
|
|
assert.equal(result, false);
|
|
assert.calledOnce(errorStub);
|
|
assert.calledWith(
|
|
errorStub,
|
|
"ASRouterStorage: Failed writing to MessagingSystemMessageImpressions",
|
|
error
|
|
);
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
assert.calledWith(storage.telemetry.handleUndesiredEvent, {
|
|
event: "SHARED_DB_WRITE_FAILED",
|
|
});
|
|
});
|
|
|
|
it("should gracefully fail when called with no message ID", async () => {
|
|
const result = await storage.setSharedMessageImpressions(
|
|
null,
|
|
[123, 456]
|
|
);
|
|
|
|
assert.equal(result, false);
|
|
assert.calledOnce(errorStub);
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
assert.calledWith(storage.telemetry.handleUndesiredEvent, {
|
|
event: "SHARED_DB_WRITE_FAILED",
|
|
});
|
|
});
|
|
|
|
it("should execute the appropriate sql query with the appropriate message ID", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
await storage.setSharedMessageImpressions("test_message", [123, 456]);
|
|
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
const executeCall = mockConnection.executeCached.getCall(0);
|
|
|
|
assert.match(
|
|
executeCall.args[0],
|
|
/INSERT INTO MessagingSystemMessageImpressions/
|
|
);
|
|
assert.match(executeCall.args[0], /ON CONFLICT \(messageId\) DO UPDATE/);
|
|
|
|
assert.deepEqual(executeCall.args[1], {
|
|
messageId: "test_message",
|
|
impressions: JSON.stringify([123, 456]),
|
|
});
|
|
});
|
|
|
|
it("should delete the record when impressions is falsy or an empty array", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
await storage.setSharedMessageImpressions("test_message", []);
|
|
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
let executeCall = mockConnection.executeCached.getCall(0);
|
|
assert.match(
|
|
executeCall.args[0],
|
|
/DELETE FROM MessagingSystemMessageImpressions/
|
|
);
|
|
assert.deepEqual(executeCall.args[1], { messageId: "test_message" });
|
|
|
|
// Reset and test with null
|
|
mockConnection.executeCached.resetHistory();
|
|
mockConnection.executeBeforeShutdown.resetHistory();
|
|
await storage.setSharedMessageImpressions("test_message", null);
|
|
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
executeCall = mockConnection.executeCached.getCall(0);
|
|
assert.match(
|
|
executeCall.args[0],
|
|
/DELETE FROM MessagingSystemMessageImpressions/
|
|
);
|
|
});
|
|
|
|
it("should call ProfilesDatastoreService.notify() after successful operation", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
const notifySpy = sandbox.spy();
|
|
|
|
overrider.set({
|
|
ProfilesDatastoreService: {
|
|
getConnection: sandbox.stub().resolves(mockConnection),
|
|
notify: notifySpy,
|
|
},
|
|
});
|
|
|
|
await storage.setSharedMessageImpressions("test_message", [123]);
|
|
assert.calledOnce(notifySpy);
|
|
});
|
|
});
|
|
|
|
describe("#getSharedMessageBlocklist", () => {
|
|
it("should return array of blocked message IDs when records exist", async () => {
|
|
const mockRows = [
|
|
{
|
|
getResultByName: columnName => {
|
|
if (columnName === "messageId") {
|
|
return "blocked_message1";
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
{
|
|
getResultByName: columnName => {
|
|
if (columnName === "messageId") {
|
|
return "blocked_message2";
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
];
|
|
|
|
mockConnection.executeCached.resolves(mockRows);
|
|
|
|
const result = await storage.getSharedMessageBlocklist();
|
|
|
|
// Execute should be called with expected SQL
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
assert.calledWith(
|
|
mockConnection.executeCached,
|
|
"SELECT messageId FROM MessagingSystemMessageBlocklist;"
|
|
);
|
|
|
|
assert.deepEqual(result, ["blocked_message1", "blocked_message2"]);
|
|
});
|
|
|
|
it("should return empty array when no blocked messages exist", async () => {
|
|
mockConnection.executeCached.resolves([]);
|
|
|
|
const result = await storage.getSharedMessageBlocklist();
|
|
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
it("should handle database errors and return null", async () => {
|
|
const error = new Error("Database connection failed");
|
|
mockConnection.executeCached.rejects(error);
|
|
|
|
const result = await storage.getSharedMessageBlocklist();
|
|
|
|
assert.equal(result, null);
|
|
assert.calledOnce(errorStub);
|
|
assert.calledWith(
|
|
errorStub,
|
|
"ASRouterStorage: Failed reading from MessagingSystemMessageBlocklist",
|
|
error
|
|
);
|
|
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
assert.calledWith(storage.telemetry.handleUndesiredEvent, {
|
|
event: "SHARED_DB_READ_FAILED",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("#setSharedMessageBlocked", () => {
|
|
it("should return true success state when blocking a message succeeds", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
const result = await storage.setSharedMessageBlocked(
|
|
"test_message",
|
|
true
|
|
);
|
|
|
|
assert.equal(result, true);
|
|
assert.calledOnce(mockConnection.executeTransaction);
|
|
});
|
|
|
|
it("should return false success state when the transaction fails", async () => {
|
|
const error = new Error("Database write failed");
|
|
mockConnection.executeCached
|
|
.withArgs(sinon.match(/INSERT/))
|
|
.rejects(error);
|
|
|
|
const result = await storage.setSharedMessageBlocked(
|
|
"test_message",
|
|
true
|
|
);
|
|
|
|
assert.equal(result, false);
|
|
assert.calledOnce(errorStub);
|
|
assert.calledWith(
|
|
errorStub,
|
|
"ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist",
|
|
error
|
|
);
|
|
assert.calledOnce(storage.telemetry.handleUndesiredEvent);
|
|
assert.calledWith(storage.telemetry.handleUndesiredEvent, {
|
|
event: "SHARED_DB_WRITE_FAILED",
|
|
});
|
|
});
|
|
|
|
it("should execute INSERT and DELETE queries in a transaction when blocking a message", async () => {
|
|
const messageId = "test_blocking_message";
|
|
mockConnection.executeCached.resolves();
|
|
await storage.setSharedMessageBlocked(messageId, true);
|
|
|
|
assertMessageBlockedTransaction(mockConnection, messageId);
|
|
});
|
|
|
|
it("should execute DELETE query when unblocking a message", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
await storage.setSharedMessageBlocked("test_unblocking_message", false);
|
|
|
|
assert.calledOnce(mockConnection.executeCached);
|
|
const executeCall = mockConnection.executeCached.getCall(0);
|
|
assert.match(
|
|
executeCall.args[0],
|
|
/DELETE FROM MessagingSystemMessageBlocklist/
|
|
);
|
|
assert.deepEqual(executeCall.args[1], {
|
|
messageId: "test_unblocking_message",
|
|
});
|
|
});
|
|
|
|
it("should default to blocking when isBlocked parameter is not provided", async () => {
|
|
const messageId = "default_blocked_message";
|
|
mockConnection.executeCached.resolves();
|
|
|
|
await storage.setSharedMessageBlocked(messageId);
|
|
|
|
assertMessageBlockedTransaction(mockConnection, messageId);
|
|
});
|
|
|
|
it("should call ProfilesDatastoreService.notify() after successful operation", async () => {
|
|
mockConnection.executeCached.resolves();
|
|
|
|
const notifySpy = sandbox.spy();
|
|
|
|
overrider.set({
|
|
ProfilesDatastoreService: {
|
|
getConnection: sandbox.stub().resolves(mockConnection),
|
|
notify: notifySpy,
|
|
},
|
|
});
|
|
|
|
await storage.setSharedMessageBlocked("test_message", true);
|
|
assert.calledOnce(notifySpy);
|
|
});
|
|
});
|
|
});
|