import { createHash } from 'crypto'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import * as request from 'supertest'; import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; import { QqbotPluginPlatformController } from '../../../../src/modules/qqbot/plugin-platform/contract/plugin-platform.controller'; import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module'; import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; import { QqbotPluginPackageReaderService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-reader.service'; import { QQBOT_PLUGIN_PLATFORM_ENTITIES, QqbotPlugin, QqbotPluginAccountBinding, QqbotPluginAsset, QqbotPluginConfig, QqbotPluginEventHandler, QqbotPluginInstallation, QqbotPluginOperation, QqbotPluginRuntimeEvent, QqbotPluginVersion, } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/persistence'; import { collectControllerRoutes, routeKey, } from '../../../helpers/controller-route.helper'; const createRepositoryMock = () => ({ find: jest.fn(async () => []), findAndCount: jest.fn(async () => [[], 0]), save: jest.fn(async (value) => value), update: jest.fn(async () => ({ affected: 1 })), }); const createManifest = () => ({ assets: [], configSchema: { type: 'object', }, entry: 'src/index.ts', events: [], minApiSdkVersion: '1.0.0', name: 'Demo Plugin', operations: [ { handlerName: 'echo', key: 'demo-plugin.echo', name: 'Echo', permissions: ['qqbot.send'], timeoutMs: 3000, }, ], permissions: ['qqbot.send'], pluginKey: 'demo-plugin', runtime: { maxConcurrency: 1, memoryMb: 128, timeoutMs: 5000, workerType: 'node-worker', }, version: '0.1.0', }); const stableStringify = (value: unknown): string => { if (Array.isArray(value)) { return `[${value.map((item) => stableStringify(item)).join(',')}]`; } if (value && typeof value === 'object') { const record = value as Record; return `{${Object.keys(record) .filter((key) => record[key] !== undefined) .sort() .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) .join(',')}}`; } return JSON.stringify(value); }; const sha256 = (content: Buffer | string) => createHash('sha256').update(content).digest('hex'); const packageRoot = join( process.cwd(), '.kt-workspace', 'qqbot-plugin-packages', 'api-contract', ); const writePluginPackage = (manifest: ReturnType) => { mkdirSync(packageRoot, { recursive: true }); const packageBody = { contentHash: '', files: [], manifest, }; packageBody.contentHash = sha256( stableStringify({ files: packageBody.files, manifest: packageBody.manifest, }), ); const packagePath = join( packageRoot, `${manifest.pluginKey}-${manifest.version}.qqbot-plugin.json`, ); writeFileSync(packagePath, `${JSON.stringify(packageBody, null, 2)}\n`); return { packageHash: packageBody.contentHash, packagePath, }; }; describe('QQBot plugin platform API contract', () => { let app: INestApplication; let repositoryMocks: Map>; beforeEach(async () => { repositoryMocks = new Map(); const moduleRef = await Test.createTestingModule({ controllers: [QqbotPluginPlatformController], providers: [ QqbotPluginPlatformService, QqbotPluginPackageReaderService, ...[ QqbotPlugin, QqbotPluginVersion, QqbotPluginInstallation, QqbotPluginOperation, QqbotPluginEventHandler, QqbotPluginAccountBinding, QqbotPluginConfig, QqbotPluginAsset, QqbotPluginRuntimeEvent, ].map((entity) => ({ provide: getRepositoryToken(entity), useFactory: () => { const repository = createRepositoryMock(); repositoryMocks.set(entity, repository); return repository; }, })), ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true, }) .compile(); app = moduleRef.createNestApplication(); await app.init(); }); afterEach(async () => { await app.close(); rmSync(packageRoot, { force: true, recursive: true }); }); it('registers plugin-platform as a first-class AppModule import', () => { const source = readFileSync( join(__dirname, '../../../../src/app.module.ts'), 'utf8', ); expect(source).toContain('QqbotPluginPlatformModule'); }); it('loads plural entity files into the TypeORM datasource', () => { const source = readFileSync( join(__dirname, '../../../../src/app.module.ts'), 'utf8', ); expect(source).toContain('*.entities{.ts,.js}'); }); it('exposes plugin-platform management routes', () => { expect(routeKey).toBeDefined(); expect( collectControllerRoutes([QqbotPluginPlatformController]).map(routeKey), ).toEqual( expect.arrayContaining([ 'GET /qqbot/plugin-platform/installations', 'POST /qqbot/plugin-platform/upload', 'POST /qqbot/plugin-platform/validate', 'POST /qqbot/plugin-platform/install', 'POST /qqbot/plugin-platform/install-local', 'POST /qqbot/plugin-platform/enable', 'POST /qqbot/plugin-platform/disable', 'POST /qqbot/plugin-platform/upgrade', 'POST /qqbot/plugin-platform/uninstall', 'POST /qqbot/plugin-platform/config', 'GET /qqbot/plugin-platform/runtime-events', 'GET /qqbot/plugin-platform/account-bindings', 'GET /qqbot/plugin-platform/capabilities', 'GET /qqbot/plugin-platform/operations/list', 'GET /qqbot/plugin-platform/operations/page', 'GET /qqbot/plugin-platform/event-handlers', ]), ); }); it('validates manifests through the real HTTP wrapper', async () => { const response = await request(app.getHttpServer()) .post('/qqbot/plugin-platform/validate') .send({ manifest: createManifest(), }) .expect(200); expect(response.body).toMatchObject({ code: 200, data: { manifest: { pluginKey: 'demo-plugin', runtime: { timeoutMs: 5000, }, }, valid: true, }, }); }); it('validates controlled plugin packages during upload and install', async () => { const manifest = createManifest(); const { packageHash, packagePath } = writePluginPackage(manifest); const uploadResponse = await request(app.getHttpServer()) .post('/qqbot/plugin-platform/upload') .send({ packagePath }) .expect(200); expect(uploadResponse.body).toMatchObject({ code: 200, data: { manifest: { pluginKey: manifest.pluginKey, }, packageHash, packagePath, valid: true, }, }); await request(app.getHttpServer()) .post('/qqbot/plugin-platform/install-local') .send({ packageHash, packagePath }) .expect(200); expect(repositoryMocks.get(QqbotPluginVersion)?.save).toHaveBeenCalledWith( expect.objectContaining({ packageHash, }), ); expect( repositoryMocks.get(QqbotPluginInstallation)?.save, ).toHaveBeenCalledWith( expect.objectContaining({ installedPath: packagePath, }), ); await request(app.getHttpServer()) .post('/qqbot/plugin-platform/install-local') .send({ packageHash: 'bad-hash', packagePath }) .expect(400); await request(app.getHttpServer()) .post('/qqbot/plugin-platform/install-local') .send({ packagePath: join(process.cwd(), 'package.json') }) .expect(400); }); it('keeps TypeORM entity registration aligned with the persistence contract', () => { expect(QqbotPluginPlatformModule).toBeDefined(); expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(11); }); it('passes runtime-event filters to persistence', async () => { await request(app.getHttpServer()) .get('/qqbot/plugin-platform/runtime-events') .query({ eventType: 'worker-crash', installationId: '2002', level: 'error', pluginId: '1001', startTime: '2026-06-15 00:00:00', endTime: '2026-06-15 23:59:59', }) .expect(200); expect( repositoryMocks.get(QqbotPluginRuntimeEvent)?.find, ).toHaveBeenCalledWith({ where: { eventType: 'worker-crash', installationId: '2002', level: 'error', pluginId: '1001', createTime: expect.any(Object), }, }); }); it('serves platform operation pages from active runtime summaries when persistence rows are empty', async () => { const manifest = createManifest(); const installation = { id: 'install-demo', installedPath: 'D:/plugins/demo', pluginId: 'plugin-demo', runtimeStatus: 'stopped', status: 'installed', versionId: 'version-demo', }; const version = { id: 'version-demo', manifestJson: manifest, packageHash: 'hash', pluginId: installation.pluginId, version: manifest.version, }; const createRepository = (findOneValue?: unknown) => ({ find: jest.fn(async () => []), findAndCount: jest.fn(async () => [[], 0]), findOne: jest.fn(async () => findOneValue || null), save: jest.fn(async (value) => value), update: jest.fn(async () => ({ affected: 1 })), }); const operationRepository = createRepository(); const worker = { activate: jest.fn(async () => ({ ok: true })), deactivate: jest.fn(async () => ({ ok: true })), dispose: jest.fn(async () => undefined), executeOperation: jest.fn(), handleEvent: jest.fn(), health: jest.fn(async () => ({ ok: true })), load: jest.fn(async () => ({ ok: true })), }; const runtimeFactory = { create: jest.fn(() => worker), }; const service = new (QqbotPluginPlatformService as any)( createRepository({ id: installation.pluginId, pluginKey: manifest.pluginKey, }), createRepository(version), createRepository(installation), operationRepository, createRepository(), createRepository(), createRepository(), createRepository(), createRepository(), undefined, runtimeFactory, ) as QqbotPluginPlatformService; await service.enableInstallation({ id: installation.id }); const page = await service.pageOperations({ pageNo: 1, pageSize: 10, triggerMode: 'command', } as any); expect(operationRepository.findAndCount).not.toHaveBeenCalled(); expect(page).toMatchObject({ pageNo: 1, pageSize: 10, total: 1, }); expect(page.list).toEqual([ expect.objectContaining({ key: 'demo-plugin.echo', pluginKey: 'demo-plugin', triggerMode: 'command', }), ]); }); it('keeps compatible plugin operation routes delegated to platform service ownership', () => { const source = readFileSync( join( __dirname, '../../../../src/modules/qqbot/plugin-platform/contract/qqbot-plugin.controller.ts', ), 'utf8', ); const operationRoutes = source.slice( source.indexOf("@Get('operation/list')"), source.indexOf("@Get('health')"), ); expect(operationRoutes).toContain('this.service.'); expect(operationRoutes).not.toContain('this.pluginRegistry'); expect(operationRoutes).not.toContain('this.eventPluginRegistry'); expect(source).not.toContain('private listOperations('); }); });