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