311 lines
15 KiB
TypeScript
311 lines
15 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
import { DictionariesService } from './dictionaries.service';
|
|
|
|
function createPrismaMock() {
|
|
return {
|
|
phoneSegment: {
|
|
findMany: jest.fn(),
|
|
count: jest.fn().mockResolvedValue(3),
|
|
delete: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
|
},
|
|
phoneCarrierRule: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'rule-1', ...data })),
|
|
delete: jest.fn().mockResolvedValue({ id: 'rule-1' }),
|
|
},
|
|
sensitiveWord: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
|
},
|
|
globalBlacklist: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
|
},
|
|
enterpriseBlacklist: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
|
},
|
|
drainageField: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
|
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
|
},
|
|
channelReportField: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
deleteMany: jest.fn(),
|
|
},
|
|
commonReportField: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
|
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
|
},
|
|
smsApplication: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
|
},
|
|
operationLog: {
|
|
create: jest.fn(),
|
|
},
|
|
$transaction: jest.fn(),
|
|
};
|
|
}
|
|
|
|
describe('DictionariesService', () => {
|
|
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
|
{ province: '山东', city: '青岛' },
|
|
{ province: '山东', city: '济南' },
|
|
{ province: '山东', city: '济南' },
|
|
{ province: '江苏', city: '苏州' },
|
|
{ province: ' ', city: '无效' },
|
|
]);
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.listAdministrativeRegions()).resolves.toEqual([
|
|
{ province: '江苏', cities: ['苏州'] },
|
|
{ province: '山东', cities: ['济南', '青岛'] },
|
|
]);
|
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
|
where: { province: { not: null } },
|
|
select: { province: true, city: true },
|
|
distinct: ['province', 'city'],
|
|
});
|
|
});
|
|
|
|
it('deletes a phone segment from the real dictionary table', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await service.deletePhoneSegment('segment-1');
|
|
|
|
expect(prisma.phoneSegment.delete).toHaveBeenCalledWith({ where: { id: 'segment-1' } });
|
|
});
|
|
|
|
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license' }]);
|
|
prisma.channelReportField.findMany.mockResolvedValue([
|
|
{ drainageFieldId: 'field-1', channelId: 'channel-1' },
|
|
{ drainageFieldId: 'field-1', channelId: 'channel-1' },
|
|
{ drainageFieldId: 'field-1', channelId: 'channel-2' },
|
|
]);
|
|
prisma.channelReportField.count.mockResolvedValue(2);
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2, commonUsageCount: 0 }]);
|
|
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
|
|
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
|
|
expect(prisma.channelReportField.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
drainageFieldId: { in: ['field-1'] },
|
|
channel: { status: { not: 'deleted' } },
|
|
},
|
|
select: { drainageFieldId: true, channelId: true },
|
|
});
|
|
});
|
|
|
|
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
|
const prisma = createPrismaMock();
|
|
const tx = {
|
|
channelReportField: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
|
drainageField: { delete: jest.fn().mockResolvedValue({ id: 'field-1' }) },
|
|
};
|
|
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.deleteDrainageField('field-1')).resolves.toEqual({ id: 'field-1' });
|
|
|
|
expect(prisma.channelReportField.count).toHaveBeenCalledWith({
|
|
where: { drainageFieldId: 'field-1', channel: { status: { not: 'deleted' } } },
|
|
});
|
|
expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({
|
|
where: { drainageFieldId: 'field-1', channel: { status: 'deleted' } },
|
|
});
|
|
expect(tx.drainageField.delete).toHaveBeenCalledWith({ where: { id: 'field-1' } });
|
|
});
|
|
|
|
it('creates and deletes real common signature and drainage field configurations', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.drainageField.findUnique = jest.fn().mockResolvedValue({ id: 'field-1', code: 'license', required: false, status: 'active' });
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await service.createCommonReportField({ drainageFieldId: 'field-1', reportType: 'signature', required: true });
|
|
await service.deleteCommonReportField('common-1');
|
|
|
|
expect(prisma.commonReportField.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ drainageFieldId: 'field-1', reportType: 'signature', required: true, status: 'active' }),
|
|
include: { drainageField: true },
|
|
});
|
|
expect(prisma.commonReportField.delete).toHaveBeenCalledWith({ where: { id: 'common-1' } });
|
|
});
|
|
|
|
it('blocks deleting a field referenced by a common configuration', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.commonReportField.count.mockResolvedValue(1);
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('通用配置');
|
|
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
|
|
});
|
|
it('paginates phone segments with a real database count', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
|
{ id: 'segment-1', prefix: '1300001', carrier: '中国联通', province: '江苏', city: '常州' },
|
|
{ id: 'segment-2', prefix: '1300002', carrier: '中国联通', province: '安徽', city: '合肥' },
|
|
{ id: 'segment-3', prefix: '1300003', carrier: '中国联通', province: '四川', city: '宜宾' },
|
|
]);
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.listPhoneSegments({ keyword: '中国联通', page: 2, pageSize: 2 })).resolves.toEqual({
|
|
items: expect.arrayContaining([
|
|
expect.objectContaining({ prefix: '1300001' }),
|
|
expect.objectContaining({ prefix: '1300002' }),
|
|
]),
|
|
pageSize: 2,
|
|
page: 2,
|
|
total: 3,
|
|
});
|
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
OR: expect.any(Array),
|
|
},
|
|
orderBy: { prefix: 'asc' },
|
|
skip: 2,
|
|
take: 2,
|
|
});
|
|
expect(prisma.phoneSegment.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
|
});
|
|
|
|
it('searches security control dictionaries with keyword and status filters', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
|
|
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
|
|
await service.listEnterpriseBlacklist({ enterpriseKeyword: '租户', applicationKeyword: '应用', phoneNumber: '138', reasonKeyword: '投诉', status: 'active' });
|
|
|
|
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
|
}));
|
|
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
|
}));
|
|
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, phoneNumber: { contains: '138' }, reason: { contains: '投诉' } }),
|
|
include: { tenant: true, application: true },
|
|
}));
|
|
});
|
|
|
|
it('excludes soft-deleted security entries even when no status or deleted is requested', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await service.listSensitiveWords();
|
|
await service.listGlobalBlacklist({ status: 'deleted' });
|
|
await service.listEnterpriseBlacklist();
|
|
|
|
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
|
}));
|
|
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
|
}));
|
|
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
where: expect.objectContaining({ status: { not: 'deleted' } }),
|
|
}));
|
|
});
|
|
|
|
it('paginates carrier rules with a real database count', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
|
|
prisma.phoneCarrierRule.count.mockResolvedValue(26);
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.listPhoneCarrierRules({ keyword: '13', page: 2, pageSize: 25 })).resolves.toEqual(expect.objectContaining({ total: 26, page: 2, pageSize: 25 }));
|
|
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 25, take: 25, where: { OR: expect.any(Array) } }));
|
|
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
|
|
});
|
|
|
|
it('invalidates the sending cache after carrier rule changes', async () => {
|
|
const prisma = createPrismaMock();
|
|
const phoneRoutingLookup = { invalidateCarrierRules: jest.fn() };
|
|
const service = new DictionariesService(prisma as never, phoneRoutingLookup as never);
|
|
|
|
await service.createPhoneCarrierRule({ carrier: 'mobile', pattern: '^138' });
|
|
await service.deletePhoneCarrierRule('rule-1');
|
|
|
|
expect(phoneRoutingLookup.invalidateCarrierRules).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await service.createSensitiveWord({ word: '高息贷款', level: 'high' });
|
|
await service.createGlobalBlacklist({ phoneNumber: '13800000000', reason: '投诉', operatorId: 'admin-1' });
|
|
await service.createEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000', reason: '退订', operatorId: 'admin-1' });
|
|
await service.changeSensitiveWordStatus('word-1', { status: 'deleted' });
|
|
await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' });
|
|
await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' });
|
|
|
|
expect(prisma.operationLog.create).toHaveBeenCalledTimes(6);
|
|
expect(prisma.enterpriseBlacklist.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000' }),
|
|
});
|
|
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
|
|
});
|
|
|
|
it.each([
|
|
['global', 'globalBlacklist', 'createGlobalBlacklist', { phoneNumber: '13800000000', reason: '投诉' }],
|
|
['enterprise', 'enterpriseBlacklist', 'createEnterpriseBlacklist', {
|
|
tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000', reason: '退订',
|
|
}],
|
|
] as const)('maps a duplicate %s blacklist entry, including a soft-deleted row, to HTTP 409', async (_scope, model, method, input) => {
|
|
const prisma = createPrismaMock();
|
|
prisma[model].create.mockRejectedValue({ code: 'P2002', meta: { target: ['phoneNumber'] } });
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service[method](input as never)).rejects.toBeInstanceOf(ConflictException);
|
|
await expect(service[method](input as never)).rejects.toMatchObject({
|
|
response: expect.objectContaining({ code: 'BLACKLIST_DUPLICATE', field: 'phoneNumber' }),
|
|
});
|
|
});
|
|
|
|
it('only accepts string, image and file report field types', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.createDrainageField({ code: 'license', name: '营业执照', fieldType: 'image' })).resolves.toEqual(
|
|
expect.objectContaining({ fieldType: 'image' }),
|
|
);
|
|
expect(() => service.createDrainageField({ code: 'amount', name: '数量', fieldType: 'number' as never })).toThrow(
|
|
'fieldType must be string, image or file',
|
|
);
|
|
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('only accepts Arabic numerals and English letters in report field codes', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new DictionariesService(prisma as never);
|
|
|
|
await expect(service.createDrainageField({ code: 'License2026', name: '营业执照', fieldType: 'image' })).resolves.toEqual(
|
|
expect.objectContaining({ code: 'License2026' }),
|
|
);
|
|
expect(() => service.createDrainageField({ code: 'license_code', name: '营业执照', fieldType: 'image' })).toThrow(
|
|
'code must contain only Arabic numerals and English letters',
|
|
);
|
|
expect(() => service.createDrainageField({ code: '营业执照', name: '营业执照', fieldType: 'image' })).toThrow(
|
|
'code must contain only Arabic numerals and English letters',
|
|
);
|
|
expect(prisma.drainageField.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|