import { RfmEngine, RfmContactRow, RfmEventRow, RfmComputeOptions, RfmScoredContact } from '../engine/RfmEngine' const NOW = new Date('2025-03-01T00:00:00Z') function makeContacts(count: number): RfmContactRow[] { return Array.from({ length: count }, (_, i) => ({ id: `c${i + 1}`, reachy_id: `r${i + 1}`, email: `user${i + 1}@test.com`, phone: `+5511${String(i + 1).padStart(9, '0')}`, is_subscribed: true, })) } function makeEvents(contactId: string, eventName: string, dates: string[]): RfmEventRow[] { return dates.map(d => ({ contact_id: contactId, event_name: eventName, event_timestamp: d, })) } describe('RfmEngine', () => { describe('aggregateFromEvents', () => { const contacts = makeContacts(3) const opts: RfmComputeOptions = { from: '2025-01-01T00:00:00Z', to: '2025-03-01T00:00:00Z', rfEventName: 'purchase', } it('aggregates events per contact within time window', () => { const events: RfmEventRow[] = [ ...makeEvents('c1', 'purchase', ['2025-01-15', '2025-02-20']), ...makeEvents('c2', 'purchase', ['2025-02-01']), ] const result = RfmEngine.aggregateFromEvents(contacts, events, opts) expect(result).toHaveLength(2) const c1 = result.find(r => r.contact_id === 'c1')! expect(c1.event_count).toBe(2) expect(c1.last_ts).toEqual(new Date('2025-02-20')) const c2 = result.find(r => r.contact_id === 'c2')! expect(c2.event_count).toBe(1) }) it('filters events by rfEventName', () => { const events: RfmEventRow[] = [ ...makeEvents('c1', 'purchase', ['2025-01-15']), ...makeEvents('c1', 'page_view', ['2025-01-16']), ] const result = RfmEngine.aggregateFromEvents(contacts, events, opts) expect(result).toHaveLength(1) expect(result[0].event_count).toBe(1) }) it('excludes events outside time window', () => { const events: RfmEventRow[] = [ ...makeEvents('c1', 'purchase', ['2024-12-31']), // before from ...makeEvents('c1', 'purchase', ['2025-03-02']), // after to ...makeEvents('c1', 'purchase', ['2025-02-01']), // within ] const result = RfmEngine.aggregateFromEvents(contacts, events, opts) expect(result).toHaveLength(1) expect(result[0].event_count).toBe(1) }) it('resolves contact via reachy_id when contact_id is missing', () => { const events: RfmEventRow[] = [ { reachy_id: 'r1', event_name: 'purchase', event_timestamp: '2025-02-01' }, ] const result = RfmEngine.aggregateFromEvents(contacts, events, opts) expect(result).toHaveLength(1) expect(result[0].contact_id).toBe('c1') }) it('ignores events with no resolvable contact', () => { const events: RfmEventRow[] = [ { contact_id: 'nonexistent', event_name: 'purchase', event_timestamp: '2025-02-01' }, ] const result = RfmEngine.aggregateFromEvents(contacts, events, opts) expect(result).toHaveLength(0) }) it('filters by filterContactIds when provided', () => { const events: RfmEventRow[] = [ ...makeEvents('c1', 'purchase', ['2025-02-01']), ...makeEvents('c2', 'purchase', ['2025-02-01']), ] const result = RfmEngine.aggregateFromEvents(contacts, events, { ...opts, filterContactIds: new Set(['c1']), }) expect(result).toHaveLength(1) expect(result[0].contact_id).toBe('c1') }) it('returns empty array for empty events', () => { const result = RfmEngine.aggregateFromEvents(contacts, [], opts) expect(result).toHaveLength(0) }) it('returns empty array for empty contacts', () => { const events = makeEvents('c1', 'purchase', ['2025-02-01']) const result = RfmEngine.aggregateFromEvents([], events, opts) expect(result).toHaveLength(0) }) }) describe('scoreAggregates', () => { const contacts = makeContacts(5) it('assigns R and F scores 1-5 based on percentiles', () => { const aggregates = [ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 50 }, // very recent, high freq { contact_id: 'c2', last_ts: new Date('2025-02-20'), event_count: 30 }, { contact_id: 'c3', last_ts: new Date('2025-02-01'), event_count: 10 }, { contact_id: 'c4', last_ts: new Date('2025-01-15'), event_count: 5 }, { contact_id: 'c5', last_ts: new Date('2025-01-01'), event_count: 1 }, // oldest, lowest freq ] const { scored, r_days_cuts, f_count_cuts } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW }) expect(scored).toHaveLength(5) expect(r_days_cuts).toHaveLength(4) expect(f_count_cuts).toHaveLength(4) // Most recent contact should have highest R score const c1 = scored.find(s => s.contact_id === 'c1')! expect(c1.r_score).toBe(5) expect(c1.f_score).toBe(5) // Oldest with lowest freq should have lowest scores const c5 = scored.find(s => s.contact_id === 'c5')! expect(c5.r_score).toBe(1) expect(c5.f_score).toBe(1) // All should have a valid segment key for (const s of scored) { expect(s.segment_key).toBeTruthy() expect([1, 2, 3, 4, 5]).toContain(s.r_score) expect([1, 2, 3, 4, 5]).toContain(s.f_score) } }) it('computes segment_key as "new_users" for single high-recency low-freq contact', () => { // With only 1 contact, percentile puts it at R=5, F=1 → new_users const aggregates = [ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 100 }, ] const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW }) // Single element: R=5 (most recent), F=1 (single bucket) → R=5,F=1 → new_users expect(scored[0].r_score).toBe(5) expect(scored[0].f_score).toBe(1) expect(scored[0].segment_key).toBe('new_users') }) it('determines email_ok and sms_ok from contact data', () => { const contactsWithPrefs: RfmContactRow[] = [ { id: 'c1', email: 'a@b.com', phone: '+5511999', is_subscribed: true }, { id: 'c2', email: '', phone: '', is_subscribed: false }, ] const aggregates = [ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 }, { contact_id: 'c2', last_ts: new Date('2025-02-28'), event_count: 5 }, ] const { scored } = RfmEngine.scoreAggregates(contactsWithPrefs, aggregates, { to: NOW }) const c1 = scored.find(s => s.contact_id === 'c1')! expect(c1.email_ok).toBe(1) expect(c1.sms_ok).toBe(1) const c2 = scored.find(s => s.contact_id === 'c2')! expect(c2.email_ok).toBe(0) // is_subscribed=false expect(c2.sms_ok).toBe(0) }) it('skips aggregates for contacts not in contact list', () => { const aggregates = [ { contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 }, { contact_id: 'nonexistent', last_ts: new Date('2025-02-28'), event_count: 5 }, ] const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW }) expect(scored).toHaveLength(1) }) }) describe('thresholdsFromScored', () => { it('extracts min/max per R and F score', () => { const scored: RfmScoredContact[] = [ { contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 }, { contact_id: 'c2', last_ts: new Date(), event_count: 30, recency_days: 3, r_score: 5, f_score: 4, segment_key: 'champions', email_ok: 1, sms_ok: 1 }, { contact_id: 'c3', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 1, sms_ok: 0 }, ] const thresholds = RfmEngine.thresholdsFromScored(scored) expect(thresholds.length).toBeGreaterThan(0) // Sorted by kind (f before r), then by score const first = thresholds[0] expect(first.kind).toBe('f') // R score 5 should have min=1, max=3 const r5 = thresholds.find(t => t.kind === 'r' && t.score === 5) expect(r5).toBeDefined() expect(r5!.min_value).toBe(1) expect(r5!.max_value).toBe(3) }) it('returns empty array for empty scored', () => { expect(RfmEngine.thresholdsFromScored([])).toEqual([]) }) }) describe('previewFromScored', () => { it('returns all 10 segments with counts and percentages', () => { const scored: RfmScoredContact[] = [ { contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 }, { contact_id: 'c2', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 0, sms_ok: 0 }, ] const preview = RfmEngine.previewFromScored(scored) expect(preview).toHaveLength(10) const champions = preview.find(p => p.segment_key === 'champions')! expect(champions.users).toBe(1) expect(champions.percent).toBe(50) expect(champions.email_reachability).toBe(1) const hibernating = preview.find(p => p.segment_key === 'hibernating')! expect(hibernating.users).toBe(1) expect(hibernating.email_reachability).toBe(0) // All other segments should have 0 users const empties = preview.filter(p => !['champions', 'hibernating'].includes(p.segment_key)) for (const seg of empties) { expect(seg.users).toBe(0) expect(seg.percent).toBe(0) } }) it('returns all zeros for empty scored', () => { const preview = RfmEngine.previewFromScored([]) expect(preview).toHaveLength(10) for (const seg of preview) { expect(seg.users).toBe(0) expect(seg.percent).toBe(0) } }) }) describe('computeFromEvents', () => { it('orchestrates aggregate → score → thresholds → preview', () => { const contacts = makeContacts(3) const events: RfmEventRow[] = [ ...makeEvents('c1', 'purchase', ['2025-02-28', '2025-02-25', '2025-02-20']), ...makeEvents('c2', 'purchase', ['2025-01-15']), ...makeEvents('c3', 'purchase', ['2025-01-01', '2025-01-02']), ] const opts: RfmComputeOptions = { from: '2025-01-01', to: '2025-03-01', rfEventName: 'purchase', } const { segments, thresholds, scored } = RfmEngine.computeFromEvents(contacts, events, opts) expect(scored).toHaveLength(3) expect(segments).toHaveLength(10) expect(thresholds.length).toBeGreaterThan(0) const totalUsers = segments.reduce((sum, s) => sum + s.users, 0) expect(totalUsers).toBe(3) }) }) })