284 lines
9.0 KiB
TypeScript
284 lines
9.0 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { QqbotAccountService } from '../../../../src/modules/qqbot/core/application/account/qqbot-account.service';
|
|
import { QqbotConfigService } from '../../../../src/modules/qqbot/core/application/config/qqbot-config.service';
|
|
import { QqbotSendService } from '../../../../src/modules/qqbot/core/application/send/qqbot-send.service';
|
|
import { DictService } from '../../../../src/modules/admin/platform-config/dict/dict.service';
|
|
import { QqbotPluginHostBridgeService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service';
|
|
import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service';
|
|
import type { QqbotPluginPackageDescriptor } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types';
|
|
|
|
describe('QQBot plugin host bridge', () => {
|
|
let tempRoot: string;
|
|
let bridge: QqbotPluginHostBridgeService;
|
|
let configValues: Record<string, string | undefined>;
|
|
let httpClient: {
|
|
requestBuffer: jest.Mock;
|
|
requestJson: jest.Mock;
|
|
resolveRedirect: jest.Mock;
|
|
};
|
|
let accountService: {
|
|
bindEventPlugin: jest.Mock;
|
|
getBoundEventPluginKeys: jest.Mock;
|
|
unbindEventPlugin: jest.Mock;
|
|
};
|
|
let sendService: { sendText: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-host-'));
|
|
configValues = {
|
|
SAMPLE_TOKEN: 'token-1',
|
|
SAMPLE_EMPTY: undefined,
|
|
};
|
|
const configService = new QqbotConfigService(
|
|
createConfigRepository(configValues),
|
|
);
|
|
const dictService = {
|
|
getDictByKey: jest.fn(),
|
|
getDictItemsByKey: jest.fn(),
|
|
relationTree: jest.fn(),
|
|
} as unknown as DictService;
|
|
httpClient = {
|
|
requestBuffer: jest.fn(),
|
|
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
|
resolveRedirect: jest.fn(),
|
|
};
|
|
accountService = {
|
|
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
|
getBoundEventPluginKeys: jest.fn(),
|
|
unbindEventPlugin: jest.fn(),
|
|
};
|
|
sendService = {
|
|
sendText: jest.fn().mockResolvedValue({ messageId: 'msg-1' }),
|
|
};
|
|
bridge = new QqbotPluginHostBridgeService(
|
|
configService,
|
|
dictService,
|
|
httpClient as unknown as QqbotPluginHttpClientService,
|
|
accountService as unknown as QqbotAccountService,
|
|
sendService as unknown as QqbotSendService,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { force: true, recursive: true });
|
|
});
|
|
|
|
it('reads many package-owned config keys through the QQBot config service', async () => {
|
|
await expect(
|
|
bridge.handleHostCall(createDescriptor(), {
|
|
args: { keys: ['SAMPLE_TOKEN', 'SAMPLE_EMPTY', 'SAMPLE_MISSING'] },
|
|
method: 'getConfigMany',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
value: {
|
|
SAMPLE_EMPTY: undefined,
|
|
SAMPLE_MISSING: undefined,
|
|
SAMPLE_TOKEN: 'token-1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('preserves configured empty string values in config snapshots', async () => {
|
|
configValues.SAMPLE_EMPTY = '';
|
|
|
|
await expect(
|
|
bridge.handleHostCall(createDescriptor(), {
|
|
args: { keys: ['SAMPLE_EMPTY'] },
|
|
method: 'getConfigMany',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
value: {
|
|
SAMPLE_EMPTY: '',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects JSON reads that try to escape the package root', async () => {
|
|
await expect(
|
|
bridge.handleHostCall(createDescriptor(), {
|
|
args: { path: '../escape.json' },
|
|
method: 'readJsonFile',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({
|
|
message: 'Plugin host file path must stay inside the package root',
|
|
ok: false,
|
|
});
|
|
});
|
|
|
|
it('delegates requestJson host calls to the plugin HTTP client', async () => {
|
|
const options = {
|
|
context: 'sample call',
|
|
url: 'https://example.test/data',
|
|
};
|
|
|
|
await expect(
|
|
bridge.handleHostCall(createDescriptor(), {
|
|
args: { options },
|
|
method: 'requestJson',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({ ok: true, value: { ok: true } });
|
|
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
|
});
|
|
|
|
it('delegates resolveRedirect host calls to the plugin HTTP client', async () => {
|
|
const options = {
|
|
maxRedirects: 3,
|
|
timeoutMs: 1000,
|
|
url: 'https://b23.tv/abc123',
|
|
};
|
|
httpClient.resolveRedirect.mockResolvedValue({
|
|
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
|
});
|
|
|
|
await expect(
|
|
bridge.handleHostCall(createDescriptor(), {
|
|
args: { input: options },
|
|
method: 'resolveRedirect',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
value: {
|
|
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
|
},
|
|
});
|
|
expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options);
|
|
});
|
|
|
|
it('delegates event binding and text sends to core QQBot services', async () => {
|
|
const descriptor = createDescriptor();
|
|
const sendInput = {
|
|
message: 'hello',
|
|
selfId: '10001',
|
|
targetId: '20001',
|
|
targetType: 'group',
|
|
};
|
|
|
|
await expect(
|
|
bridge.handleHostCall(descriptor, {
|
|
args: { selfId: '10001', pluginKey: 'other-plugin' },
|
|
method: 'bindEventPlugin',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({ ok: true, value: true });
|
|
await expect(
|
|
bridge.handleHostCall(descriptor, {
|
|
args: { input: sendInput },
|
|
method: 'sendText',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({ ok: true, value: { messageId: 'msg-1' } });
|
|
|
|
expect(accountService.bindEventPlugin).toHaveBeenCalledWith(
|
|
'10001',
|
|
'sample',
|
|
);
|
|
expect(sendService.sendText).toHaveBeenCalledWith(sendInput);
|
|
});
|
|
|
|
it('uses the request plugin key when unbinding event plugins', async () => {
|
|
const descriptor = createDescriptor();
|
|
|
|
await bridge.handleHostCall(descriptor, {
|
|
args: { selfId: '10001', pluginKey: 'other-plugin' },
|
|
method: 'unbindEventPlugin',
|
|
pluginKey: 'sample',
|
|
});
|
|
|
|
expect(accountService.unbindEventPlugin).toHaveBeenCalledWith(
|
|
'10001',
|
|
'sample',
|
|
);
|
|
});
|
|
|
|
it('reads JSON files from descriptor package roots', async () => {
|
|
const descriptor = createDescriptor();
|
|
const filePath = join(tempRoot, 'data', 'sample.json');
|
|
mkdirSync(join(tempRoot, 'data'), { recursive: true });
|
|
writeFileSync(filePath, '{"enabled":true}\n');
|
|
|
|
await expect(
|
|
bridge.handleHostCall(descriptor, {
|
|
args: { path: 'data/sample.json' },
|
|
method: 'readJsonFile',
|
|
pluginKey: 'sample',
|
|
}),
|
|
).resolves.toEqual({ ok: true, value: { enabled: true } });
|
|
});
|
|
|
|
/**
|
|
* Creates a descriptor fixture rooted at this test's temporary package directory.
|
|
* @returns Descriptor used to constrain host bridge file-system access.
|
|
*/
|
|
function createDescriptor(): QqbotPluginPackageDescriptor {
|
|
return {
|
|
entry: 'src/index.ts',
|
|
entryFile: join(tempRoot, 'src', 'index.ts'),
|
|
manifest: {
|
|
key: 'sample',
|
|
pluginKey: 'sample',
|
|
name: 'Sample',
|
|
version: '1.0.0',
|
|
minApiSdkVersion: '1.0.0',
|
|
entry: 'src/index.ts',
|
|
runtime: {
|
|
configKeys: ['SAMPLE_TOKEN'],
|
|
maxConcurrency: 1,
|
|
memoryMb: 128,
|
|
timeoutMs: 5000,
|
|
workerType: 'thread',
|
|
},
|
|
operations: [],
|
|
events: [],
|
|
tasks: [],
|
|
assets: [],
|
|
configSchema: {},
|
|
legacyAliases: [],
|
|
migrations: [],
|
|
permissions: [],
|
|
},
|
|
packageRoot: tempRoot,
|
|
pluginKey: 'sample',
|
|
};
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Creates a minimal config repository that lets QqbotConfigService read fixture values through its real raw-value method.
|
|
* @param values - Config key/value fixture; absent keys behave like missing records.
|
|
* @returns Repository-shaped fixture used by QqbotConfigService in host bridge tests.
|
|
*/
|
|
function createConfigRepository(
|
|
values: Record<string, string | undefined>,
|
|
): ConstructorParameters<typeof QqbotConfigService>[0] {
|
|
/**
|
|
* Finds one QQBot config fixture using the TypeORM `where.configKey` query shape.
|
|
* @param query - TypeORM-style lookup object produced by QqbotConfigService.
|
|
* @returns Fixture record with the stored config value, or `null` when the key is not present.
|
|
*/
|
|
const findOne = async (query: { where?: { configKey?: string } }) => {
|
|
const configKey = query.where?.configKey;
|
|
if (
|
|
!configKey ||
|
|
!Object.prototype.hasOwnProperty.call(values, configKey)
|
|
) {
|
|
return null;
|
|
}
|
|
return { configValue: values[configKey] };
|
|
};
|
|
|
|
return {
|
|
findOne: jest.fn(findOne),
|
|
} as unknown as ConstructorParameters<typeof QqbotConfigService>[0];
|
|
}
|