import { AudienceModule } from '../AudienceModule' import { AudienceCriteria } from '../types' function mockContactRepository(contactIds: Set = new Set(['c1', 'c2'])) { return { getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(contactIds), matchesContactByAudienceCriteriaV2: jest.fn().mockImplementation( (_org: string, _proj: string, _criteria: any, contactId: string) => Promise.resolve(contactIds.has(contactId)) ), findByIds: jest.fn().mockResolvedValue({ data: Array.from(contactIds).map(id => ({ id, email: `${id}@test.com` })), error: null, }), } } function mockAudienceRepository() { return { create: jest.fn().mockResolvedValue({ data: { id: 'aud-1', name: 'Test', count: 0 }, error: null }), update: jest.fn().mockResolvedValue({ data: { id: 'aud-1', name: 'Updated' }, error: null }), updateCount: jest.fn().mockResolvedValue(undefined), findById: jest.fn().mockResolvedValue({ data: { id: 'aud-1' }, error: null }), delete: jest.fn().mockResolvedValue({ data: null, error: null }), } } function mockMemberRepository() { return { bulkUpsert: jest.fn().mockResolvedValue({ data: [], error: null }), listMembers: jest.fn().mockResolvedValue({ data: [{ id: 'c1' }], count: 1 }), } } const validCriteria: AudienceCriteria = { groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'email', op: 'contains', value: '@' }] }] } describe('AudienceModule', () => { describe('constructor', () => { it('creates instance without config', () => { const mod = new AudienceModule() expect(mod).toBeInstanceOf(AudienceModule) }) it('creates instance with supabaseClient and sets up internal repo', () => { // Just verifying no throw — actual Supabase calls are mocked in integration tests expect(() => new AudienceModule({ supabaseClient: {} })).not.toThrow() }) }) describe('setRepositories', () => { it('accepts contactRepository', () => { const mod = new AudienceModule() const repo = mockContactRepository() mod.setRepositories({ contactRepository: repo }) // No error = success }) it('accepts all repositories', () => { const mod = new AudienceModule() mod.setRepositories({ contactRepository: mockContactRepository(), audienceRepository: mockAudienceRepository(), memberRepository: mockMemberRepository(), }) }) }) describe('executeQuery', () => { it('executes static query and returns contactIds', async () => { const mod = new AudienceModule() const repo = mockContactRepository(new Set(['c1', 'c2', 'c3'])) mod.setRepositories({ contactRepository: repo }) const result = await mod.executeQuery(validCriteria, { organizationId: 'org-1', projectId: 'proj-1', }) expect(result.contactIds.size).toBe(3) expect(result.count).toBe(3) expect(result.metadata?.criteriaType).toBe('static') }) it('parses JSON string criteria', async () => { const mod = new AudienceModule() const repo = mockContactRepository() mod.setRepositories({ contactRepository: repo }) const result = await mod.executeQuery(JSON.stringify(validCriteria), { organizationId: 'org-1', projectId: 'proj-1', }) expect(result.contactIds.size).toBe(2) }) it('executes live query using memberRepository', async () => { const mod = new AudienceModule() const memberRepo = mockMemberRepository() mod.setRepositories({ memberRepository: memberRepo }) const liveCriteria: AudienceCriteria = { type: 'live-actions', groups: [{ rules: [{ kind: 'event', eventName: 'click' }] }] } const result = await mod.executeQuery(liveCriteria, { organizationId: 'org-1', projectId: 'proj-1', }) expect(result.metadata?.criteriaType).toBe('live') expect(memberRepo.listMembers).toHaveBeenCalled() }) it('throws when memberRepository is not set for live query', async () => { const mod = new AudienceModule() const liveCriteria: AudienceCriteria = { type: 'live-page-visit', groups: [] } await expect(mod.executeQuery(liveCriteria, { organizationId: 'org-1', projectId: 'proj-1', })).rejects.toThrow('MemberRepository não configurado') }) }) describe('getContactCount', () => { it('returns count of matching contacts', async () => { const mod = new AudienceModule() mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1', 'c2'])) }) const count = await mod.getContactCount(validCriteria, { organizationId: 'org-1', projectId: 'proj-1', }) expect(count).toBe(2) }) }) describe('getContactIds', () => { it('returns Set of contact IDs', async () => { const mod = new AudienceModule() const repo = mockContactRepository(new Set(['c1', 'c2'])) mod.setRepositories({ contactRepository: repo }) const ids = await mod.getContactIds(validCriteria, 'org-1', 'proj-1') expect(ids).toBeInstanceOf(Set) expect(ids.size).toBe(2) expect(repo.getContactIdsByAudienceCriteriaV2).toHaveBeenCalledWith('org-1', 'proj-1', expect.any(Object)) }) it('throws when contactRepository not configured', async () => { const mod = new AudienceModule() await expect(mod.getContactIds(validCriteria, 'org-1', 'proj-1')) .rejects.toThrow('ContactRepository não configurado') }) }) describe('matchesContact', () => { it('returns true for matching contact', async () => { const mod = new AudienceModule() mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1', 'c2'])) }) const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1') expect(result).toBe(true) }) it('returns false for non-matching contact', async () => { const mod = new AudienceModule() mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1'])) }) const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c99') expect(result).toBe(false) }) it('falls back to getContactIds when matchesContactByAudienceCriteriaV2 is missing', async () => { const mod = new AudienceModule() const repo = { getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(new Set(['c1'])), findByIds: jest.fn(), } mod.setRepositories({ contactRepository: repo }) const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1') expect(result).toBe(true) expect(repo.getContactIdsByAudienceCriteriaV2).toHaveBeenCalled() }) it('throws when contactRepository not configured', async () => { const mod = new AudienceModule() await expect(mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1')) .rejects.toThrow('ContactRepository não configurado') }) it('forwards triggerEvent to repository (used by journeys to scope event-property to the firing event)', async () => { const mod = new AudienceModule() const repo = mockContactRepository(new Set(['c1'])) mod.setRepositories({ contactRepository: repo }) const triggerEvent = { event_id: 'evt-123', event_name: 'Button Clicked', event_timestamp: '2026-04-29T07:19:05.240406+00' } await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1', { triggerEvent }) expect(repo.matchesContactByAudienceCriteriaV2).toHaveBeenCalledWith( 'org-1', 'proj-1', expect.anything(), 'c1', triggerEvent ) }) }) describe('CRUD operations', () => { it('createAudience validates and creates', async () => { const mod = new AudienceModule() const audienceRepo = mockAudienceRepository() const contactRepo = mockContactRepository() mod.setRepositories({ contactRepository: contactRepo, audienceRepository: audienceRepo }) const result = await mod.createAudience({ name: 'Test Audience', criteria: validCriteria, organization_id: 'org-1', project_id: 'proj-1', user_id: 'user-1', }) expect(result.data).toBeDefined() expect(audienceRepo.create).toHaveBeenCalled() expect(audienceRepo.updateCount).toHaveBeenCalled() }) it('createAudience throws on invalid criteria', async () => { const mod = new AudienceModule() mod.setRepositories({ audienceRepository: mockAudienceRepository() }) await expect(mod.createAudience({ name: 'Bad', criteria: {} as AudienceCriteria, organization_id: 'org-1', project_id: 'proj-1', user_id: 'user-1', })).rejects.toThrow('Critérios inválidos') }) it('createAudience throws when audienceRepository not set', async () => { const mod = new AudienceModule() await expect(mod.createAudience({ name: 'Test', criteria: validCriteria, organization_id: 'org-1', project_id: 'proj-1', user_id: 'user-1', })).rejects.toThrow('AudienceRepository não configurado') }) it('updateAudience updates and recounts', async () => { const mod = new AudienceModule() const audienceRepo = mockAudienceRepository() const contactRepo = mockContactRepository() mod.setRepositories({ contactRepository: contactRepo, audienceRepository: audienceRepo }) const result = await mod.updateAudience('aud-1', { criteria: validCriteria }, 'org-1', 'proj-1') expect(result.data).toBeDefined() expect(audienceRepo.update).toHaveBeenCalled() expect(audienceRepo.updateCount).toHaveBeenCalled() }) it('getAudienceById delegates to repository', async () => { const mod = new AudienceModule() const audienceRepo = mockAudienceRepository() mod.setRepositories({ audienceRepository: audienceRepo }) await mod.getAudienceById('aud-1', 'org-1', 'proj-1') expect(audienceRepo.findById).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1') }) it('deleteAudience delegates to repository', async () => { const mod = new AudienceModule() const audienceRepo = mockAudienceRepository() mod.setRepositories({ audienceRepository: audienceRepo }) await mod.deleteAudience('aud-1') expect(audienceRepo.delete).toHaveBeenCalledWith('aud-1') }) }) describe('hasEventRule', () => { it('returns true when event rule matches', () => { const mod = new AudienceModule() const criteria = { groups: [{ rules: [{ kind: 'event', eventName: 'purchase' }] }] } expect(mod.hasEventRule(criteria, 'purchase')).toBe(true) }) it('returns false when event name does not match', () => { const mod = new AudienceModule() const criteria = { groups: [{ rules: [{ kind: 'event', eventName: 'click' }] }] } expect(mod.hasEventRule(criteria, 'purchase')).toBe(false) }) it('returns false when no event rules exist', () => { const mod = new AudienceModule() const criteria = { groups: [{ rules: [{ kind: 'property', field: 'email', op: 'equals', value: 'x' }] }] } expect(mod.hasEventRule(criteria, 'purchase')).toBe(false) }) it('handles JSON string input', () => { const mod = new AudienceModule() const criteria = JSON.stringify({ groups: [{ rules: [{ kind: 'event', eventName: 'signup' }] }] }) expect(mod.hasEventRule(criteria, 'signup')).toBe(true) }) it('returns false for invalid JSON', () => { const mod = new AudienceModule() expect(mod.hasEventRule('invalid-json', 'signup')).toBe(false) }) it('returns false for empty criteria', () => { const mod = new AudienceModule() expect(mod.hasEventRule({}, 'signup')).toBe(false) }) }) describe('members management', () => { it('addMembers delegates to memberRepository', async () => { const mod = new AudienceModule() const memberRepo = mockMemberRepository() mod.setRepositories({ memberRepository: memberRepo }) await mod.addMembers('aud-1', ['c1', 'c2'], 'org-1', 'proj-1', 'realtime') expect(memberRepo.bulkUpsert).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', ['c1', 'c2'], 'realtime') }) it('addMembers defaults origin to "backfill"', async () => { const mod = new AudienceModule() const memberRepo = mockMemberRepository() mod.setRepositories({ memberRepository: memberRepo }) await mod.addMembers('aud-1', ['c1'], 'org-1', 'proj-1') expect(memberRepo.bulkUpsert).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', ['c1'], 'backfill') }) it('addMembers throws when memberRepository not set', async () => { const mod = new AudienceModule() await expect(mod.addMembers('aud-1', ['c1'], 'org-1', 'proj-1')) .rejects.toThrow('MemberRepository não configurado') }) it('getMembers delegates to memberRepository', async () => { const mod = new AudienceModule() const memberRepo = mockMemberRepository() mod.setRepositories({ memberRepository: memberRepo }) await mod.getMembers('aud-1', 'org-1', 'proj-1', { page: 1, limit: 10 }) expect(memberRepo.listMembers).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', { page: 1, limit: 10 }) }) }) describe('utilities', () => { it('validateCriteria returns valid for good criteria', () => { const mod = new AudienceModule() const result = mod.validateCriteria(validCriteria) expect(result.valid).toBe(true) }) it('validateCriteria returns invalid for empty criteria', () => { const mod = new AudienceModule() const result = mod.validateCriteria({ type: 'static' }) expect(result.valid).toBe(false) }) it('validateCriteria parses JSON string', () => { const mod = new AudienceModule() const result = mod.validateCriteria(JSON.stringify(validCriteria)) expect(result.valid).toBe(true) }) it('getAudienceType returns "static" for property criteria', () => { const mod = new AudienceModule() expect(mod.getAudienceType(validCriteria)).toBe('static') }) it('getAudienceType returns "live" for live criteria', () => { const mod = new AudienceModule() expect(mod.getAudienceType({ type: 'live-actions', groups: [] })).toBe('live') }) }) })