UNPKG

@itwin/core-backend

Version:
353 lines 18.9 kB
/*--------------------------------------------------------------------------------------------- * 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