import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing'; import { PrismaService } from '../prisma/prisma.service'; const channel = { status: 'active', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], sendRegion: '全国', connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }], }; const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel })); const options = { carrier: 'mobile', excludedChannelIds: new Set(), approvedChannelIds: new Set(['a', 'b']) }; const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 }; describe('channel sensitive routing snapshot', () => { it('checks rewritten content separately for each candidate', () => { const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]); expect( snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R')) .selected?.channelId, ).toBe('a'); expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b'); }); it('removes only matching eligible channels before original priority selection', () => { const snapshot = new ChannelWordSnapshot([rule]); expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b'); expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a'); expect(snapshot.decisions[0].snapshot).toEqual( expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }), ); }); it('keeps original exclusions and distinguishes no original route from all hit', () => { const snapshot = new ChannelWordSnapshot([rule]); expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true); expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false); expect( snapshot.select( 'm', '贷款', items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })), options, ).rejected, ).toBe(false); }); it('allows national fallback when matching province channel is excluded', () => { const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]]; expect( new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected ?.channelId, ).toBe('b'); }); it('matches original complete content, case sensitively without removing separators', () => { const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]); expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1); for (const value of ['ab贷款', 'Ab贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0); }); it('caps sample words but never truncates excluded channels or hit counts', () => { const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) })); const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]); expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) })); expect(snapshot.hits('贷款')[0].samples).toHaveLength(20); expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true); }); it('reads rules once per batch and persists all decisions in one idempotent write', async () => { const prisma = { channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) }, smsChannelSensitiveDecision: { createMany: jest.fn() }, }; const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']); for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options); expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款')); await snapshot.persist(prisma as unknown as PrismaService); expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1); expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({ data: expect.any(Array), skipDuplicates: true, }); expect(snapshot.decisions).toHaveLength(100); expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100); }); it('fails closed for technical read and persistence errors, not as a word hit', async () => { const prisma = { channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) }, smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) }, }; await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 }); const snapshot = new ChannelWordSnapshot([rule]); snapshot.select('m', '贷款', items, options); await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 }); }); });