kt-template-online-api/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts

135 lines
3.6 KiB
TypeScript

import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join, relative } from 'path';
const repoRoot = join(__dirname, '../../../..');
const pluginRoot = join(repoRoot, 'src/modules/qqbot/plugins');
const collectTsFiles = (root: string): string[] => {
if (!existsSync(root)) return [];
return readdirSync(root).flatMap((name) => {
const filePath = join(root, name);
const stat = statSync(filePath);
if (stat.isDirectory()) return collectTsFiles(filePath);
return filePath.endsWith('.ts') ? [filePath] : [];
});
};
const toRepoPath = (filePath: string) =>
relative(repoRoot, filePath).replace(/\\/g, '/');
const requiredPluginPaths = [
'plugin.json',
'src/index.ts',
'src/operations',
'src/events',
'src/domain',
'src/application',
'src/infrastructure/integration',
'src/infrastructure/storage',
'src/config',
'src/assets',
'src/migrations',
'src/tests',
];
describe('QQBot plugin package boundary', () => {
it('uses only approved built-in plugin package keys as directory names', () => {
const pluginDirs = readdirSync(pluginRoot)
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
.sort();
expect(pluginDirs).toEqual([
'bangdream',
'ff14-market',
'fflogs',
'repeater',
]);
});
it('uses the same package shape for every built-in plugin', () => {
const missing = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].flatMap(
(pluginKey) =>
requiredPluginPaths
.map((pathName) => `${pluginKey}/${pathName}`)
.filter((pathName) => !existsSync(join(pluginRoot, pathName))),
);
expect(missing).toEqual([]);
});
it('keeps plugin runtime source independent from host internals and direct IO', () => {
const banned = [
{
name: '@nestjs',
pattern: /@nestjs\//,
},
{
name: 'Admin module import',
pattern: /@\/modules\/admin\//,
},
{
name: 'QQBot core import',
pattern: /@\/modules\/qqbot\/core\//,
},
{
name: 'ConfigService',
pattern: /\bConfigService\b/,
},
{
name: 'DictService',
pattern: /\bDictService\b/,
},
{
name: 'process.env',
pattern: /process\.env/,
},
{
name: 'direct axios',
pattern: /\baxios\b/,
},
{
name: 'direct fetch',
pattern: /\bfetch\(/,
},
{
name: 'direct fs',
pattern: /from ['"](?:node:)?fs['"]|require\(['"](?:node:)?fs['"]\)/,
},
{
name: 'import-time timer',
pattern: /\bset(?:Interval|Timeout)\(/,
},
];
const violations = collectTsFiles(pluginRoot).flatMap((filePath) => {
const source = readFileSync(filePath, 'utf8');
return banned
.filter(({ pattern }) => pattern.test(source))
.map(({ name }) => `${toRepoPath(filePath)} :: ${name}`);
});
expect(violations).toEqual([]);
});
it('uses plugin.json as the only operation metadata source', () => {
const duplicateMetadataFiles = collectTsFiles(pluginRoot)
.filter((filePath) => {
const file = toRepoPath(filePath);
if (file.endsWith('/src/index.ts')) return false;
const source = readFileSync(filePath, 'utf8');
return (
/operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test(
file,
) ||
/operation-registry|COMMAND_DEFINITIONS|OperationDefinition/.test(
source,
)
);
})
.map(toRepoPath);
expect(duplicateMetadataFiles).toEqual([]);
});
});