Files
lislgosms/api/src/sms-config/sms-config.service.spec.ts
T

1157 lines
52 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { SmsConfigService } from './sms-config.service';
function createPrismaMock() {
return {
smsApplication: {
findMany: jest.fn().mockResolvedValue([{
id: 'app-1',
tenantId: 'tenant-1',
name: '应用A',
status: 'active',
cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
cmppMaxConnections: 2,
cmppWindowSize: 32,
interfaceEnabled: true,
interfaceType: 'cmpp20',
queuePriority: 'normal',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}]),
findUnique: jest.fn().mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
name: '应用A',
status: 'active',
cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
cmppMaxConnections: 2,
cmppWindowSize: 32,
interfaceEnabled: true,
interfaceType: 'cmpp20',
queuePriority: 'normal',
secretHash: '0123456789abcdef',
ipAllowlist: [],
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
},
smsApplicationIpAllowlist: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsChannelGroup: {
findMany: jest.fn().mockResolvedValue([
{ id: 'group-mobile', carrier: 'mobile' },
{ id: 'group-unicom', carrier: 'unicom' },
]),
},
channelRouteRule: {
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
createMany: jest.fn().mockResolvedValue({ count: 2 }),
findMany: jest.fn().mockResolvedValue([
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
]),
},
commonReportField: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
smsSignature: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([{
id: 'sig-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '签名A',
purpose: '行业通知',
auditStatus: 'pending',
drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] },
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
application: { id: 'app-1', name: '应用A' },
materials: [],
drainageItems: [],
reportTasks: [],
}]),
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', auditStatus: 'pending' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
},
signatureReportMaterial: {
upsert: jest.fn().mockResolvedValue({ id: 'signature-report-value-1' }),
},
drainageReportMaterial: {
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
create: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportRecord: {
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
},
smsTemplate: {
findMany: jest.fn().mockResolvedValue([{
id: 'tpl-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '模板A',
content: '您好${name}',
auditStatus: 'pending',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
application: { id: 'app-1', name: '应用A' },
signature: { id: 'sig-1', name: '签名A' },
variables: [{ name: 'name', required: true }],
}]),
findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', content: '【签名A】您好${name}', auditStatus: 'pending' }),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })),
},
auditRecord: {
create: jest.fn(),
findMany: jest.fn(),
},
user: {
findUnique: jest.fn().mockResolvedValue(null),
},
cmppConnectionState: {
findMany: jest.fn().mockResolvedValue([{ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'connected', currentConnections: 1, desiredConnections: 1 }]),
findFirst: jest.fn().mockResolvedValue({ id: 'conn-state-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
},
cmppDownstreamConnection: {
findMany: jest.fn().mockResolvedValue([{ id: 'downstream-1', applicationId: 'app-1', tenantId: 'tenant-1', account: '100001', enterpriseCode: 'APP-EC', connectionId: 'gateway-1-1', status: 'connected', connectedAt: new Date(), lastHeartbeatAt: new Date() }]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } },
{ applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } },
]),
},
smsChannel: {
findFirst: jest.fn().mockResolvedValue({
id: 'channel-1',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
enterpriseCode: 'EC',
account: 'sp',
passwordCipher: 'cipher',
srcId: '10690000',
cmppVersion: '3.0',
config: { maxConnections: 2 },
}),
},
operationLog: {
create: jest.fn(),
},
tenant: {
findUnique: jest.fn().mockResolvedValue({ code: 'TENANT-A' }),
},
$transaction: jest.fn((callback) => callback({
smsApplication: {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
},
smsApplicationIpAllowlist: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
channelRouteRule: {
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
createMany: jest.fn().mockResolvedValue({ count: 2 }),
findMany: jest.fn().mockResolvedValue([
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
]),
},
templateVariable: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsTemplate: {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
},
})),
};
}
describe('SmsConfigService', () => {
it('rejects unknown reviewer ids before writing audit records', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.approveSignature('sig-1', { reviewerId: 'missing-user' })).rejects.toThrow(
'reviewerId does not reference an existing user',
);
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
});
it('lists enterprise applications with real CMPP connection state', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active' })).resolves.toEqual([
expect.objectContaining({
id: 'app-1',
cmppStatus: 'connected',
queuePriority: 'normal',
sentToday: 2,
deliveryRate: 50,
cmppConnections: [expect.objectContaining({ connectionId: 'gateway-1-1', account: '100001' })],
}),
]);
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
include: { tenant: true, ipAllowlist: true, httpConfig: true },
}));
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
by: ['applicationId', 'status'],
_count: { _all: true },
}));
});
it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => {
const prisma = createPrismaMock();
const baseApplication = {
tenantId: 'tenant-1',
status: 'active',
interfaceEnabled: true,
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
ipAllowlist: [],
};
prisma.smsApplication.findMany.mockResolvedValue([
{ ...baseApplication, id: 'app-1', name: '乙应用' },
{ ...baseApplication, id: 'app-2', name: '甲应用' },
{ ...baseApplication, id: 'app-3', name: '丙应用' },
]);
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([]);
prisma.smsMessageRecord.groupBy.mockResolvedValue([
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 2 } },
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 2 } },
{ applicationId: 'app-3', status: 'delivered', _count: { _all: 5 } },
]);
const service = new SmsConfigService(prisma as never);
const applications = await service.listApplications({ includeConnections: true });
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
});
it('returns CMPP params from the public inbound Gateway config instead of an upstream channel', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com';
process.env.CMPP_PUBLIC_PORT = '17891';
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
applicationId: 'app-1',
tenantName: '租户A',
account: '100001',
enterpriseCode: 'APP-EC',
passwordCipher: '0123456789abcdef',
srcId: '000001',
applicationExtension: '0001',
accessNumberFillEnabled: true,
accessNumberFillPrefix: '00',
gatewayHost: 'cmpp.example.com',
gatewayPort: 17891,
interfaceEnabled: true,
interfaceType: 'cmpp20',
maxConnections: 2,
windowSize: 32,
protocolVersion: 'CMPP2.0',
}));
expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled();
delete process.env.CMPP_PUBLIC_HOST;
delete process.env.CMPP_PUBLIC_PORT;
});
it('creates enterprise applications with persisted queue priority', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValueOnce(null);
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '优先应用',
cmppAccount: '123456',
cmppEnterpriseCode: 'CUSTOM-EC',
passwordCipher: '1234567890abcdef',
cmppMaxConnections: 3,
cmppWindowSize: 32,
interfaceEnabled: false,
interfaceType: 'cmpp20',
queuePriority: 'priority',
ipAllowlist: ['10.0.0.1/32'],
})).resolves.toEqual(expect.objectContaining({ id: 'app-new' }));
expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
tenantId: 'tenant-1',
name: '优先应用',
cmppAccount: '123456',
cmppEnterpriseCode: '123456',
secretHash: '1234567890abcdef',
cmppMaxConnections: 3,
cmppWindowSize: 32,
interfaceEnabled: false,
interfaceType: 'cmpp20',
queuePriority: 'priority',
dailyLimit: 100000,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
}),
}));
});
it('persists a filled client Src_Id separately from the real application extension', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
const service = new SmsConfigService(prisma as never);
await service.createApplication({
tenantId: 'tenant-1',
name: '接入号应用',
cmppAccount: '123456',
passwordCipher: '1234567890abcdef',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
});
expect(prisma.smsApplication.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
cmppClientSrcId: '000001',
}),
}));
});
it('rejects access number filling without a numeric prefix and application extension', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue(null);
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '缺少扩展码',
cmppAccount: '123456',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: '00',
})).rejects.toThrow('cmppApplicationExtension is required');
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '错误前缀',
cmppAccount: '123456',
cmppApplicationExtension: '0001',
cmppAccessNumberFillEnabled: true,
cmppAccessNumberFillPrefix: 'AB',
})).rejects.toThrow('cmppAccessNumberFillPrefix must contain digits only');
});
it('rejects invalid enterprise application queue priority', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '异常应用',
queuePriority: 'urgent',
})).rejects.toThrow('queuePriority must be normal or priority');
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
});
it('rejects unavailable enterprise application interface types', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: 'HTTP应用',
interfaceType: 'http',
})).rejects.toThrow('interfaceType only supports cmpp20');
expect(prisma.smsApplication.create).not.toHaveBeenCalled();
});
it('rejects duplicate or invalid CMPP application accounts', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '异常应用',
cmppAccount: 'abc',
})).rejects.toThrow('cmppAccount must be a 6-digit number');
await expect(service.createApplication({
tenantId: 'tenant-1',
name: '重复应用',
cmppAccount: '100001',
})).rejects.toThrow('cmppAccount already exists');
});
it('updates enterprise application profile and allowlist through a transaction', async () => {
const prisma = createPrismaMock();
const tx = {
smsApplication: {
update: jest.fn().mockResolvedValue({ id: 'app-1', name: '新应用', ipAllowlist: [{ ipCidr: '10.0.0.1/32' }] }),
},
smsApplicationIpAllowlist: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
channelRouteRule: prisma.channelRouteRule,
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 }))
.rejects.toThrow('客户单价最多支持人民币小数点后 4 位');
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
expect(tx.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'app-1' },
data: expect.objectContaining({
name: '新应用',
cmppEnterpriseCode: '100001',
customerUnitPrice: 325,
queuePriority: 'priority',
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
}),
}));
});
it('replaces application carrier channel-group routes with carrier validation', async () => {
const prisma = createPrismaMock();
const tx = {
channelRouteRule: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
createMany: jest.fn().mockResolvedValue({ count: 2 }),
findMany: jest.fn().mockResolvedValue([
{ id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' },
{ id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' },
]),
},
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await expect(service.replaceApplicationRouteRules('app-1', {
routes: [
{ carrier: 'mobile', groupId: 'group-mobile' },
{ carrier: 'unicom', groupId: 'group-unicom' },
],
})).resolves.toEqual([
expect.objectContaining({ carrier: 'mobile' }),
expect.objectContaining({ carrier: 'unicom' }),
]);
expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({
where: { applicationId: 'app-1', channelId: null, province: null },
});
expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }),
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }),
],
});
});
it('rejects application routes when channel-group carrier does not match', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.replaceApplicationRouteRules('app-1', {
routes: [{ carrier: 'telecom', groupId: 'group-mobile' }],
})).rejects.toThrow('channel group carrier must match route carrier');
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('hides CMPP params when the application belongs to another tenant', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
});
it('forbids client CMPP parameter access when the interface is not enabled', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false,
tenant: { id: 'tenant-1', name: '租户A' },
});
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationCmppParams('app-1', 'tenant-1')).rejects.toThrow('该企业应用未开通 CMPP 接口');
});
it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status: 'connected',
remoteIp: '127.0.0.1',
protocol: 'cmpp30',
observedAt: '2026-07-11T11:00:00.000Z',
});
expect(prisma.cmppDownstreamConnection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
applicationId: 'app-1',
tenantId: 'tenant-1',
account: '100001',
enterpriseCode: 'APP-EC',
connectionId: 'gateway-1-1',
status: 'connected',
remoteIp: '127.0.0.1',
}),
});
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
where: {
status: 'connected',
OR: [
{ lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } },
{
lastHeartbeatAt: null,
connectedAt: { lt: new Date('2026-07-11T10:58:30.000Z') },
},
],
},
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'cmpp_downstream_connection.connected',
resource: 'cmpp_downstream_connection',
resourceId: 'gateway-1-1',
}),
});
});
it('prunes expired downstream connections whose heartbeat was never recorded', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
const now = new Date('2026-07-21T04:00:00.000Z');
await service.markTimedOutDownstreamConnections(now);
expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({
where: {
status: 'connected',
OR: [
{ lastHeartbeatAt: { lt: new Date('2026-07-21T03:58:30.000Z') } },
{
lastHeartbeatAt: null,
connectedAt: { lt: new Date('2026-07-21T03:58:30.000Z') },
},
],
},
});
});
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }],
});
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() });
const service = new SmsConfigService(prisma as never);
await expect(service.recordDownstreamConnectionEvent({
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP source IP is not in application allowlist');
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
interfaceEnabled: true, status: 'active', ipAllowlist: [],
});
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([
{ connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' },
]);
await expect(service.recordDownstreamConnectionEvent({
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP connection limit exceeded (1)');
});
it.each([
['heartbeat', 'lastHeartbeatAt'],
['submit', 'lastSubmitAt'],
['deliver', 'lastDeliverAt'],
] as const)('updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => {
const prisma = createPrismaMock();
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({
id: 'downstream-1',
connectedAt: new Date('2026-07-11T11:00:00.000Z'),
lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'),
lastSubmitAt: null,
lastDeliverAt: null,
lastError: null,
});
const service = new SmsConfigService(prisma as never);
await service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status,
observedAt: '2026-07-11T11:00:30.000Z',
});
expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'downstream-1' },
data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }),
}));
expect(prisma.operationLog.create).not.toHaveBeenCalled();
});
it('removes a disconnected downstream session instead of retaining connection history', async () => {
const prisma = createPrismaMock();
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' });
const service = new SmsConfigService(prisma as never);
await expect(service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-1-1',
status: 'disconnected',
errorMessage: 'client closed',
})).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true }));
expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } });
expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled();
});
it('lists enterprise signatures with keyword filters and real relations', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
expect.objectContaining({
id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }),
application: expect.objectContaining({ name: '应用A' }),
}),
]);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '签名' },
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
}),
include: {
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
}));
});
it('merges report fields from every channel in the application channel groups', async () => {
const prisma = createPrismaMock();
prisma.channelRouteRule.findMany.mockResolvedValue([
{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '默认通道组',
items: [
{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: false, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } },
{ channel: { id: 'channel-2', code: 'CH-2', name: '通道二', reportFields: [{ status: 'active', required: true, reportType: 'both', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } },
],
},
},
] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([
expect.objectContaining({
id: 'field-1',
code: 'license',
required: true,
reportTypes: ['signature', 'both'],
channels: [
expect.objectContaining({ id: 'channel-1', groupId: 'group-1' }),
expect.objectContaining({ id: 'channel-2', groupId: 'group-1' }),
],
}),
]);
});
it('merges common report fields into every target channel requirement', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '默认通道组',
items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [] } }],
},
}] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.getApplicationReportFields('app-1', 'signature')).resolves.toEqual([
expect.objectContaining({
id: 'field-common',
required: true,
reportTypes: ['signature'],
commonReportTypes: ['signature'],
channels: [expect.objectContaining({ id: 'channel-1', source: 'common', required: true })],
}),
]);
});
it('removes channel sources from client report-field responses', async () => {
const prisma = createPrismaMock();
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '内部通道组',
items: [{ channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }],
},
}] as never);
const service = new SmsConfigService(prisma as never);
const fields = await service.getClientApplicationReportFields('app-1', 'signature');
expect(fields).toEqual([expect.objectContaining({ id: 'field-1', code: 'license', required: true })]);
expect(JSON.stringify(fields)).not.toContain('channel-secret');
expect(JSON.stringify(fields)).not.toContain('内部通道');
expect(fields[0]).not.toHaveProperty('channels');
});
it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockResolvedValue([{
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知',
auditStatus: 'rejected', rejectReason: '请补充资料',
drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }] },
createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'),
application: { id: 'app-1', name: '应用A', status: 'active' },
materials: [{ id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date() }],
drainageItems: [{ id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date() }],
_count: { reportMaterials: 2 },
reportTasks: [{ channelId: 'channel-secret' }],
}] as never);
const service = new SmsConfigService(prisma as never);
const result = await service.listClientSignatures('tenant-1');
const serialized = JSON.stringify(result);
expect(result[0]).toEqual(expect.objectContaining({
id: 'sig-1',
submittedMaterialCount: 3,
reportValues: { license: 'file-1' },
drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] },
}));
expect(serialized).not.toContain('channel-secret');
expect(serialized).not.toContain('内部通道');
expect(result[0]).not.toHaveProperty('reportTasks');
expect(result[0]).toHaveProperty('reportStatus');
});
it('returns real client signature workspace counts from database grouping', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockResolvedValue([]);
prisma.smsSignature.groupBy.mockResolvedValue([
{ auditStatus: 'pending', _count: { _all: 2 } },
{ auditStatus: 'approved', _count: { _all: 5 } },
{ auditStatus: 'rejected', _count: { _all: 1 } },
] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({
items: [],
summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 },
});
expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
}));
});
it('selects client drainage information without internal tasks or channels', async () => {
const prisma = createPrismaMock();
prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never);
const service = new SmsConfigService(prisma as never);
await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([{ id: 'drainage-1', siteName: '官网' }]);
const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0];
expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } });
expect(query.select).not.toHaveProperty('reportTasks');
expect(JSON.stringify(query.select)).not.toContain('channel');
});
it('requires common signature fields even when a signature is not bound to an application', async () => {
const prisma = createPrismaMock();
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-1', reportType: 'signature', required: true, status: 'active',
drainageField: { id: 'field-common', code: 'creditCode', name: '统一社会信用代码', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createSignature({ tenantId: 'tenant-1', name: '无应用签名' }))
.rejects.toThrow('缺少必填签名报备资料:统一社会信用代码');
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
});
it('requires common drainage fields when adding drainage info without an application', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: null, auditStatus: 'approved' });
prisma.commonReportField.findMany.mockResolvedValue([{
id: 'common-2', reportType: 'drainage', required: true, status: 'active',
drainageField: { id: 'field-site', code: 'siteOwner', name: '网站主体', fieldType: 'string', description: null, status: 'active' },
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.rejects.toThrow('引流信息缺少必填报备资料:网站主体');
expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled();
});
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }));
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: {
id: 'group-1', name: '默认通道组',
items: [{
channel: {
id: 'channel-1', code: 'CH-1', name: '通道一',
reportFields: [
{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } },
{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'text', description: null, status: 'active' } },
],
},
}],
},
}] as never);
const service = new SmsConfigService(prisma as never);
await service.updateSignature('sig-1', {
applicationId: 'app-1',
drainageInfo: {
signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } },
links: [{ id: 'legacy-drain-1', reportValues: { site_owner: '企业A' } }],
},
});
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }),
}));
expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled();
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('creates client drainage info as pending without generating channel report tasks', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
prisma.channelRouteRule.findMany.mockResolvedValue([]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('resets an approved signature to pending when key content is changed', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】',
purpose: '通知', drainageInfo: {}, auditStatus: 'approved',
});
const service = new SmsConfigService(prisma as never);
await service.updateSignature('sig-1', { name: '【新签名】' });
expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }),
}));
});
it('resets an approved template to pending when key content is changed', async () => {
const prisma = createPrismaMock();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved',
});
const tx = {
templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', auditStatus: 'pending' }) },
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' });
expect(tx.smsTemplate.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ auditStatus: 'pending' }),
}));
});
it('creates real drainage materials and channel tasks after operations approval', async () => {
const prisma = createPrismaMock();
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' } };
prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem);
prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} });
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active' } }] } }] },
}] as never);
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const service = new SmsConfigService(prisma as never);
await service.approveDrainageInfo('drainage-1', {});
expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }) });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }) });
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }) });
});
it('creates admin signatures with an approved initial audit status', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.createSignature({ tenantId: 'tenant-1', name: '【运营新建签名】' }, { initialAuditStatus: 'approved' });
expect(prisma.smsSignature.create).toHaveBeenCalledWith({
data: expect.objectContaining({ auditStatus: 'approved', name: '【运营新建签名】' }),
});
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
});
it.each(['未带括号', '[英文括号]', '【【重复括号】】', '【 】'])(
'rejects a signature name without exactly one complete Chinese black bracket pair: %s',
async (name) => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createSignature({ tenantId: 'tenant-1', name }))
.rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】');
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
},
);
it('rejects editing a signature to a name without complete Chinese black brackets', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.updateSignature('sig-1', { name: '编辑无括号' }))
.rejects.toThrow('短信签名必须包含完整中文黑括号,例如:【某某科技】');
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
it('updates enterprise signature drainage info through the admin API path', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.updateSignature('sig-1', {
name: '【签名B】',
auditStatus: 'approved',
drainageInfo: {
carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' },
links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }],
},
})).resolves.toEqual(expect.objectContaining({
id: 'sig-1',
name: '【签名B】',
auditStatus: 'approved',
}));
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: expect.objectContaining({
name: '【签名B】',
auditStatus: 'approved',
drainageInfo: expect.objectContaining({
carrierStatus: expect.objectContaining({ mobile: 'approved' }),
}),
}),
include: { materials: true, tenant: true, application: true },
});
});
it('lists enterprise templates with real relations and excludes deleted by default', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码' })).resolves.toEqual([
expect.objectContaining({
id: 'tpl-1',
tenant: expect.objectContaining({ name: '租户A' }),
application: expect.objectContaining({ name: '应用A' }),
signature: expect.objectContaining({ name: '签名A' }),
}),
]);
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '模板' },
content: { contains: '验证码' },
}),
include: { variables: true, application: true, tenant: true, signature: true },
}));
});
it('returns only approved templates from the client send-candidate view by default', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await service.listClientTemplates('tenant-1');
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }),
}));
});
it('creates admin enterprise templates as approved when requested', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createTemplate({
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '运营添加模板',
content: '【签名A】您的验证码为${code}',
variables: [{ name: 'code', example: '123456', required: true }],
}, { initialAuditStatus: 'approved' })).resolves.toEqual(expect.objectContaining({ id: 'tpl-new' }));
expect(prisma.smsTemplate.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '运营添加模板',
content: '【签名A】您的验证码为${code}',
auditStatus: 'approved',
variables: {
create: [{ name: 'code', example: '123456', required: true }],
},
}),
include: { variables: true, application: true, tenant: true, signature: true },
}));
});
it('updates enterprise templates and rebuilds template variables', async () => {
const prisma = createPrismaMock();
const tx = {
templateVariable: {
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
},
smsTemplate: {
update: jest.fn().mockResolvedValue({ id: 'tpl-1', name: '模板B', variables: [{ name: 'code' }] }),
},
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await expect(service.updateTemplate('tpl-1', {
applicationId: 'app-1',
signatureId: 'sig-1',
name: '模板B',
content: '【签名A】验证码${code}',
variables: [{ name: 'code', example: '123456', required: true }],
})).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' }));
expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } });
expect(tx.smsTemplate.update).toHaveBeenCalledWith({
where: { id: 'tpl-1' },
data: expect.objectContaining({
applicationId: 'app-1',
signatureId: 'sig-1',
name: '模板B',
content: '【签名A】验证码${code}',
variables: {
create: [{ name: 'code', example: '123456', required: true }],
},
}),
include: { variables: true, application: true, tenant: true, signature: true },
});
});
it('requires the selected signature at the start of template content', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createTemplate({
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '缺少签名模板',
content: '您的验证码为${code}',
})).rejects.toThrow('短信模板必须选择短信签名');
await expect(service.createTemplate({
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'sig-1',
name: '签名不匹配模板',
content: '【其他签名】您的验证码为${code}',
})).rejects.toThrow('模板内容必须以所选短信签名 【签名A】 开头');
expect(prisma.smsTemplate.create).not.toHaveBeenCalled();
});
it.each([
['空变量', '【签名A】验证码${}'],
['中文变量', '【签名A】验证码${中文}'],
['未闭合变量', '【签名A】验证码${code'],
['重复变量', '【签名A】${code}-${code}'],
['超长变量', `【签名A】\${${'a'.repeat(33)}}`],
])('rejects %s before persisting the template', async (_label, content) => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createTemplate({
tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '非法模板', content,
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.smsTemplate.create).not.toHaveBeenCalled();
});
});