文件
OmniCard/backend/test/modules/card/locale.test.ts
wikiboss 6c3d5d323a refactor(arch): 落地平台架构演进第一二阶段整改,消除OOM与越权风险并支持高并发缓冲与AI多租户BYOK
- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId
- [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患
- [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建
- [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata
- [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘
- [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动
- [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范
- [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
2026-09-21 11:14:10 +08:00

410 行
13 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { getMetadataArgsStorage } from 'typeorm';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CardLocaleEntity } from '../../../src/modules/card/entity/locale';
import { CardLocaleService } from '../../../src/modules/card/service/locale';
import {
AdminCardLocaleController,
adminCardLocaleCurdOption,
} from '../../../src/modules/card/controller/admin/locale';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
return apis.map(api => (typeof api === 'string' ? api : api.method));
}
interface LocaleFindOptions {
where?: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>;
order?: Partial<Record<'id' | 'locale', 'ASC' | 'DESC' | 'asc' | 'desc'>>;
}
/**
* 内存 Repository,覆盖 upsert / listByCardId / getByCardIdAndLocale
*/
class MemoryLocaleRepo {
private data: CardLocaleEntity[] = [];
private currentId = 1;
constructor(initialData: Array<Partial<CardLocaleEntity>> = []) {
this.data = initialData.map(item => this.createEntity(item));
if (this.data.length > 0) {
this.currentId = Math.max(...this.data.map(d => Number(d.id) || 0)) + 1;
}
}
private createEntity(item: Partial<CardLocaleEntity>): CardLocaleEntity {
const entity = new CardLocaleEntity();
Object.assign(entity, {
id: item.id || this.currentId++,
createTime: item.createTime || new Date(),
updateTime: new Date(),
...item,
});
return entity;
}
async find(options?: LocaleFindOptions): Promise<CardLocaleEntity[]> {
let result = [...this.data];
if (options?.where) {
const where = options.where;
result = result.filter(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
}
if (options?.order) {
const orderKeys = Object.keys(options.order) as Array<
keyof NonNullable<LocaleFindOptions['order']>
>;
result.sort((a, b) => {
for (const key of orderKeys) {
const dir = (options.order?.[key] || 'ASC').toUpperCase();
if (a[key] < b[key]) return dir === 'ASC' ? -1 : 1;
if (a[key] > b[key]) return dir === 'ASC' ? 1 : -1;
}
return 0;
});
}
return result.map(item => ({ ...item } as CardLocaleEntity));
}
async findOneBy(
where: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>
): Promise<CardLocaleEntity | null> {
const found = this.data.find(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
return found ? ({ ...found } as CardLocaleEntity) : null;
}
async save(
entityOrEntities:
| Partial<CardLocaleEntity>
| Array<Partial<CardLocaleEntity>>
): Promise<CardLocaleEntity | CardLocaleEntity[]> {
if (Array.isArray(entityOrEntities)) {
const savedList: CardLocaleEntity[] = [];
for (const item of entityOrEntities) {
savedList.push((await this.save(item)) as CardLocaleEntity);
}
return savedList;
}
const item = entityOrEntities;
if (item.id) {
const index = this.data.findIndex(d => d.id === item.id);
if (index !== -1) {
this.data[index] = {
...this.data[index],
...item,
updateTime: new Date(),
} as CardLocaleEntity;
return { ...this.data[index] } as CardLocaleEntity;
}
}
const newEntity = this.createEntity(item);
this.data.push(newEntity);
return { ...newEntity } as CardLocaleEntity;
}
getSnapshot(): CardLocaleEntity[] {
return [...this.data];
}
}
function createService(
seed: Array<Partial<CardLocaleEntity>> = []
): CardLocaleService {
const service = new CardLocaleService();
service.cardLocaleEntity = new MemoryLocaleRepo(
seed
) as unknown as CardLocaleService['cardLocaleEntity'];
return service;
}
function seedLocales(): Array<Partial<CardLocaleEntity>> {
return [
{
id: 1,
cardId: 100,
locale: 'zh-CN',
name: '张伟',
title: '全球商务总监',
company: 'OmniCard',
slogan: '连接全球商务',
},
{
id: 2,
cardId: 100,
locale: 'en-US',
name: 'Alex Zhang',
title: 'Global BD Director',
company: 'OmniCard',
slogan: 'Connecting Global Business',
},
{
id: 3,
cardId: 200,
locale: 'zh-CN',
name: '李娜',
title: '产品经理',
},
];
}
describe('T17 名片内容级多语言分支 (card_locale)', () => {
describe('1. 实体 CardLocaleEntity 表结构与索引', () => {
const metadata = getMetadataArgsStorage();
it('应继承 comm/entity/base BaseEntity,表名为 card_locale', () => {
const entity = new CardLocaleEntity();
expect(entity).toBeInstanceOf(BaseEntity);
const table = metadata.tables.find(t => t.target === CardLocaleEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('card_locale');
});
it('cardId / locale 必须建索引,locale 为 varchar(16)', () => {
const columns = metadata.columns.filter(
c => c.target === CardLocaleEntity
);
const cardIdCol = columns.find(c => c.propertyName === 'cardId');
const localeCol = columns.find(c => c.propertyName === 'locale');
expect(cardIdCol?.options.name || cardIdCol?.propertyName).toBe(
'card_id'
);
expect(localeCol?.options.length).toBe(16);
const indexes = metadata.indices.filter(
i => i.target === CardLocaleEntity
);
const hasCardIdIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('cardId')
);
const hasLocaleIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('locale')
);
expect(hasCardIdIndex).toBe(true);
expect(hasLocaleIndex).toBe(true);
});
it('name / title / company / slogan 必须可空', () => {
const columns = metadata.columns.filter(
c => c.target === CardLocaleEntity
);
for (const prop of ['name', 'title', 'company', 'slogan'] as const) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.nullable).toBe(true);
}
});
});
describe('2. Service upsert / listByCardId / getByCardIdAndLocale', () => {
it('upsert 同卡同语言应覆盖而非新增重复行', async () => {
const service = createService(seedLocales());
const first = await service.upsert(100, 'ar-SA', {
name: 'أليكس تشانغ',
title: 'مدير تطوير الأعمال',
company: 'OmniCard',
slogan: 'ربط الأعمال العالمية',
});
expect(first.id).toBeDefined();
expect(first.cardId).toBe(100);
expect(first.locale).toBe('ar-SA');
expect(first.name).toBe('أليكس تشانغ');
const overwritten = await service.upsert(100, 'ar-SA', {
name: 'أليكس',
slogan: 'نسخة محدثة',
});
expect(overwritten.id).toBe(first.id);
expect(overwritten.name).toBe('أليكس');
expect(overwritten.slogan).toBe('نسخة محدثة');
expect(overwritten.title).toBe('مدير تطوير الأعمال');
expect(overwritten.company).toBe('OmniCard');
const listed = await service.listByCardId(100);
const arRows = listed.filter(row => row.locale === 'ar-SA');
expect(arRows).toHaveLength(1);
});
it('listByCardId 应按 cardId 返回多语种分支且不串卡', async () => {
const service = createService(seedLocales());
const list = await service.listByCardId(100);
expect(list).toHaveLength(2);
expect(list.map(row => row.locale).sort()).toEqual(['en-US', 'zh-CN']);
expect(list.every(row => row.cardId === 100)).toBe(true);
const other = await service.listByCardId(200);
expect(other).toHaveLength(1);
expect(other[0].name).toBe('李娜');
const empty = await service.listByCardId(9999);
expect(empty).toEqual([]);
});
it('getByCardIdAndLocale 应按名片与语言精确命中', async () => {
const service = createService(seedLocales());
const zh = await service.getByCardIdAndLocale(100, 'zh-CN');
expect(zh?.name).toBe('张伟');
expect(zh?.title).toBe('全球商务总监');
const missing = await service.getByCardIdAndLocale(100, 'fr-FR');
expect(missing).toBeNull();
});
it('cardId 或 locale 非法时应拒绝写入', async () => {
const service = createService();
await expect(
service.upsert(0, 'zh-CN', { name: '无效' })
).rejects.toThrow(CoolCommException);
await expect(
service.upsert(100, ' ', { name: '无效' })
).rejects.toThrow(CoolCommException);
});
});
describe('3. 后台控制器装饰器 /admin/card/locale', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/locale.ts'
);
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCardLocaleController);
option = meta.curdOption as CurdOption;
});
it('文件路径应符合 /admin/card/locale 自动路由约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/locale\.ts$/
);
});
it('路由前缀应为 /admin/card/locale', () => {
expect(meta.prefix).toBe('/admin/card/locale');
});
it('控制器应继承 BaseController 并绑定实体与服务', () => {
expect(AdminCardLocaleController.prototype).toBeInstanceOf(
BaseController
);
expect(option.entity).toBe(CardLocaleEntity);
expect(option.service).toBe(CardLocaleService);
expect(adminCardLocaleCurdOption.entity).toBe(CardLocaleEntity);
expect(adminCardLocaleCurdOption.service).toBe(CardLocaleService);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
expect(option.api).toContain('page');
});
it('pageQueryOp.fieldEq 应支持 cardId 与 locale 精确筛选', () => {
const pageQueryOp = option.pageQueryOp as {
fieldEq?: string[];
};
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining([{ column: 'a.card_id', requestParam: 'cardId' }, 'a.locale'])
);
});
it('应将 upsert / listByCardId / getByCardIdAndLocale 注册为 serviceApis', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toEqual([
'upsert',
'listByCardId',
'getByCardIdAndLocale',
]);
});
});
describe('4. serviceApis POST body 单参数对象转调', () => {
it('upsert 接收 { cardId, locale, fields } 时应覆盖同卡同语言', async () => {
const service = createService(seedLocales());
const saved = await service.upsert({
cardId: 100,
locale: 'en-US',
fields: {
name: 'Alexander Zhang',
slogan: 'Updated slogan',
},
});
expect(saved.id).toBe(2);
expect(saved.name).toBe('Alexander Zhang');
expect(saved.slogan).toBe('Updated slogan');
expect(saved.title).toBe('Global BD Director');
const listed = await service.listByCardId({ cardId: 100 });
expect(listed.filter(row => row.locale === 'en-US')).toHaveLength(1);
});
it('getByCardIdAndLocale 接收 { cardId, locale } 时应转调已有双参签名', async () => {
const service = createService(seedLocales());
const found = await service.getByCardIdAndLocale({
cardId: 100,
locale: 'zh-CN',
});
expect(found?.id).toBe(1);
expect(found?.name).toBe('张伟');
});
});
});