Files
lislgosms/api/src/dictionaries/dictionaries.service.spec.ts
T

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);
});
});