Files
nj/apps/web/src/stores/plugin.test.ts
iven 8111471e93
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat: 添加管理端前端 (HMS 基座 React 管理面板)
- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript)
- 管理端自动代理 API 到 localhost:3000 (vite.config.ts)
- 更新 scripts/dev.sh 支持三端启动: backend/admin/app
- 登录验证通过, 用户管理/角色权限/审计日志等页面正常
- 添加 .gitignore 排除 node_modules/dist
2026-06-02 10:03:13 +08:00

604 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* plugin store 单元测试
*
* 覆盖 usePluginStore 的状态管理逻辑:
* - 初始状态值
* - fetchPlugins加载列表、并行 schema、去重
* - refreshMenuItems菜单生成、分组、各页面类型路由
* - schema 缓存
* - 错误处理schema 加载失败、API 失败)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ---------------------------------------------------------------------------
// Mock 外部依赖
// ---------------------------------------------------------------------------
const mockListPlugins = vi.fn();
const mockGetPluginSchema = vi.fn();
vi.mock('../api/plugins', () => ({
listPlugins: (...args: unknown[]) => mockListPlugins(...args),
getPluginSchema: (...args: unknown[]) => mockGetPluginSchema(...args),
}));
import { usePluginStore } from './plugin';
import type { PluginInfo, PluginSchemaResponse } from '../api/plugins';
// ---------------------------------------------------------------------------
// 测试辅助函数
// ---------------------------------------------------------------------------
function createFakePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
return {
id: 'plugin-001',
name: '测试插件',
version: '1.0.0',
description: '用于单元测试',
status: 'running',
config: {},
entities: [
{ name: 'customer', display_name: '客户', table_name: 'plugin_001_customer' },
],
record_version: 1,
...overrides,
};
}
function createFakeSchema(
overrides: Partial<PluginSchemaResponse> = {},
): PluginSchemaResponse {
return {
entities: [
{
name: 'customer',
display_name: '客户',
fields: [
{ name: 'id', field_type: 'uuid', required: true },
{ name: 'name', field_type: 'text', required: true, display_name: '名称' },
],
},
],
...overrides,
};
}
// ---------------------------------------------------------------------------
// 重置 fetchPluginsPromise模块级变量
// 因为 fetchPluginsPromise 是模块作用域变量,无法直接重置,
// 但在 fetchPlugins finally 块中会被设为 null。
// 只要每次 fetchPlugins 正常结束,就不会残留。
// ---------------------------------------------------------------------------
describe('usePluginStore', () => {
beforeEach(() => {
vi.clearAllMocks();
usePluginStore.setState({
plugins: [],
loading: false,
pluginMenuItems: [],
pluginMenuGroups: [],
schemaCache: {},
});
});
// =========================================================================
// 初始状态
// =========================================================================
describe('初始状态', () => {
it('应有正确的默认状态字段', () => {
const state = usePluginStore.getState();
expect(state).toHaveProperty('plugins');
expect(state).toHaveProperty('loading');
expect(state).toHaveProperty('pluginMenuItems');
expect(state).toHaveProperty('pluginMenuGroups');
expect(state).toHaveProperty('schemaCache');
expect(state).toHaveProperty('fetchPlugins');
expect(state).toHaveProperty('refreshMenuItems');
});
it('默认值应为空', () => {
const state = usePluginStore.getState();
expect(state.plugins).toEqual([]);
expect(state.loading).toBe(false);
expect(state.pluginMenuItems).toEqual([]);
expect(state.pluginMenuGroups).toEqual([]);
expect(state.schemaCache).toEqual({});
});
});
// =========================================================================
// fetchPlugins
// =========================================================================
describe('fetchPlugins', () => {
it('成功时应更新 plugins 和 loading 状态', async () => {
const fakePlugin = createFakePlugin();
mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] });
mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema());
await usePluginStore.getState().fetchPlugins();
const state = usePluginStore.getState();
expect(state.plugins).toEqual([fakePlugin]);
expect(state.loading).toBe(false);
expect(mockListPlugins).toHaveBeenCalledWith(1, 100, undefined);
});
it('加载中 loading 应为 true完成后恢复为 false', async () => {
let resolveList: (value: unknown) => void;
const listPromise = new Promise((resolve) => {
resolveList = resolve;
});
mockListPlugins.mockReturnValue(listPromise);
const fetchPromise = usePluginStore.getState().fetchPlugins();
expect(usePluginStore.getState().loading).toBe(true);
resolveList!({ data: [] });
await fetchPromise;
expect(usePluginStore.getState().loading).toBe(false);
});
it('应并行加载所有运行中/启用插件的 schema', async () => {
const plugin1 = createFakePlugin({ id: 'p1', status: 'running' });
const plugin2 = createFakePlugin({ id: 'p2', status: 'enabled' });
const plugin3 = createFakePlugin({ id: 'p3', status: 'disabled' });
mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2, plugin3] });
mockGetPluginSchema
.mockResolvedValueOnce(createFakeSchema())
.mockResolvedValueOnce(createFakeSchema());
await usePluginStore.getState().fetchPlugins();
// 只有 running 和 enabled 的插件加载了 schema
expect(mockGetPluginSchema).toHaveBeenCalledWith('p1');
expect(mockGetPluginSchema).toHaveBeenCalledWith('p2');
expect(mockGetPluginSchema).not.toHaveBeenCalledWith('p3');
});
it('schema 加载应写入 schemaCache', async () => {
const fakePlugin = createFakePlugin({ id: 'p1', status: 'running' });
const fakeSchema = createFakeSchema();
mockListPlugins.mockResolvedValueOnce({ data: [fakePlugin] });
mockGetPluginSchema.mockResolvedValueOnce(fakeSchema);
await usePluginStore.getState().fetchPlugins();
expect(usePluginStore.getState().schemaCache['p1']).toEqual(fakeSchema);
});
it('单个 schema 加载失败不应阻止其他 schema', async () => {
const plugin1 = createFakePlugin({ id: 'p1', status: 'running' });
const plugin2 = createFakePlugin({ id: 'p2', status: 'running' });
mockListPlugins.mockResolvedValueOnce({ data: [plugin1, plugin2] });
mockGetPluginSchema
.mockRejectedValueOnce(new Error('schema fail'))
.mockResolvedValueOnce(createFakeSchema());
await usePluginStore.getState().fetchPlugins();
// p1 失败不应出现在 cache 中
expect(usePluginStore.getState().schemaCache['p1']).toBeUndefined();
// p2 应该成功
expect(usePluginStore.getState().schemaCache['p2']).toBeDefined();
});
it('无运行中插件时不应调用 getPluginSchema', async () => {
mockListPlugins.mockResolvedValueOnce({ data: [] });
await usePluginStore.getState().fetchPlugins();
expect(mockGetPluginSchema).not.toHaveBeenCalled();
});
it('API 失败时应恢复 loading 为 false', async () => {
mockListPlugins.mockRejectedValueOnce(new Error('network error'));
// fetchPlugins 使用 try/finally不捕获异常错误会向上传播
await expect(usePluginStore.getState().fetchPlugins()).rejects.toThrow('network error');
expect(usePluginStore.getState().loading).toBe(false);
});
it('应传递 page 和 status 参数', async () => {
mockListPlugins.mockResolvedValueOnce({ data: [] });
await usePluginStore.getState().fetchPlugins(2, 'enabled' as any);
expect(mockListPlugins).toHaveBeenCalledWith(2, 100, 'enabled');
});
});
// =========================================================================
// refreshMenuItems — 无 schema 回退模式
// =========================================================================
describe('refreshMenuItems无 schema 回退)', () => {
it('无运行中插件时菜单应为空', () => {
usePluginStore.setState({
plugins: [createFakePlugin({ status: 'disabled' })],
});
usePluginStore.getState().refreshMenuItems();
expect(usePluginStore.getState().pluginMenuItems).toEqual([]);
});
it('无 schema 时应从 entities 生成回退菜单', () => {
const plugin = createFakePlugin({
id: 'p1',
status: 'running',
entities: [
{ name: 'customer', display_name: '客户', table_name: 't1' },
{ name: 'order', display_name: '订单', table_name: 't2' },
],
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: {},
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
key: '/plugins/p1/customer',
icon: 'AppstoreOutlined',
label: '客户',
pluginId: 'p1',
entity: 'customer',
pageType: 'crud',
});
expect(items[1]).toEqual({
key: '/plugins/p1/order',
icon: 'AppstoreOutlined',
label: '订单',
pluginId: 'p1',
entity: 'order',
pageType: 'crud',
});
});
it('entity 无 display_name 时应使用 name 作为 label', () => {
const plugin = createFakePlugin({
id: 'p2',
status: 'enabled',
entities: [
{ name: 'product', display_name: '', table_name: 't3' },
],
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: {},
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items[0].label).toBe('product');
});
});
// =========================================================================
// refreshMenuItems — 有 schema 的各页面类型
// =========================================================================
describe('refreshMenuItems有 schema 页面类型)', () => {
it('crud 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{ type: 'crud' as const, entity: 'customer', label: '客户管理', icon: 'UserOutlined' },
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
key: '/plugins/p1/customer',
icon: 'UserOutlined',
label: '客户管理',
pluginId: 'p1',
entity: 'customer',
pageType: 'crud',
});
});
it('tree 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{
type: 'tree' as const,
entity: 'department',
label: '部门树',
id_field: 'id',
parent_field: 'parent_id',
label_field: 'name',
},
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
key: '/plugins/p1/tree/department',
icon: 'ApartmentOutlined',
label: '部门树',
pluginId: 'p1',
entity: 'department',
pageType: 'tree',
});
});
it('tabs 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{ type: 'tabs' as const, label: '综合视图', icon: 'LayoutOutlined', tabs: [] },
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0].pageType).toBe('tabs');
expect(items[0].key).toContain('/plugins/p1/tabs/');
expect(items[0].key).toContain(encodeURIComponent('综合视图'));
});
it('graph 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{
type: 'graph' as const,
entity: 'relation',
label: '关系图',
relationship_entity: 'rel',
source_field: 'src',
target_field: 'tgt',
edge_label_field: 'label',
node_label_field: 'name',
},
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
key: '/plugins/p1/graph/relation',
icon: 'ApartmentOutlined',
label: '关系图',
pluginId: 'p1',
entity: 'relation',
pageType: 'graph',
});
});
it('dashboard 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{ type: 'dashboard' as const, label: '仪表盘', widgets: [] },
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
key: '/plugins/p1/dashboard',
icon: 'DashboardOutlined',
label: '仪表盘',
pluginId: 'p1',
pageType: 'dashboard',
});
});
it('kanban 类型应生成正确的菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{
type: 'kanban' as const,
entity: 'task',
label: '看板',
lane_field: 'status',
card_title_field: 'title',
},
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
const items = usePluginStore.getState().pluginMenuItems;
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
key: '/plugins/p1/kanban/task',
icon: 'UnorderedListOutlined',
label: '看板',
pluginId: 'p1',
entity: 'task',
pageType: 'kanban',
});
});
it('detail 类型不应生成菜单项', () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
const schema = createFakeSchema({
ui: {
pages: [
{
type: 'detail' as const,
entity: 'customer',
label: '客户详情',
sections: [{ type: 'fields', label: '基本信息', fields: ['name'] }],
},
],
},
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: { p1: schema },
});
usePluginStore.getState().refreshMenuItems();
expect(usePluginStore.getState().pluginMenuItems).toEqual([]);
});
});
// =========================================================================
// pluginMenuGroups 分组
// =========================================================================
describe('pluginMenuGroups 分组', () => {
it('应按 pluginId 正确分组', () => {
const plugin1 = createFakePlugin({
id: 'p1',
name: '插件一',
status: 'running',
entities: [
{ name: 'a', display_name: '实体A', table_name: 't1' },
{ name: 'b', display_name: '实体B', table_name: 't2' },
],
});
const plugin2 = createFakePlugin({
id: 'p2',
name: '插件二',
status: 'enabled',
entities: [
{ name: 'c', display_name: '实体C', table_name: 't3' },
],
});
usePluginStore.setState({
plugins: [plugin1, plugin2],
schemaCache: {},
});
usePluginStore.getState().refreshMenuItems();
const groups = usePluginStore.getState().pluginMenuGroups;
expect(groups).toHaveLength(2);
const g1 = groups.find((g) => g.pluginId === 'p1');
expect(g1).toBeDefined();
expect(g1!.pluginName).toBe('插件一');
expect(g1!.items).toHaveLength(2);
const g2 = groups.find((g) => g.pluginId === 'p2');
expect(g2).toBeDefined();
expect(g2!.pluginName).toBe('插件二');
expect(g2!.items).toHaveLength(1);
});
it('无运行中插件时 groups 应为空', () => {
usePluginStore.setState({
plugins: [createFakePlugin({ status: 'disabled' })],
});
usePluginStore.getState().refreshMenuItems();
expect(usePluginStore.getState().pluginMenuGroups).toEqual([]);
});
it('pluginName 应从 plugins 列表查找', () => {
const plugin = createFakePlugin({
id: 'px',
name: '名称查找插件',
status: 'running',
entities: [
{ name: 'x', display_name: 'X', table_name: 't1' },
],
});
usePluginStore.setState({
plugins: [plugin],
schemaCache: {},
});
usePluginStore.getState().refreshMenuItems();
const groups = usePluginStore.getState().pluginMenuGroups;
expect(groups[0].pluginName).toBe('名称查找插件');
});
});
// =========================================================================
// 集成fetchPlugins 后菜单自动生成
// =========================================================================
describe('fetchPlugins 集成', () => {
it('fetchPlugins 完成后应自动生成菜单', async () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
mockListPlugins.mockResolvedValueOnce({ data: [plugin] });
mockGetPluginSchema.mockResolvedValueOnce(createFakeSchema());
await usePluginStore.getState().fetchPlugins();
// 应该有菜单项(来自回退或 schema
const items = usePluginStore.getState().pluginMenuItems;
expect(items.length).toBeGreaterThan(0);
});
it('多次 fetchPlugins 不应导致菜单重复', async () => {
const plugin = createFakePlugin({ id: 'p1', status: 'running' });
mockListPlugins.mockResolvedValue({ data: [plugin] });
mockGetPluginSchema.mockResolvedValue(createFakeSchema());
await usePluginStore.getState().fetchPlugins();
await usePluginStore.getState().fetchPlugins();
// 每次都是完全替换,不会累积
const items = usePluginStore.getState().pluginMenuItems;
const keys = items.map((i) => i.key);
const uniqueKeys = [...new Set(keys)];
expect(keys.length).toBe(uniqueKeys.length);
});
});
});