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

2176 lines
76 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { SmsConfigService } from './sms-config.service';
function createPrismaMock() {
return {
$executeRaw: jest.fn().mockResolvedValue(1),
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 })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
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([]),
count: jest.fn().mockResolvedValue(0),
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: {
findFirst: jest.fn().mockResolvedValue(null),
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: {
count: jest.fn().mockResolvedValue(0),
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 })),
upsert: jest
.fn()
.mockImplementation(({ create, update }) => Promise.resolve({ id: 'downstream-1', ...(create ?? update) })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
count: jest.fn().mockResolvedValue(1),
},
smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } },
{ applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } },
]),
count: jest.fn().mockResolvedValue(0),
},
cmppDownstreamDelivery: {
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
cmppDownstreamDeliveryAttempt: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsChannel: {
findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'] }]),
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 },
omit: { secretHash: true },
}),
);
const applicationModel = Prisma.dmmf.datamodel.models.find((model) => model.name === 'SmsApplication');
const applicationFields = new Set(applicationModel?.fields.map((field) => field.name));
const omittedFields = Object.keys(prisma.smsApplication.findMany.mock.calls[0][0].omit);
expect(omittedFields.every((field) => applicationFields.has(field))).toBe(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('moves an application with outstanding receipts into disabling for 72 hours', async () => {
const prisma = createPrismaMock();
prisma.smsMessageRecord.count.mockResolvedValue(1);
const service = new SmsConfigService(prisma as never);
const result = await service.changeApplicationStatus('app-1', {
status: 'disabled',
reason: '运营端停用',
});
expect(result).toEqual(
expect.objectContaining({
status: 'disabling',
autoDisableAt: expect.any(Date),
deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }),
}),
);
expect(prisma.smsApplication.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'app-1' },
data: expect.objectContaining({
status: 'disabling',
disablingAt: expect.any(Date),
autoDisableAt: expect.any(Date),
}),
}),
);
});
it('automatically disables and abandons outstanding deliveries after 72 hours', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findMany.mockResolvedValue([
{
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'disabling',
autoDisableAt: new Date(Date.now() - 1_000),
},
]);
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'disabling',
disablingAt: new Date(Date.now() - 73 * 60 * 60 * 1_000),
autoDisableAt: new Date(Date.now() - 1_000),
disableReason: '等待清算',
});
prisma.smsMessageRecord.count.mockResolvedValue(1);
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1' }]);
prisma.cmppDownstreamDelivery.updateMany.mockResolvedValue({ count: 1 });
const originalFetch = global.fetch;
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: jest.fn().mockResolvedValue('{"account":"100001","disconnected":2}'),
}) as never;
const service = new SmsConfigService(prisma as never);
await service['runApplicationDisableScan']();
expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'app-1', status: 'disabling' }),
data: expect.objectContaining({ status: 'disabled' }),
}),
);
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }),
}),
);
global.fetch = originalFetch;
});
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('derives both downstream delivery modes when CMPP is enabled alongside HTTP', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
interfaceEnabled: false,
httpConfig: { enabled: true },
});
const tx = {
smsApplication: {
update: jest.fn().mockResolvedValue({ id: 'app-1', interfaceEnabled: true }),
},
smsApplicationHttpConfig: {
update: jest.fn().mockResolvedValue({}),
},
};
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
const service = new SmsConfigService(prisma as never);
await service.updateApplication('app-1', { interfaceEnabled: true });
expect(tx.smsApplicationHttpConfig.update).toHaveBeenCalledWith({
where: { applicationId: 'app-1' },
data: {
receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'both',
},
});
});
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.upsert).toHaveBeenCalledWith({
where: { connectionId: 'gateway-1-1' },
create: expect.objectContaining({
applicationId: 'app-1',
tenantId: 'tenant-1',
account: '100001',
enterpriseCode: 'APP-EC',
connectionId: 'gateway-1-1',
status: 'connected',
remoteIp: '127.0.0.1',
}),
update: expect.objectContaining({
applicationId: 'app-1',
status: 'connected',
}),
});
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) }) }),
}),
select: expect.objectContaining({
materials: true,
tenant: { select: { id: true, name: true, code: true, status: true } },
application: { select: { id: true, tenantId: true, name: true, status: true } },
drainageItems: expect.objectContaining({
where: { auditStatus: { not: 'deleted' } },
orderBy: { updatedAt: 'desc' },
}),
reportTasks: expect.objectContaining({ select: expect.any(Object) }),
reportBatchItems: expect.any(Object),
}),
orderBy: { createdAt: 'desc' },
}),
);
});
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.count.mockResolvedValue(8);
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 },
total: 8,
page: 1,
pageSize: 10,
});
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', { 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([]);
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const service = new SmsConfigService(prisma as never);
await expect(
service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1'),
).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }),
}),
);
expect(prisma.auditRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }),
});
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('never lets client status filters bypass deleted signature exclusion and keeps template history filtered', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockResolvedValue([]);
prisma.smsSignature.groupBy.mockResolvedValue([] as never);
prisma.smsTemplate.findMany.mockResolvedValue([]);
const service = new SmsConfigService(prisma as never);
for (const status of ['deleted', 'all', 'approved']) {
await service.getClientSignatureWorkspace('tenant-1', { status });
const filter = { notIn: ['deleted', 'disabled'], ...(status !== 'all' ? { equals: status } : {}) };
expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) }),
);
expect(prisma.smsSignature.count).toHaveBeenLastCalledWith(
expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) }),
);
}
await service.listClientTemplates('tenant-1', true);
expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }),
}),
);
await service.listTemplatesPage({ tenantId: 'tenant-1', status: 'all', page: 1, pageSize: 10 });
expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith(
expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) }),
);
prisma.smsSignature.findUnique.mockResolvedValue({
id: 'sig-deleted',
tenantId: 'tenant-1',
auditStatus: 'deleted',
} as never);
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-deleted',
tenantId: 'tenant-1',
auditStatus: 'deleted',
} as never);
await expect(service.submitSignature('sig-deleted', 'tenant-1')).rejects.toThrow('not found');
await expect(service.submitTemplate('tpl-deleted', 'tenant-1')).rejects.toThrow('not found');
});
it('uses exactly the same editable common fields in client and admin signature forms', async () => {
const prisma = createPrismaMock();
const field = {
id: 'field-common',
code: 'license',
name: '主体证明',
fieldType: 'file',
description: '最新配置',
status: 'active',
};
prisma.commonReportField.findMany.mockResolvedValue([
{ id: 'common-1', reportType: 'signature', required: true, drainageField: field },
]);
const service = new SmsConfigService(prisma as never);
for (const applicationId of [undefined, 'app-1']) {
const admin = await service.getApplicationReportFields(applicationId, 'signature');
const client = await service.getClientApplicationReportFields(applicationId, 'signature');
expect(client).toEqual(
admin.map((item) =>
Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key))),
),
);
expect(client[0]).toMatchObject({
code: 'license',
name: '主体证明',
fieldType: 'file',
required: true,
description: '最新配置',
});
}
prisma.commonReportField.findMany.mockResolvedValue([
{ id: 'common-1', reportType: 'signature', required: false, drainageField: field },
]);
expect((await service.getClientApplicationReportFields(undefined, 'signature'))[0].required).toBe(false);
});
it('keeps both common field purposes in material snapshots and ignores deleted channel requirements', async () => {
const prisma = createPrismaMock();
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
prisma.commonReportField.findMany.mockResolvedValue([
{ reportType: 'signature', required: true, drainageField: field },
{ reportType: 'drainage', required: false, drainageField: field },
]);
prisma.channelRouteRule.findMany.mockResolvedValue([
{
group: {
id: 'group-1',
items: [
{
channel: {
id: 'deleted-channel',
status: 'deleted',
reportFields: [
{
status: 'active',
reportType: 'signature',
required: true,
drainageField: { ...field, id: 'old', code: 'old' },
},
],
},
},
],
},
},
] as never);
const result = await new SmsConfigService(prisma as never).getApplicationReportFields('app-1');
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
required: true,
reportTypes: ['signature', 'drainage'],
commonReportTypes: ['signature', 'drainage'],
channels: [],
});
});
it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({
id: 'sig-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
auditStatus: 'approved',
});
prisma.channelRouteRule.findMany.mockResolvedValue([]);
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const service = new SmsConfigService(prisma as never);
await expect(
service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1'),
).resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }),
}),
);
});
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('keeps an operator-edited signature approved and records the operator audit', 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);
const result = await service.updateSignature('sig-1', { name: '【新签名】' }, undefined, {
initialAuditStatus: 'approved',
reviewerId: 'operator-1',
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
auditStatus: 'approved',
rejectReason: null,
materialVersion: { increment: 1 },
pendingReport: true,
}),
}),
);
expect(prisma.auditRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'admin_update_approved',
reviewerId: 'operator-1',
statusBefore: 'approved',
statusAfter: 'approved',
}),
});
expect(result).toEqual(
expect.objectContaining({
auditStatus: 'approved',
reportMaterialChanged: true,
reportPoolAvailableAfter: 'immediate',
}),
);
});
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('accepts concurrent state callbacks for the same newly connected Gateway session', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppEnterpriseCode: 'APP-EC',
cmppMaxConnections: 1,
interfaceEnabled: true,
status: 'active',
ipAllowlist: [],
});
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue(null);
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([{ connectionId: 'gateway-current' }]);
const service = new SmsConfigService(prisma as never);
await expect(
service.recordDownstreamConnectionEvent({
account: '100001',
connectionId: 'gateway-current',
status: 'submit',
remoteIp: '127.0.0.1',
}),
).resolves.toEqual(expect.objectContaining({ connectionId: 'gateway-current' }));
expect(prisma.cmppDownstreamConnection.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { connectionId: 'gateway-current' },
}),
);
});
it('applies the audit submission range to signatures, templates and drainage records', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
const query = { submittedAtFrom: '2026-08-01', submittedAtTo: '2026-08-03' };
const expectedRange = {
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
};
await service.listSignatures(query);
await service.listTemplates(query);
await service.listDrainageInfos(query);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) }),
);
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) }),
);
expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }),
);
});
it('returns a signature page summary without edit materials or report target arrays', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.count.mockResolvedValue(1);
const service = new SmsConfigService(prisma as never);
const result = await service.listSignaturesPage({ page: 1, pageSize: 10 });
expect(result.total).toBe(1);
expect(result.pendingReportMaterialTotal).toBe(0);
expect(result.pendingReportDetailTotal).toBe(0);
expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' }));
expect(result.items[0]).not.toHaveProperty('materials');
expect(result.items[0]).not.toHaveProperty('reportTasks');
expect(result.items[0]).not.toHaveProperty('reportTargets');
expect(result.items[0]).not.toHaveProperty('drainageReportTargets');
});
it.each([
['abandoned', 1],
['pending', 2],
['reporting', 2],
['approved', 2],
['failed', 2],
['rejected', 2],
])('keeps the pending detail total aligned after a persisted %s task status', async (taskStatus, expectedTotal) => {
const prisma = createPrismaMock();
prisma.smsSignature.findMany.mockImplementation(({ where }) => {
if (where?.AND) {
return Promise.resolve([
{
id: 'sig-pending',
applicationId: 'app-1',
materialVersion: 2,
application: { status: 'active' },
reportTasks: [
{ channelId: 'channel-1', carrier: 'mobile', status: taskStatus, approvalScope: 'carrier_specific' },
],
reportBatchItems: [],
},
]);
}
return Promise.resolve([]);
});
prisma.channelRouteRule.findMany.mockResolvedValue([
{
applicationId: 'app-1',
group: {
status: 'active',
items: [
{
channel: {
id: 'channel-1',
status: 'active',
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
reportFields: [],
},
},
],
},
},
] as never);
const service = new SmsConfigService(prisma as never);
const result = await service.listSignaturesPage({ signatureKeyword: '测试', page: 1, pageSize: 10 });
expect(result.pendingReportMaterialTotal).toBe(1);
expect(result.pendingReportDetailTotal).toBe(expectedTotal);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: [
expect.objectContaining({ name: { contains: '测试' } }),
{ auditStatus: 'approved', pendingReport: true },
],
},
}),
);
});
it.each([
'【带 空格】',
' 【外部空格】',
'【不换\n行】',
'【零宽\u200B字符】',
'【不换行空格\u00A0】',
'【字节顺序标记\uFEFF】',
'【变体选择符\uFE0F】',
])('rejects spaces, controls, and invisible characters in signature names: %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('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();
});
});