@itwin/core-backend
Version:
iTwin.js backend components
353 lines • 18.9 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { expect } from "chai";
import * as fs from "fs-extra";
import * as path from "path";
import * as sinon from "sinon";
import { NativeLibrary } from "@bentley/imodeljs-native";
import { DbResult, Guid, OpenMode } from "@itwin/core-bentley";
import { BlobContainer } from "../../BlobContainerService";
import { IModelHost } from "../../IModelHost";
import { setOnlineStatus } from "../../internal/OnlineStatus";
import { WorkspaceSqliteDb } from "../../internal/workspace/WorkspaceSqliteDb";
import { SettingsPriority } from "../../workspace/Settings";
import { settingsWorkspaceDbName } from "../../workspace/SettingsDb";
import { SettingsEditor, settingsResourceName } from "../../workspace/SettingsEditor";
import { TestUtils } from "../TestUtils";
describe("ITwin Workspace", () => {
const opts = { cacheDir: TestUtils.getCacheDir() };
let savedBlobContainerService;
function getITwinWorkspaceDir(containerId) {
return path.join(opts.cacheDir ?? NativeLibrary.defaultCacheDir, "Workspace", containerId);
}
function writeSettingsDb(dbFileName, settings, workspaceName) {
fs.ensureDirSync(path.dirname(dbFileName));
WorkspaceSqliteDb.createNewDb(dbFileName, { manifest: { workspaceName } });
const db = new WorkspaceSqliteDb();
db.openDb(dbFileName, OpenMode.ReadWrite);
db.withSqliteStatement("INSERT INTO strings(id,value) VALUES(?,?)", (stmt) => {
stmt.bindString(1, settingsResourceName);
stmt.bindString(2, JSON.stringify(settings));
const rc = stmt.step();
expect(rc).to.equal(DbResult.BE_SQLITE_DONE);
});
db.saveChanges();
db.closeDb();
}
function createLocalSettingsDb(containerId, settings) {
const dbDir = getITwinWorkspaceDir(containerId);
const dbFileName = path.join(dbDir, "settings-db.itwin-workspace");
writeSettingsDb(dbFileName, settings, `${containerId} settings`);
}
function createSettingsContainerService(iTwinId, containerIds) {
return {
create: async () => ({ baseUri: "", containerId: containerIds[0], provider: "azure" }),
delete: async () => { },
queryScope: async () => ({ iTwinId }),
queryMetadata: async () => ({ containerType: "settings", label: "settings" }),
queryContainersMetadata: async () => containerIds.map((containerId) => ({ containerId, containerType: "settings", label: containerId, iTwinId })),
updateJson: async () => { },
requestToken: async ({ containerId }) => ({
token: "",
scope: { iTwinId },
provider: "azure",
expiration: new Date(Date.now() + 3600000),
metadata: { containerType: "settings", label: containerId },
baseUri: "",
}),
};
}
/** Creates a mock service that returns containers from both the child iTwin and its account iTwin. */
function createAccountSettingsContainerService(childITwinId, accountITwinId, childContainerIds, accountContainerIds) {
return {
create: async () => ({ baseUri: "", containerId: childContainerIds[0], provider: "azure" }),
delete: async () => { },
queryScope: async () => ({ iTwinId: childITwinId }),
queryMetadata: async () => ({ containerType: "settings", label: "settings" }),
queryContainersMetadata: async (_userToken, args) => {
expect(args.includeParentITwins).to.deep.equal({ filter: "accountOnly" });
const results = childContainerIds.map((containerId) => ({
containerId,
containerType: "settings",
label: containerId,
iTwinId: childITwinId,
accountITwinId,
}));
results.push(...accountContainerIds.map((containerId) => ({
containerId,
containerType: "settings",
label: containerId,
iTwinId: accountITwinId,
accountITwinId,
})));
return results;
},
updateJson: async () => { },
requestToken: async ({ containerId }) => ({
token: "",
scope: { iTwinId: childITwinId },
provider: "azure",
expiration: new Date(Date.now() + 3600000),
metadata: { containerType: "settings", label: containerId },
baseUri: "",
}),
};
}
beforeEach(async () => {
await TestUtils.shutdownBackend();
savedBlobContainerService = BlobContainer.service;
setOnlineStatus(true);
});
afterEach(() => {
BlobContainer.service = savedBlobContainerService;
setOnlineStatus(true);
sinon.restore();
});
after(async () => {
await TestUtils.startBackend(); // restart normal backend so subsequent test suites aren't left without IModelHost
});
it("loads iTwin workspaces from the settings container", async () => {
const iTwinId = Guid.createValue();
createLocalSettingsDb("itwin-settings-a", {
"app/testA": "value-a",
"app/testB": "value-b",
});
BlobContainer.service = createSettingsContainerService(iTwinId, ["itwin-settings-a"]);
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace(iTwinId);
expect(workspace.settings.getString("app/testA")).to.equal("value-a");
expect(workspace.settings.getString("app/testB")).to.equal("value-b");
workspace.close();
});
it("returns an empty iTwin workspace if no root settings container exists", async () => {
const iTwinId = Guid.createValue();
BlobContainer.service = createSettingsContainerService(iTwinId, []);
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace(iTwinId);
expect(workspace.settings.dictionaries.length).to.equal(0);
expect(workspace.settings.getString("app/testA")).to.be.undefined;
workspace.close();
});
it("fails if multiple iTwin settings containers exist for the same iTwin", async () => {
const iTwinId = Guid.createValue();
BlobContainer.service = createSettingsContainerService(iTwinId, ["itwin-settings-a", "itwin-settings-b"]);
await IModelHost.startup(opts);
await expect(IModelHost.getITwinWorkspace(iTwinId)).to.be.rejectedWith("Multiple iTwin settings containers were found");
});
it("loads iTwin workspace from container props without network calls", async () => {
const containerId = "itwin-settings-offline";
createLocalSettingsDb(containerId, { "app/testA": "value-a" });
const unexpectedNetworkCall = sinon.stub().rejects(new Error("unexpected network call"));
const queryContainersMetadata = sinon.stub().resolves([]);
const requestToken = sinon.stub().resolves({
token: "",
scope: { iTwinId: Guid.createValue() },
provider: "azure",
expiration: new Date(Date.now() + 3600000),
metadata: { containerType: "settings", label: containerId },
baseUri: "",
});
const networkService = {
create: unexpectedNetworkCall,
delete: unexpectedNetworkCall,
queryScope: unexpectedNetworkCall,
queryMetadata: unexpectedNetworkCall,
queryContainersMetadata,
updateJson: unexpectedNetworkCall,
requestToken,
};
BlobContainer.service = networkService;
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace({
baseUri: "",
containerId,
storageType: "azure",
priority: SettingsPriority.iTwin,
dbName: settingsWorkspaceDbName,
includePrerelease: true,
});
expect(workspace.settings.getString("app/testA")).to.equal("value-a");
expect(queryContainersMetadata.called).to.be.false;
expect(requestToken.called).to.be.false;
workspace.close();
});
it("getITwinWorkspace loads all named dictionaries from the settings container", async () => {
const iTwinId = Guid.createValue();
const containerId = "itwin-settings-multi";
const dbDir = getITwinWorkspaceDir(containerId);
const dbFileName = path.join(dbDir, "settings-db.itwin-workspace");
// Create a local SettingsDb with two named dictionary resources
fs.ensureDirSync(path.dirname(dbFileName));
WorkspaceSqliteDb.createNewDb(dbFileName, { manifest: { workspaceName: "multi-dict settings" } });
const db = new WorkspaceSqliteDb();
db.openDb(dbFileName, OpenMode.ReadWrite);
db.withSqliteStatement("INSERT INTO strings(id,value) VALUES(?,?)", (stmt) => {
stmt.bindString(1, "dict-a");
stmt.bindString(2, JSON.stringify({ "app/testA": "value-a" }));
expect(stmt.step()).to.equal(DbResult.BE_SQLITE_DONE);
});
db.withSqliteStatement("INSERT INTO strings(id,value) VALUES(?,?)", (stmt) => {
stmt.bindString(1, "dict-b");
stmt.bindString(2, JSON.stringify({ "app/testB": "value-b" }));
expect(stmt.step()).to.equal(DbResult.BE_SQLITE_DONE);
});
db.saveChanges();
db.closeDb();
BlobContainer.service = createSettingsContainerService(iTwinId, [containerId]);
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace(iTwinId);
expect(workspace.settings.getString("app/testA")).to.equal("value-a");
expect(workspace.settings.getString("app/testB")).to.equal("value-b");
workspace.close();
});
it("saveSettingDictionary saves a named dictionary and closes the editor", async () => {
const iTwinId = Guid.createValue();
const updateSettingsResource = sinon.spy();
const close = sinon.spy();
const withEditableDb = sinon.stub().callsFake(async (_user, operation) => {
operation({ updateSettingsResource });
});
const constructStub = sinon.stub(SettingsEditor, "constructForITwin").resolves({
editor: { close },
container: { withEditableDb },
});
await IModelHost.saveSettingDictionary(iTwinId, "myDict", { "app/value": 1, "app/name": "test" });
expect(constructStub.calledOnceWithExactly(iTwinId)).to.be.true;
expect(withEditableDb.calledOnce).to.be.true;
expect(withEditableDb.firstCall.args[0]).to.equal(IModelHost.userMoniker);
expect(updateSettingsResource.calledOnce).to.be.true;
expect(updateSettingsResource.firstCall.args[0]).to.deep.equal({ "app/value": 1, "app/name": "test" });
expect(updateSettingsResource.firstCall.args[1]).to.equal("myDict");
expect(close.calledOnce).to.be.true;
});
it("deleteSettingDictionary is a no-op when no iTwin settings container exists", async () => {
const iTwinId = Guid.createValue();
const getEditor = sinon.stub(SettingsEditor, "getForITwin").resolves(undefined);
const constructStub = sinon.stub(SettingsEditor, "constructForITwin");
await IModelHost.deleteSettingDictionary(iTwinId, "myDict");
expect(getEditor.calledOnceWithExactly(iTwinId)).to.be.true;
expect(constructStub.called).to.be.false;
});
it("deleteSettingDictionary removes a named dictionary and closes the editor", async () => {
const iTwinId = Guid.createValue();
const removeString = sinon.spy();
const close = sinon.spy();
const withEditableDb = sinon.stub().callsFake(async (_user, operation) => {
operation({ removeString });
});
const getEditor = sinon.stub(SettingsEditor, "getForITwin").resolves({
editor: { close },
container: { withEditableDb },
});
await IModelHost.deleteSettingDictionary(iTwinId, "myDict");
expect(getEditor.calledOnceWithExactly(iTwinId)).to.be.true;
expect(withEditableDb.calledOnce).to.be.true;
expect(withEditableDb.firstCall.args[0]).to.equal(IModelHost.userMoniker);
expect(removeString.calledOnceWithExactly("myDict")).to.be.true;
expect(close.calledOnce).to.be.true;
});
it("saveSettingDictionary closes editor even on error", async () => {
const iTwinId = Guid.createValue();
const close = sinon.spy();
sinon.stub(SettingsEditor, "constructForITwin").resolves({
editor: { close },
container: { withEditableDb: sinon.stub().rejects(new Error("write failed")) },
});
await expect(IModelHost.saveSettingDictionary(iTwinId, "x", {})).to.be.rejectedWith("write failed");
expect(close.calledOnce).to.be.true;
});
it("deleteSettingDictionary closes editor even on error", async () => {
const iTwinId = Guid.createValue();
const close = sinon.spy();
sinon.stub(SettingsEditor, "getForITwin").resolves({
editor: { close },
container: { withEditableDb: sinon.stub().rejects(new Error("delete failed")) },
});
await expect(IModelHost.deleteSettingDictionary(iTwinId, "x")).to.be.rejectedWith("delete failed");
expect(close.calledOnce).to.be.true;
});
it("loads account iTwin settings at organization priority", async () => {
const childITwinId = Guid.createValue();
const accountITwinId = Guid.createValue();
createLocalSettingsDb("child-settings", {
"app/childOnly": "from-child",
"app/shared": "child-wins",
});
createLocalSettingsDb("account-settings", {
"app/accountOnly": "from-account",
"app/shared": "account-loses",
});
BlobContainer.service = createAccountSettingsContainerService(childITwinId, accountITwinId, ["child-settings"], ["account-settings"]);
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace(childITwinId);
// Child iTwin setting is present.
expect(workspace.settings.getString("app/childOnly")).to.equal("from-child");
// Account iTwin setting is present (loaded at organization priority).
expect(workspace.settings.getString("app/accountOnly")).to.equal("from-account");
// Child iTwin setting overrides account iTwin setting for the same key.
expect(workspace.settings.getString("app/shared")).to.equal("child-wins");
workspace.close();
});
it("when the iTwin is its own account iTwin, its container loads at organization priority", async () => {
// Edge case: the requested iTwin IS the account iTwin (e.g. a root/tenant iTwin with no parent).
// The service returns one container where iTwinId === accountITwinId === requestedITwinId.
// getITwinSettingsSources should succeed and assign SettingsPriority.organization because
// `container.accountITwinId === ownerITwinId` is true.
const iTwinId = Guid.createValue();
createLocalSettingsDb("self-account-settings", {
"app/rootSetting": "root-value",
});
// Pass the same id for both child and account — the service returns a single container
// with iTwinId === accountITwinId === iTwinId.
BlobContainer.service = createAccountSettingsContainerService(iTwinId, iTwinId, ["self-account-settings"], []);
await IModelHost.startup(opts);
const workspace = await IModelHost.getITwinWorkspace(iTwinId);
try {
expect(workspace.settings.getString("app/rootSetting")).to.equal("root-value");
// The container's accountITwinId equals its ownerITwinId, so it loads at organization priority.
expect(workspace.settings.dictionaries).to.have.lengthOf(1);
expect(workspace.settings.dictionaries[0].props.priority).to.equal(SettingsPriority.organization);
}
finally {
workspace.close();
}
});
it("fails if multiple settings containers exist for the same account iTwin", async () => {
const childITwinId = Guid.createValue();
const accountITwinId = Guid.createValue();
BlobContainer.service = createAccountSettingsContainerService(childITwinId, accountITwinId, ["child-settings"], ["account-a", "account-b"]);
await IModelHost.startup(opts);
await expect(IModelHost.getITwinWorkspace(childITwinId)).to.be.rejectedWith("Multiple iTwin settings containers were found");
});
it("throws missing-container-itwinid if the service returns a container without an iTwinId", async () => {
// Simulates an older BlobContainer service version that does not populate iTwinId
// in queryContainersMetadata results. getITwinSettingsSources requires iTwinId to
// decide which SettingsPriority to assign each container, so it must throw.
const iTwinId = Guid.createValue();
const service = {
create: async () => ({ baseUri: "", containerId: "no-itwinid-container", provider: "azure" }),
delete: async () => { },
queryScope: async () => ({ iTwinId }),
queryMetadata: async () => ({ containerType: "settings", label: "settings" }),
queryContainersMetadata: async () => [
// iTwinId intentionally omitted — simulates a service that predates the iTwinId field
{ containerId: "no-itwinid-container", containerType: "settings", label: "no-itwinid-container" },
],
updateJson: async () => { },
requestToken: async ({ containerId }) => ({
token: "",
scope: { iTwinId },
provider: "azure",
expiration: new Date(Date.now() + 3600000),
metadata: { containerType: "settings", label: containerId },
baseUri: "",
}),
};
BlobContainer.service = service;
await IModelHost.startup(opts);
await expect(IModelHost.getITwinWorkspace(iTwinId)).to.be.rejectedWith("has no iTwinId");
});
});
//# sourceMappingURL=ITwinWorkspace.test.js.map