1403 lines
64 KiB
TypeScript
1403 lines
64 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
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 })),
|
|
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: {
|
|
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: {
|
|
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: '官网', signatureSort: 'asc' })).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 } },
|
|
},
|
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
|
}));
|
|
});
|
|
|
|
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([]);
|
|
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([]);
|
|
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('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.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();
|
|
});
|
|
});
|