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 })), update: 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' }), update: jest.fn(), }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), }, operationLog: { create: jest.fn(), }, $transaction: jest.fn(), }; } describe('DictionariesService', () => { it('edits common configuration in place with an audit trail and rejects duplicate or inactive fields', async () => { const prisma = createPrismaMock(); const existing = { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false }; prisma.commonReportField.findUnique.mockImplementation(({ where }: { where: { id?: string } }) => Promise.resolve(where.id ? existing : null) as never); prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'active' } as never); const tx = { commonReportField: { update: jest.fn().mockResolvedValue({ ...existing, required: true }) }, operationLog: { create: jest.fn() } }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new DictionariesService(prisma as never); const body = { drainageFieldId: 'field-2', reportType: 'drainage' as const, required: true }; await service.updateCommonReportField('common-1', body, 'admin-1'); expect(tx.commonReportField.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'common-1' }, data: { ...body, sortOrder: undefined } })); expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'common_report_field.update', userId: 'admin-1' }) })); prisma.commonReportField.findUnique.mockResolvedValue({ id: 'other' } as never); await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已配置'); prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'inactive' } as never); await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用'); await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效'); }); it('reorders every common field in one report type transaction and writes an audit trail', async () => { const prisma = createPrismaMock(); const tx = { commonReportField: { update: jest.fn().mockResolvedValue({}), findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]), }, operationLog: { create: jest.fn().mockResolvedValue({}) }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new DictionariesService(prisma as never); await expect( service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'), ).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]); expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, { where: { id: 'common-2' }, data: { sortOrder: 10 }, }); expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, { where: { id: 'common-1' }, data: { sortOrder: 20 }, }); expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }), }); expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' }); tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]); await expect( service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }), ).resolves.toEqual([{ id: 'common-1' }]); await expect( service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }), ).rejects.toThrow('排序范围已变化'); }); 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('edits an unreferenced field atomically and protects mapping keys once referenced', async () => { const prisma = createPrismaMock(); const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null }; prisma.drainageField.findUnique.mockResolvedValue(existing as never); const tx = { drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) }, operationLog: { create: jest.fn().mockResolvedValue({}) }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new DictionariesService(prisma as never); await expect(service.updateDrainageField('field-1', { code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ', }, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' })); expect(tx.drainageField.update).toHaveBeenCalledWith({ where: { id: 'field-1' }, data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' }, }); expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) }); prisma.channelReportField.count.mockResolvedValue(1); await expect(service.updateDrainageField('field-1', { code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '', })).rejects.toThrow('不能修改字段代码或类型'); }); 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); }); });