379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { SmsConfigService } from './sms-config.service';
|
|
|
|
function createPrismaMock() {
|
|
return {
|
|
smsApplication: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
name: '应用A',
|
|
status: 'active',
|
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
|
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
|
}]),
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'app-1',
|
|
tenantId: 'tenant-1',
|
|
name: '应用A',
|
|
status: 'active',
|
|
secretHash: 'secret-hash',
|
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
|
}),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
|
|
},
|
|
smsApplicationIpAllowlist: {
|
|
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
},
|
|
smsChannelGroup: {
|
|
findMany: jest.fn().mockResolvedValue([
|
|
{ id: 'group-mobile', carrier: 'mobile' },
|
|
{ id: 'group-unicom', carrier: 'unicom' },
|
|
]),
|
|
},
|
|
channelRouteRule: {
|
|
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
findMany: jest.fn().mockResolvedValue([
|
|
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
|
|
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
|
|
]),
|
|
},
|
|
smsSignature: {
|
|
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: [],
|
|
}]),
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
|
|
},
|
|
smsTemplate: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'tpl-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
signatureId: 'sig-1',
|
|
name: '模板A',
|
|
content: '您好${name}',
|
|
auditStatus: 'pending',
|
|
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
|
application: { id: 'app-1', name: '应用A' },
|
|
signature: { id: 'sig-1', name: '签名A' },
|
|
variables: [{ name: 'name', required: true }],
|
|
}]),
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', 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([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
|
|
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
|
|
},
|
|
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(),
|
|
},
|
|
$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 })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: 'app-1',
|
|
cmppStatus: 'connected',
|
|
sentToday: 2,
|
|
deliveryRate: 50,
|
|
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('returns CMPP params from persisted application and channel config', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new SmsConfigService(prisma as never);
|
|
|
|
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
|
applicationId: 'app-1',
|
|
tenantName: '租户A',
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 17890,
|
|
maxConnections: 2,
|
|
}));
|
|
});
|
|
|
|
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', { name: '新应用', customerUnitPrice: 300, 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: '新应用',
|
|
customerUnitPrice: 300,
|
|
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('replaces application carrier channel-group routes with carrier validation', async () => {
|
|
const prisma = createPrismaMock();
|
|
const tx = {
|
|
channelRouteRule: {
|
|
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
findMany: jest.fn().mockResolvedValue([
|
|
{ id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' },
|
|
{ id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' },
|
|
]),
|
|
},
|
|
};
|
|
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
|
const service = new SmsConfigService(prisma as never);
|
|
|
|
await expect(service.replaceApplicationRouteRules('app-1', {
|
|
routes: [
|
|
{ carrier: 'mobile', groupId: 'group-mobile' },
|
|
{ carrier: 'unicom', groupId: 'group-unicom' },
|
|
],
|
|
})).resolves.toEqual([
|
|
expect.objectContaining({ carrier: 'mobile' }),
|
|
expect.objectContaining({ carrier: 'unicom' }),
|
|
]);
|
|
|
|
expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({
|
|
where: { applicationId: 'app-1', channelId: null, province: null },
|
|
});
|
|
expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({
|
|
data: [
|
|
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }),
|
|
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects application routes when channel-group carrier does not match', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new SmsConfigService(prisma as never);
|
|
|
|
await expect(service.replaceApplicationRouteRules('app-1', {
|
|
routes: [{ carrier: 'telecom', groupId: 'group-mobile' }],
|
|
})).rejects.toThrow('channel group carrier must match route carrier');
|
|
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hides CMPP params when the application belongs to another tenant', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new SmsConfigService(prisma as never);
|
|
|
|
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
|
|
});
|
|
|
|
it('disconnects application CMPP connections and writes operation logs', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new SmsConfigService(prisma as never);
|
|
|
|
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
|
|
|
|
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
|
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
|
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'cmpp_connection.disconnected',
|
|
resource: 'cmpp_connection',
|
|
resourceId: 'channel-1:conn-a',
|
|
}),
|
|
});
|
|
});
|
|
|
|
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({ keyword: '签名A' })).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' },
|
|
OR: expect.any(Array),
|
|
}),
|
|
include: { materials: true, tenant: true, application: true },
|
|
}));
|
|
});
|
|
|
|
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({ keyword: '模板A' })).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' },
|
|
OR: expect.any(Array),
|
|
}),
|
|
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: '验证码${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: '验证码${code}',
|
|
variables: {
|
|
create: [{ name: 'code', example: '123456', required: true }],
|
|
},
|
|
}),
|
|
include: { variables: true, application: true, tenant: true, signature: true },
|
|
});
|
|
});
|
|
});
|