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 { 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(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>; order?: Partial>; } /** * 内存 Repository,覆盖 upsert / listByCardId / getByCardIdAndLocale */ class MemoryLocaleRepo { private data: CardLocaleEntity[] = []; private currentId = 1; constructor(initialData: Array> = []) { 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 { 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 { let result = [...this.data]; if (options?.where) { const where = options.where; result = result.filter(item => { const keys = Object.keys(where) as Array; 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 >; 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> ): Promise { const found = this.data.find(item => { const keys = Object.keys(where) as Array; return keys.every(key => { const expected = where[key]; return expected === undefined || item[key] === expected; }); }); return found ? ({ ...found } as CardLocaleEntity) : null; } async save( entityOrEntities: | Partial | Array> ): Promise { 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> = [] ): CardLocaleService { const service = new CardLocaleService(); service.cardLocaleEntity = new MemoryLocaleRepo( seed ) as unknown as CardLocaleService['cardLocaleEntity']; return service; } function seedLocales(): Array> { 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('张伟'); }); }); });