import { BadRequestException, ConflictException } from '@nestjs/common'; import { decryptSecret, encryptSecret } from './open-api.crypto'; import { OpenApiService } from './open-api.service'; describe('OpenApiService', () => { beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; }); it('encrypts secrets with authenticated encryption', () => { const encrypted = encryptSecret('customer-secret'); expect(encrypted).not.toContain('customer-secret'); expect(decryptSecret(encrypted)).toBe('customer-secret'); }); it('returns the configured public HTTPS origin for customer integration parameters', async () => { const previous = process.env.HTTP_API_PUBLIC_ORIGIN; process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/'; const prisma = { smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) }, }; try { const service = new OpenApiService(prisma as never, {} as never); await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' })); } finally { if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN; else process.env.HTTP_API_PUBLIC_ORIGIN = previous; } }); it('replays a completed request for the same idempotency key and body', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, }; const sendChain = { createHttpBatchTask: jest.fn() }; const service = new OpenApiService(prisma as never, sendChain as never); const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' }); expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' }); expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled(); }); it('rejects reuse of an idempotency key with a different body', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } }; const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException); }); it('replays the same persisted business rejection', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } }; const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 }); }); it('uses the real send chain and persists the accepted response', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}), }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, }; const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) }; const service = new OpenApiService(prisma as never, sendChain as never); const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' }); expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' })); expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' })); expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) })); }); it('persists a 422 result when the real send chain rejects the business request', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, }; const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 }); expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) })); }); it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => { const prisma = { smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) }, httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) }, httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) }, httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) }, }; const service = new OpenApiService(prisma as never, {} as never); const input = { tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt' as const, messageRecordId: 'record-1', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } }; await service.queueWebhookEvent(input); await service.queueWebhookEvent(input); expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { eventId: 'evt_receipt_record-1' }, })); expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2); expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(expect.objectContaining({ create: { eventId: 'event-row-1', endpointId: 'endpoint-1' }, })); }); it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => { const prisma = { smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) }, smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) }, smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() }, $transaction: jest.fn((operations) => Promise.all(operations)), }; const service = new OpenApiService(prisma as never, {} as never); await service.updateConfig('app-1', { enabled: true }); expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ enabled: true, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true, uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true, receiptDeliveryMode: 'both', uplinkDeliveryMode: 'both', }), })); }); it('removes a webhook endpoint when an operator saves a blank address', async () => { const prisma = { smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: { enabled: true, requireHttps: true }, httpIpAllowlist: [], }), }, httpWebhookEndpoint: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }), }, }; const service = new OpenApiService(prisma as never, {} as never); await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })) .resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true })); expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1', eventType: 'receipt' }, }); }); }); function auth() { return { application: { id: 'app-1', tenantId: 'tenant-1', status: 'active' }, config: { sendEnabled: true }, credentialId: 'credential-1', accessKey: 'ak_test', sourceIp: '203.0.113.10', }; }