2176 lines
76 KiB
TypeScript
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();
|
|
});
|
|
});
|