import * as fs from 'fs'; import * as path from 'path'; import axios from 'axios'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core'; import { AiOcrService } from '../../../src/modules/ai/service/ocr'; import { OpenAiOcrController, openAiOcrCurdOption, } from '../../../src/modules/ai/controller/open/ocr'; /** * 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)); } describe('P06: 名片 OCR 识别 (modules/ai ocr)', () => { const ORIGINAL_ENV = { OCR_API_KEY: process.env.OCR_API_KEY, OCR_BASE_URL: process.env.OCR_BASE_URL, }; afterEach(() => { jest.restoreAllMocks(); if (ORIGINAL_ENV.OCR_API_KEY === undefined) { delete process.env.OCR_API_KEY; } else { process.env.OCR_API_KEY = ORIGINAL_ENV.OCR_API_KEY; } if (ORIGINAL_ENV.OCR_BASE_URL === undefined) { delete process.env.OCR_BASE_URL; } else { process.env.OCR_BASE_URL = ORIGINAL_ENV.OCR_BASE_URL; } }); describe('1. AiOcrService.recognize 入参与配置校验', () => { let service: AiOcrService; beforeEach(() => { service = new AiOcrService(); delete process.env.OCR_API_KEY; delete process.env.OCR_BASE_URL; }); it('imageBase64 与 imageUrl 都为空时应抛 CoolCommException', async () => { await expect(service.recognize({})).rejects.toThrow(CoolCommException); await expect(service.recognize({ imageBase64: '', imageUrl: '' })).rejects.toThrow( CoolCommException ); await expect( service.recognize({ imageBase64: ' ', imageUrl: ' ' }) ).rejects.toThrow(CoolCommException); }); it('未配置 OCR_API_KEY 或 OCR_BASE_URL 时应抛中文未配置错误,禁止伪造识别结果', async () => { process.env.OCR_API_KEY = 'test-key'; delete process.env.OCR_BASE_URL; await expect( service.recognize({ imageBase64: 'data:image/png;base64,abc' }) ).rejects.toThrow(CoolCommException); await expect( service.recognize({ imageBase64: 'data:image/png;base64,abc' }) ).rejects.toThrow(/未配置.*OCR|OCR.*未配置/); delete process.env.OCR_API_KEY; process.env.OCR_BASE_URL = 'https://ocr.example.test/v1/recognize'; await expect( service.recognize({ imageUrl: 'https://cdn.example/card.jpg' }) ).rejects.toThrow(/未配置.*OCR|OCR.*未配置/); // 自审:不得在未配置时返回写死姓名/公司/电话 try { await service.recognize({ imageBase64: 'abc' }); fail('应抛错'); } catch (err) { expect(err).toBeInstanceOf(CoolCommException); const msg = String((err as Error).message); expect(msg).not.toMatch(/张三|李四|示例公司|13800138000/); } }); }); describe('2. AiOcrService.recognize 调用远程 OCR', () => { let service: AiOcrService; beforeEach(() => { service = new AiOcrService(); process.env.OCR_API_KEY = 'ocr-secret-key'; process.env.OCR_BASE_URL = 'https://ocr.example.test/v1/recognize'; }); it('配置齐全时应 axios.post 转发 body,映射 name/company/phone/email/title', async () => { const body = { imageBase64: 'data:image/jpeg;base64,/9j/4AAQ', imageUrl: undefined as string | undefined, }; const postSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({ data: { name: 'Alex Zheng', company: 'OmniCard', phone: '+971501234567', email: 'alex@omnicard.ai', title: 'Product Lead', extra: 'ignored', }, }); const result = await service.recognize(body); expect(postSpy).toHaveBeenCalledTimes(1); expect(postSpy.mock.calls[0][0]).toBe('https://ocr.example.test/v1/recognize'); expect(postSpy.mock.calls[0][1]).toEqual(body); expect(postSpy.mock.calls[0][2]).toMatchObject({ headers: { Authorization: 'Bearer ocr-secret-key', }, timeout: 15000, }); expect(result).toEqual({ name: 'Alex Zheng', company: 'OmniCard', phone: '+971501234567', email: 'alex@omnicard.ai', title: 'Product Lead', }); }); it('对方 JSON 缺字段时应用空字符串填充,不得伪造默认姓名电话', async () => { jest.spyOn(axios, 'post').mockResolvedValueOnce({ data: { name: 'Only Name' }, }); const result = await service.recognize({ imageUrl: 'https://cdn.example/card.png', }); expect(result).toEqual({ name: 'Only Name', company: '', phone: '', email: '', title: '', }); expect(result.phone).toBe(''); expect(result.company).toBe(''); }); it('远程 OCR 请求失败时应抛错', async () => { jest.spyOn(axios, 'post').mockRejectedValueOnce(new Error('OCR upstream 503')); await expect( service.recognize({ imageBase64: 'base64payload' }) ).rejects.toThrow(/OCR upstream 503|OCR|识别/); }); }); describe('3. OpenAiOcrController /open/ai/ocr/recognize', () => { const controllerFile = path.join( __dirname, '../../../src/modules/ai/controller/open/ocr.ts' ); let meta: CoolControllerMeta; let option: CurdOption; beforeAll(async () => { meta = await waitCoolMeta(OpenAiOcrController); option = meta.curdOption as CurdOption; }); it('文件路径应符合 /open/ai/ocr 自动路由约定', () => { expect(fs.existsSync(controllerFile)).toBe(true); expect(controllerFile.replace(/\\/g, '/')).toMatch( /modules\/ai\/controller\/open\/ocr\.ts$/ ); }); it('路由前缀应为 /open/ai/ocr,serviceApis 含 recognize', () => { expect(meta.prefix).toBe('/open/ai/ocr'); expect(OpenAiOcrController.prototype).toBeInstanceOf(BaseController); expect(option.service).toBe(AiOcrService); expect(openAiOcrCurdOption.service).toBe(AiOcrService); expect(option.api || []).toEqual([]); const methods = serviceApiMethods(option.serviceApis); expect(methods).toContain('recognize'); }); }); });