import assert from "node:assert/strict"; import { canEditStorePlugin, getPluginFullName, isOnlinePluginUpgradeAvailable, parseOnlinePluginFullName, PluginService } from "./plugin-service.js"; type RepoOptions = { marketRows?: any[]; installedRows?: any[]; queryRows?: any[]; existingStoreRows?: any[]; }; function createPluginRepositoryMock(options: RepoOptions = {}) { const state = { savedRows: [] as any[], updates: [] as any[], queryRows: options.queryRows || options.marketRows || [], installedRows: options.installedRows || [], existingStoreRows: options.existingStoreRows || [], }; function buildQueryBuilder() { const flags: { storeOnly: boolean; installedOnly: boolean; notBuiltIn: boolean; keyword: string; fullName: string; author: string; name: string; pluginType: string; whereQuery: Record; offset: number; limit?: number; } = { storeOnly: false, installedOnly: false, notBuiltIn: false, keyword: "", fullName: "", author: "", name: "", pluginType: "", whereQuery: {}, offset: 0, }; function getFilteredList(options?: { paginate?: boolean }) { let list = (state.savedRows.length ? state.savedRows : state.queryRows).slice(); for (const key of Object.keys(flags.whereQuery)) { list = list.filter(item => item[key] === flags.whereQuery[key]); } if (flags.notBuiltIn) { list = list.filter(item => item.type !== "builtIn"); } if (flags.storeOnly) { list = list.filter(item => item.type === "store"); } if (flags.installedOnly) { list = list.filter(item => item.type !== "store" || item.installed === true); } if (flags.keyword) { const keyword = flags.keyword.toLowerCase(); list = list.filter(item => { const searchable = [item.fullName, item.author, item.name, item.title, item.desc, item.group, item.pluginType].filter(Boolean).join(" ").toLowerCase(); return searchable.includes(keyword); }); } if (!options?.paginate) { return list; } return list.slice(flags.offset, flags.limit == null ? undefined : flags.offset + flags.limit); } const builder = { where(expr: any, params?: any) { applyExpr(expr, params); return builder; }, andWhere(expr: any, params?: any) { applyExpr(expr, params); return builder; }, orWhere(expr: any, params?: any) { applyExpr(expr, params); return builder; }, orderBy() { return builder; }, addOrderBy() { return builder; }, offset(value: number) { flags.offset = value; return builder; }, limit(value: number) { flags.limit = value; return builder; }, setFindOptions() { return builder; }, getMany() { return getFilteredList({ paginate: true }); }, getCount() { return getFilteredList().length; }, getOne() { return ( state.installedRows.find(item => { if (item.type !== "store") { return false; } if (item.installed !== true) { return false; } if (flags.fullName && item.fullName !== flags.fullName && `${item.author || ""}/${item.name || ""}` !== flags.fullName && item.name !== flags.fullName) { return false; } if (flags.author && item.author !== flags.author) { return false; } if (flags.name && item.name !== flags.name) { return false; } if (flags.pluginType && item.pluginType !== flags.pluginType) { return false; } return true; }) || null ); }, }; function applyExpr(expr: any, params?: any) { if (typeof expr?.whereFactory === "function") { expr.whereFactory(builder); return; } if (expr && typeof expr === "object") { flags.whereQuery = { ...flags.whereQuery, ...expr }; return; } if (typeof expr !== "string") { return; } if (expr.includes("type != :type") && params?.type === "builtIn") { flags.notBuiltIn = true; } if (expr.includes("item.type = :type") && params?.type === "store") { flags.storeOnly = true; } if (expr.includes("main.installed") || expr.includes("plugin.installed")) { flags.installedOnly = true; } if (expr.includes("LOWER(COALESCE(item.fullName, '')) LIKE :keyword") && params?.keyword) { flags.keyword = `${params.keyword}`.replace(/%/g, ""); } if (expr.includes("plugin.fullName = :fullName") && params?.fullName) { flags.fullName = params.fullName; } if (expr.includes("plugin.author = :author AND plugin.name = :name") && params?.author && params?.name) { flags.author = params.author; flags.name = params.name; } if (expr.includes("plugin.pluginType = :pluginType") && params?.pluginType) { flags.pluginType = params.pluginType; } } return builder; } return { state, create(record: any) { return record; }, async find(options?: any) { if (options?.where?.type === "store") { return state.existingStoreRows; } return []; }, async save(records: any[] | any) { const list = Array.isArray(records) ? records : [records]; state.savedRows.push(...list); return records; }, async update(where: any, value: any) { state.updates.push({ where, value }); }, createQueryBuilder() { return buildQueryBuilder(); }, }; } describe("PluginService online plugins", () => { it("parses online plugin full name", () => { assert.deepEqual(parseOnlinePluginFullName("greper/DeployToDemo"), { author: "greper", name: "DeployToDemo", }); assert.throws(() => parseOnlinePluginFullName("greper/deploy:DeployToDemo"), /插件标识格式错误/); assert.throws(() => parseOnlinePluginFullName("DeployToDemo"), /插件标识格式错误/); }); it("detects when an online plugin can upgrade the installed version", () => { assert.equal(isOnlinePluginUpgradeAvailable("1.0.0", "1.0.1"), true); assert.equal(isOnlinePluginUpgradeAvailable("v1.2.0", "1.2.0"), false); assert.equal(isOnlinePluginUpgradeAvailable("2.0.0", "1.9.9"), false); assert.equal(isOnlinePluginUpgradeAvailable("beta", "beta2"), true); }); it("allows editing store plugins without developerId or owned by bound user", () => { assert.equal(canEditStorePlugin(undefined, undefined), true); assert.equal(canEditStorePlugin(0, undefined), true); assert.equal(canEditStorePlugin(12, 12), true); assert.equal(canEditStorePlugin(12, 13), false); assert.equal(canEditStorePlugin(12, undefined), false); }); it("uses fullName for plugin registration and falls back to name when author is empty", () => { assert.equal(getPluginFullName({ fullName: "developer/plugin", author: "other", name: "different" }), "developer/plugin"); assert.equal(getPluginFullName({ author: "developer", name: "plugin" }), "developer/plugin"); assert.equal(getPluginFullName({ author: "", name: "plugin" }), "plugin"); assert.equal(getPluginFullName({ name: "developer/plugin", author: "developer" }), "developer/plugin"); }); it("marks online plugins with local installation state", async () => { const service = new PluginService(); service.repository = createPluginRepositoryMock({ marketRows: [ { type: "store", fullName: "greper/DeployToDemo", author: "greper", name: "DeployToDemo", pluginType: "deploy", latest: "1.0.1", title: "Deploy Demo", }, ], installedRows: [ { type: "store", id: 3, fullName: "greper/DeployToDemo", author: "greper", name: "DeployToDemo", pluginType: "deploy", version: "1.0.0", disabled: false, installed: true, content: "name: DeployToDemo", }, ], }) as any; const list = await service.onlinePluginList({}); assert.equal(list.length, 1); assert.equal(list[0].installed, true); assert.equal(list[0].installedVersion, "1.0.0"); assert.equal(list[0].upgradeAvailable, true); assert.equal(list[0].localPluginId, 3); }); it("does not treat uninstalled market rows as installed plugins", async () => { const service = new PluginService(); service.repository = createPluginRepositoryMock({ marketRows: [ { type: "store", fullName: "developer/new-plugin", author: "developer", name: "new-plugin", pluginType: "access", latest: "1.0.0", }, ], installedRows: [], }) as any; const list = await service.onlinePluginList({}); assert.equal(list.length, 1); assert.equal(list[0].installed, false); assert.equal(list[0].upgradeAvailable, false); }); it("syncs online plugin list metadata into pi_plugin and saves the sync time", async () => { const service = new PluginService(); const savedSettings: any[] = []; const requestCalls: any[] = []; let createdAtLt: number | undefined; service.plusService = { async register() {}, async request(config: any) { requestCalls.push(config); assert.equal(config.url, "/activation/plugin/page"); assert.equal(config.data.page.limit, 200); assert.equal(typeof config.data.createdAtLt, "number"); if (createdAtLt === undefined) { createdAtLt = config.data.createdAtLt; } else { assert.equal(config.data.createdAtLt, createdAtLt); } if (config.data.page.start === 0) { return { page: { start: 0, limit: 200, total: 201, }, list: Array.from({ length: 200 }, (_, index) => ({ author: "developer", name: `plugin-${index + 1}`, pluginType: "deploy", title: `plugin ${index + 1}`, latest: "1.0.0", status: "published", aiCheckStatus: "passed", })), }; } assert.equal(config.data.page.start, 200); return { page: { start: 200, limit: 200, total: 201, }, list: [ { author: "developer", name: "plugin-201", pluginType: "deploy", title: "plugin 201", latest: "1.0.0", status: "published", aiCheckStatus: "passed", }, ], }; }, } as any; service.sysSettingsService = { async getSetting() { return { lastSyncTime: 0 }; }, async saveSetting(setting: any) { savedSettings.push(setting); }, } as any; const repository = createPluginRepositoryMock({ existingStoreRows: [ { id: 1, type: "store", fullName: "developer/old-plugin", author: "developer", name: "old-plugin", pluginType: "deploy", }, ], queryRows: [], }); service.repository = repository as any; const res = await service.syncOnlinePluginList(); assert.equal(requestCalls.length, 2); assert.equal(res.length, 201); assert.equal(repository.state.savedRows.length, 201); assert.equal(repository.state.savedRows[0].fullName, "developer/plugin-1"); assert.equal(repository.state.savedRows[0].aiCheckStatus, "passed"); assert.equal(savedSettings.length, 1); assert.equal(typeof savedSettings[0].lastSyncTime, "number"); assert.equal(savedSettings[0].lastSyncTime > 0, true); }); it("page includes synced store plugins without installed state", async () => { const service = new PluginService(); const installedRow = { id: 2, type: "store", fullName: "developer/installed", author: "developer", name: "installed", pluginType: "deploy", latest: "1.0.1", version: "1.0.0", installed: true, content: "name: installed", }; service.repository = createPluginRepositoryMock({ queryRows: [ { id: 1, type: "store", fullName: "developer/market-only", author: "developer", name: "market-only", pluginType: "deploy", latest: "1.0.0", content: "", }, installedRow, { id: 3, type: "store", name: "custom-one", pluginType: "deploy", content: "name: custom-one", }, ], installedRows: [installedRow], }) as any; const res = await service.page({ page: { offset: 0, limit: 20, }, query: { type: "store", }, } as any); assert.equal(res.total, 3); assert.deepEqual( res.records.map((item: any) => item.name), ["market-only", "installed", "custom-one"] ); assert.equal((res.records[0] as any).installed, false); assert.equal((res.records[1] as any).installed, true); assert.equal((res.records[1] as any).localPluginId, installedRow.id); assert.equal((res.records[1] as any).upgradeAvailable, true); }); it("registerFromDb skips uninstalled market rows", async () => { const service = new PluginService(); const registered: string[] = []; service.repository = createPluginRepositoryMock({ queryRows: [ { id: 1, type: "builtIn", name: "builtin-plugin", }, { id: 2, type: "store", fullName: "developer/market-only", author: "developer", name: "market-only", pluginType: "deploy", installed: false, content: "", }, { id: 3, type: "store", fullName: "developer/installed", author: "developer", name: "installed", pluginType: "deploy", installed: true, content: "name: installed", }, { id: 4, type: "store", name: "custom-one", pluginType: "deploy", installed: true, content: "name: custom-one", }, ], }) as any; service.registerPlugin = async plugin => { registered.push(plugin.name); }; await service.registerFromDb(); assert.deepEqual(registered, ["installed", "custom-one"]); }); it("downloads and imports online plugin content as store plugin", async () => { const service = new PluginService(); let importReq: any; const downloadUpdates: any[] = []; service.plusService = { async register() {}, async request(config: any) { assert.equal(config.url, "/activation/plugin/download"); assert.deepEqual(config.data, { fullName: "greper/DeployToDemo", version: "1.0.1" }); return { fullName: "greper/DeployToDemo", content: "name: DeployToDemo\npluginType: deploy\nauthor: greper\ncontent: |\n return class Demo {}\n", plugin: { fullName: "greper/DeployToDemo", downloadCount: 88, }, version: { version: "1.0.1", }, }; }, } as any; const repository = createPluginRepositoryMock() as any; repository.update = async (where: any, value: any) => { downloadUpdates.push({ where, value }); return {}; }; service.repository = repository; service.importPlugin = async req => { importReq = req; return { id: 9 }; }; const res = await service.installOnlinePlugin({ fullName: "greper/DeployToDemo", version: "1.0.1" }); assert.equal(res.id, 9); assert.equal(res.fullName, "greper/DeployToDemo"); assert.equal(importReq.override, true); assert.equal(importReq.type, "store"); assert.match(importReq.content, /DeployToDemo/); assert.deepEqual(downloadUpdates[0].where, { type: "store", fullName: "greper/DeployToDemo", }); }); it("fills missing yaml version from online plugin version before import", async () => { const service = new PluginService(); let importReq: any; service.plusService = { async register() {}, async request() { return { fullName: "developer/testtest2", content: "name: testtest2\npluginType: access\nauthor: developer\ncontent: |\n return class DemoAccess {}\n", plugin: { fullName: "developer/testtest2", }, version: { version: "1.0.0", }, }; }, } as any; service.repository = createPluginRepositoryMock() as any; service.importPlugin = async req => { importReq = req; return { id: 10 }; }; await service.installOnlinePlugin({ fullName: "developer/testtest2" }); assert.match(importReq.content, /version: 1\.0\.0/); }); it("preserves synced market ownership when installing plugin content", async () => { const service = new PluginService(); let updatedPlugin: any; service.repository = { async findOne() { return { id: 7, type: "store", appId: 3, developerId: 12, fullName: "greper/online-plugin", latest: "1.0.1", status: "published", downloadCount: 20, syncTime: 123, }; }, } as any; service.update = async plugin => { updatedPlugin = plugin; }; await service.importPlugin({ type: "store", override: true, content: "name: online-plugin\npluginType: deploy\nauthor: greper\ncontent: |\n return class Demo {}\n", }); assert.equal(updatedPlugin.id, 7); assert.equal(updatedPlugin.appId, 3); assert.equal(updatedPlugin.developerId, 12); assert.equal(updatedPlugin.latest, "1.0.1"); assert.equal(updatedPlugin.status, "published"); assert.equal(updatedPlugin.downloadCount, 20); assert.equal(updatedPlugin.syncTime, 123); assert.equal(updatedPlugin.installed, true); }); it("marks an uninstalled market plugin as installed after importing it again", async () => { const service = new PluginService(); let updatedPlugin: any; service.repository = { async findOne() { return { id: 7, type: "store", developerId: 12, fullName: "greper/online-plugin", installed: false, content: "", }; }, } as any; service.update = async plugin => { updatedPlugin = plugin; }; await service.importPlugin({ type: "store", override: true, content: "name: online-plugin\npluginType: deploy\nauthor: greper\ncontent: |\n return class Demo {}\n", }); assert.equal(updatedPlugin.installed, true); }); it("publishes a local store plugin through activation with exported content", async () => { const service = new PluginService(); let requestConfig: any; let registered = false; service.sysSettingsService = { async getSetting() { return { bindUserId: 12 }; }, } as any; service.plusService = { async register() { registered = true; }, async request(config: any) { requestConfig = config; return { plugin: { fullName: "developer/custom-one" }, version: { version: "1.2.3" } }; }, } as any; service.info = async () => { return { id: 7, type: "store", name: "custom-one", version: "1.2.3", } as any; }; service.exportPlugin = async () => { return "name: custom-one\nversion: 1.2.3\n"; }; const res = await service.publishLocalPlugin({ id: 7 }); assert.equal(registered, true); assert.equal(res.plugin.fullName, "developer/custom-one"); assert.equal(requestConfig.url, "/activation/plugin/publish"); assert.equal(requestConfig.method, "post"); assert.deepEqual(requestConfig.data, { userId: 12, content: "name: custom-one\nversion: 1.2.3\n", version: "1.2.3", minAppVersion: "", maxAppVersion: "", }); }); it("gets and creates online plugin author for the bound user", async () => { const service = new PluginService(); const requestConfigs: any[] = []; service.sysSettingsService = { async getSetting() { return { bindUserId: 23 }; }, } as any; service.plusService = { async register() {}, async request(config: any) { requestConfigs.push(config); if (config.url === "/activation/plugin/author/get") { return { registered: false, }; } return { id: 5, name: "developer", }; }, } as any; const authorGet = await service.getOnlinePluginAuthor(); const authorAdd = await service.addOnlinePluginAuthor({ name: "developer", displayName: "Developer", }); assert.equal(authorGet.registered, false); assert.equal(authorAdd.name, "developer"); assert.deepEqual(requestConfigs[0], { url: "/activation/plugin/author/get", method: "post", data: { userId: 23, }, }); assert.deepEqual(requestConfigs[1], { url: "/activation/plugin/author/add", method: "post", data: { userId: 23, name: "developer", displayName: "Developer", avatar: "", desc: "", }, }); }); it("gets local plugin publish management info with author and market versions", async () => { const service = new PluginService(); service.sysSettingsService = { async getSetting() { return { bindUserId: 12 }; }, } as any; service.info = async () => { return { id: 7, type: "store", name: "custom-one", title: "Custom One", pluginType: "deploy", group: "cdn", version: "1.0.0", } as any; }; service.getOnlinePluginAuthor = async () => { return { registered: true, author: { id: 2, name: "developer", }, }; }; service.onlinePluginDetail = async req => { assert.equal(req.fullName, "developer/custom-one"); return { plugin: { fullName: "developer/custom-one", status: "reviewing", }, versions: [ { version: "1.0.0", status: "reviewing", reviewStatus: "ai_pending", }, ], }; }; const info = await service.getOnlinePluginPublishInfo({ id: 7 }); assert.equal(info.localPlugin.name, "custom-one"); assert.equal(info.authorRegistered, true); assert.equal(info.marketPlugin?.fullName, "developer/custom-one"); assert.equal(info.versions[0].reviewStatus, "ai_pending"); }); it("uninstalls synced store plugins without deleting market metadata", async () => { const service = new PluginService(); const updates: any[] = []; const deleted: number[] = []; const unregistered: number[] = []; service.repository = { async update(where: any, values: any) { updates.push({ where, values }); }, } as any; service.info = async (id: number) => { if (id === 7) { return { id, type: "store", developerId: 12, fullName: "developer/online-plugin", installed: true, content: "name: online-plugin", metadata: "input: {}", extra: "dependPackages: {}", version: "1.0.0", disabled: true, } as any; } return { id, type: "store", fullName: "local/local-plugin", content: "name: local-plugin", } as any; }; service.unRegisterById = async id => { unregistered.push(id); }; service.delete = (async (id: any) => { deleted.push(Number(id)); }) as any; await service.deleteByIds([7, 8]); assert.deepEqual(unregistered, [7, 8]); assert.deepEqual(deleted, [8]); assert.deepEqual(updates, [ { where: { id: 7 }, values: { installed: false, disabled: false, }, }, ]); }); });