@nocobase/flow-engine
Version:
A standalone flow engine for NocoBase, managing workflows, models, and actions.
263 lines (201 loc) • 9.06 kB
text/typescript
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, expect, it, vi } from 'vitest';
import { FlowEngine } from '../../flowEngine';
import { BaseRecordResource } from '../baseRecordResource';
import { ResourceError } from '../flowResource';
// 定义可实例化的最小子类
class TestRecordResource<T = any> extends BaseRecordResource<T> {
async refresh(): Promise<void> {}
}
function createTestRecordResource<T = any>() {
const engine = new FlowEngine();
return engine.createResource(TestRecordResource);
}
describe('BaseRecordResource - basic properties', () => {
it('supportsFilter should be true', () => {
const r = createTestRecordResource();
expect(r.supportsFilter).toBe(true);
});
it('set/get resourceName & sourceId & dataSourceKey', () => {
const r = createTestRecordResource();
r.setResourceName('users');
expect(r.getResourceName()).toBe('users');
r.setSourceId(123);
expect(r.getSourceId()).toBe(123);
r.setDataSourceKey('ds1');
expect(r.getDataSourceKey()).toBe('ds1');
});
});
describe('BaseRecordResource - filters', () => {
it('add/remove filter groups and resetFilter should update params.filter (JSON)', () => {
const r = createTestRecordResource();
// initially, no filter -> resetFilter leads to undefined value stored
r.resetFilter();
expect(r.getRequestParameter('filter')).toBeUndefined();
// add groups
r.addFilterGroup('g1', { status: { $eq: 'active' } });
r.addFilterGroup('g2', { age: { $gt: 18 } });
const filterStr = r.getRequestParameter('filter');
expect(typeof filterStr === 'string' || filterStr === undefined).toBeTruthy();
const parsed = JSON.parse(filterStr as string);
expect(parsed).toEqual({ $and: [{ status: { $eq: 'active' } }, { age: { $gt: 18 } }] });
// remove one group
r.removeFilterGroup('g1');
const filterStr2 = r.getRequestParameter('filter');
const parsed2 = JSON.parse(filterStr2 as string);
expect(parsed2).toEqual({ $and: [{ age: { $gt: 18 } }] });
// remove last group -> filter becomes undefined
r.removeFilterGroup('g2');
expect(r.getRequestParameter('filter')).toBeUndefined();
});
it('setFilterByTk / getFilterByTk', () => {
const r = createTestRecordResource();
r.setFilterByTk(1);
expect(r.getFilterByTk()).toBe(1);
});
});
describe('BaseRecordResource - field and list params', () => {
it('fields, sort, except, whitelist, blacklist support string or array with split', () => {
const r = createTestRecordResource();
r.setFields('id,name');
expect(r.getFields()).toEqual(['id', 'name']);
r.setSort(['-createdAt', 'name']);
expect(r.getSort()).toEqual(['-createdAt', 'name']);
r.setExcept('password,secret');
expect(r.getExcept()).toEqual(['password', 'secret']);
r.setWhitelist('id,name');
expect(r.getWhitelist()).toEqual(['id', 'name']);
r.setBlacklist(['secret', 'token']);
expect(r.getBlacklist()).toEqual(['secret', 'token']);
});
});
describe('BaseRecordResource - appends helpers', () => {
it('setAppends / getAppends', () => {
const r = createTestRecordResource();
r.setAppends(['a', 'b']);
expect(r.getAppends()).toEqual(['a', 'b']);
});
it('addAppends merges and dedups; removeAppends removes items', () => {
const r = createTestRecordResource();
r.addAppends('a,b');
expect(r.getAppends()).toEqual(['a', 'b']);
r.addAppends(['b', 'c']);
expect(r.getAppends()).toEqual(['a', 'b', 'c']);
r.removeAppends('b');
expect(r.getAppends()).toEqual(['a', 'c']);
r.removeAppends(['a', 'c']);
expect(r.getAppends()).toEqual([]);
});
});
describe('BaseRecordResource - runAction and URL building', () => {
it('runAction builds URL from resourceName and action; merges configs', async () => {
const r = createTestRecordResource();
const api = { request: vi.fn().mockResolvedValue({ data: { data: { ok: 1 }, meta: { total: 10 } } }) };
r.setAPIClient(api as any);
r.setResourceName('users');
r.setDataSourceKey('dsA');
r.setRunActionOptions('custom', { headers: { 'X-From-Option': 'A' }, timeout: 1000 });
const result = await r.runAction('custom', { headers: { 'X-From-Option': 'B' }, params: { q: 1 } });
expect(result).toEqual({ data: { ok: 1 }, meta: { total: 10 } });
expect(api.request).toHaveBeenCalledTimes(1);
const cfg = api.request.mock.calls[0][0];
expect(cfg.method).toBe('post');
expect(cfg.url).toBe('users:custom');
expect(cfg.headers['X-From-Option']).toBe('B');
expect(cfg.headers['X-Data-Source']).toBe('dsA');
expect(cfg.timeout).toBe(1000);
expect(cfg.params.q).toBe(1);
});
it('nested resourceName with sourceId builds parent/{id}/child:action', async () => {
const r = createTestRecordResource<any>();
const api = { request: vi.fn().mockResolvedValue({ data: { data: { ok: 1 } } }) };
r.setAPIClient(api as any);
r.setResourceName('users.tags').setSourceId(5);
await r.runAction('list', {});
const cfg = api.request.mock.calls[0][0];
expect(cfg.url).toBe('users/5/tags:list');
});
it('when response has no "data" wrapper, returns raw response', async () => {
const r = createTestRecordResource<any>();
const api = { request: vi.fn().mockResolvedValue({ data: { ok: 1 } }) };
r.setAPIClient(api as any);
r.setResourceName('users');
const ret = await r.runAction('ping', {});
expect(ret).toEqual({ ok: 1 });
});
});
describe('BaseRecordResource - updateAssociationValues', () => {
it('getUpdateAssociationValues returns [] by default; addUpdateAssociationValues merges and dedups', () => {
const r = createTestRecordResource();
expect(r.getUpdateAssociationValues()).toEqual([]);
r.addUpdateAssociationValues('roles,groups');
expect(r.getUpdateAssociationValues()).toEqual(['roles', 'groups']);
r.addUpdateAssociationValues(['groups', 'tags']);
expect(r.getUpdateAssociationValues()).toEqual(['roles', 'groups', 'tags']);
});
it('create-like actions include updateAssociationValues in request params', async () => {
const r = createTestRecordResource<any>();
const api = { request: vi.fn().mockResolvedValue({ data: { data: {} } }) };
r.setAPIClient(api as any);
r.setResourceName('users');
r.addUpdateAssociationValues(['roles', 'groups']);
await r.runAction('create', { params: { q: 1 } });
const cfg = api.request.mock.calls[0][0];
expect(cfg.params).toMatchObject({ q: 1, updateAssociationValues: ['roles', 'groups'] });
});
});
describe('BaseRecordResource - runAction error', () => {
it('throws ResourceError on API failure', async () => {
const r = createTestRecordResource<any>();
const api = { request: vi.fn().mockRejectedValue({ response: { data: { error: { message: 'boom' } } } }) };
r.setAPIClient(api as any).setResourceName('users');
await expect(r.runAction('create', {})).rejects.toBeInstanceOf(ResourceError);
});
});
describe('BaseRecordResource - mergeRequestConfig', () => {
it('deep merges params; later overrides earlier for scalars', () => {
const r = createTestRecordResource();
const merged = r.mergeRequestConfig(
{ params: { a: 'a', x: 1, nested: { p: 1 } } },
{ params: { b: 'b', x: 2, nested: { q: 2 } } },
);
expect(merged.params).toEqual({ a: 'a', b: 'b', x: 2, nested: { p: 1, q: 2 } });
});
it('deep merges data; arrays are replaced by later config', () => {
const r = createTestRecordResource();
const merged = r.mergeRequestConfig(
{ data: { list: [1, 2], obj: { a: 1 } } as any },
{ data: { list: [3], obj: { b: 2 } } as any },
);
expect(merged.data).toEqual({ list: [3], obj: { a: 1, b: 2 } });
});
it('skips undefined values when merging params/data', () => {
const r = createTestRecordResource();
const merged = r.mergeRequestConfig(
{ params: { a: 1, b: 2 }, data: { x: 1, y: 2 } as any },
{ params: { a: undefined, c: 3 }, data: { x: undefined, z: 9 } as any },
);
expect(merged.params).toEqual({ a: 1, b: 2, c: 3 });
expect(merged.data).toEqual({ x: 1, y: 2, z: 9 });
});
it('params.obj.nested should be replaced (not merged)', () => {
const r = createTestRecordResource();
const merged = r.mergeRequestConfig(
{ params: { obj: { nested: { a: 1 }, arr: [1, 2], keep: { x: 1 } } } },
{ params: { obj: { nested: { b: 2 } }, arr: [3, 4] } },
);
// nested 被后者整体覆盖,仅保留 { b: 2 }
expect(merged.params.obj.nested).toEqual({ b: 2 });
// 数组被后者替换
expect(merged.params.arr).toEqual([3, 4]);
// 其它键不受影响
expect(merged.params.obj.keep).toEqual({ x: 1 });
});
});