@reachy/audience-module 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ import { AudienceModule } from '../AudienceModule'
2
+ import { AudienceCriteria } from '../types'
3
+
4
+ function mockContactRepository(contactIds: Set<string> = new Set(['c1', 'c2'])) {
5
+ return {
6
+ getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(contactIds),
7
+ matchesContactByAudienceCriteriaV2: jest.fn().mockImplementation(
8
+ (_org: string, _proj: string, _criteria: any, contactId: string) =>
9
+ Promise.resolve(contactIds.has(contactId))
10
+ ),
11
+ findByIds: jest.fn().mockResolvedValue({
12
+ data: Array.from(contactIds).map(id => ({ id, email: `${id}@test.com` })),
13
+ error: null,
14
+ }),
15
+ }
16
+ }
17
+
18
+ function mockAudienceRepository() {
19
+ return {
20
+ create: jest.fn().mockResolvedValue({ data: { id: 'aud-1', name: 'Test', count: 0 }, error: null }),
21
+ update: jest.fn().mockResolvedValue({ data: { id: 'aud-1', name: 'Updated' }, error: null }),
22
+ updateCount: jest.fn().mockResolvedValue(undefined),
23
+ findById: jest.fn().mockResolvedValue({ data: { id: 'aud-1' }, error: null }),
24
+ delete: jest.fn().mockResolvedValue({ data: null, error: null }),
25
+ }
26
+ }
27
+
28
+ function mockMemberRepository() {
29
+ return {
30
+ bulkUpsert: jest.fn().mockResolvedValue({ data: [], error: null }),
31
+ listMembers: jest.fn().mockResolvedValue({ data: [{ id: 'c1' }], count: 1 }),
32
+ }
33
+ }
34
+
35
+ const validCriteria: AudienceCriteria = {
36
+ groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'email', op: 'contains', value: '@' }] }]
37
+ }
38
+
39
+ describe('AudienceModule', () => {
40
+ describe('constructor', () => {
41
+ it('creates instance without config', () => {
42
+ const mod = new AudienceModule()
43
+ expect(mod).toBeInstanceOf(AudienceModule)
44
+ })
45
+
46
+ it('creates instance with supabaseClient and sets up internal repo', () => {
47
+ // Just verifying no throw — actual Supabase calls are mocked in integration tests
48
+ expect(() => new AudienceModule({ supabaseClient: {} })).not.toThrow()
49
+ })
50
+ })
51
+
52
+ describe('setRepositories', () => {
53
+ it('accepts contactRepository', () => {
54
+ const mod = new AudienceModule()
55
+ const repo = mockContactRepository()
56
+ mod.setRepositories({ contactRepository: repo })
57
+ // No error = success
58
+ })
59
+
60
+ it('accepts all repositories', () => {
61
+ const mod = new AudienceModule()
62
+ mod.setRepositories({
63
+ contactRepository: mockContactRepository(),
64
+ audienceRepository: mockAudienceRepository(),
65
+ memberRepository: mockMemberRepository(),
66
+ })
67
+ })
68
+ })
69
+
70
+ describe('executeQuery', () => {
71
+ it('executes static query and returns contactIds', async () => {
72
+ const mod = new AudienceModule()
73
+ const repo = mockContactRepository(new Set(['c1', 'c2', 'c3']))
74
+ mod.setRepositories({ contactRepository: repo })
75
+
76
+ const result = await mod.executeQuery(validCriteria, {
77
+ organizationId: 'org-1',
78
+ projectId: 'proj-1',
79
+ })
80
+
81
+ expect(result.contactIds.size).toBe(3)
82
+ expect(result.count).toBe(3)
83
+ expect(result.metadata?.criteriaType).toBe('static')
84
+ })
85
+
86
+ it('parses JSON string criteria', async () => {
87
+ const mod = new AudienceModule()
88
+ const repo = mockContactRepository()
89
+ mod.setRepositories({ contactRepository: repo })
90
+
91
+ const result = await mod.executeQuery(JSON.stringify(validCriteria), {
92
+ organizationId: 'org-1',
93
+ projectId: 'proj-1',
94
+ })
95
+
96
+ expect(result.contactIds.size).toBe(2)
97
+ })
98
+
99
+ it('executes live query using memberRepository', async () => {
100
+ const mod = new AudienceModule()
101
+ const memberRepo = mockMemberRepository()
102
+ mod.setRepositories({ memberRepository: memberRepo })
103
+
104
+ const liveCriteria: AudienceCriteria = {
105
+ type: 'live-actions',
106
+ groups: [{ rules: [{ kind: 'event', eventName: 'click' }] }]
107
+ }
108
+
109
+ const result = await mod.executeQuery(liveCriteria, {
110
+ organizationId: 'org-1',
111
+ projectId: 'proj-1',
112
+ })
113
+
114
+ expect(result.metadata?.criteriaType).toBe('live')
115
+ expect(memberRepo.listMembers).toHaveBeenCalled()
116
+ })
117
+
118
+ it('throws when memberRepository is not set for live query', async () => {
119
+ const mod = new AudienceModule()
120
+ const liveCriteria: AudienceCriteria = { type: 'live-page-visit', groups: [] }
121
+
122
+ await expect(mod.executeQuery(liveCriteria, {
123
+ organizationId: 'org-1',
124
+ projectId: 'proj-1',
125
+ })).rejects.toThrow('MemberRepository não configurado')
126
+ })
127
+ })
128
+
129
+ describe('getContactCount', () => {
130
+ it('returns count of matching contacts', async () => {
131
+ const mod = new AudienceModule()
132
+ mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1', 'c2'])) })
133
+
134
+ const count = await mod.getContactCount(validCriteria, {
135
+ organizationId: 'org-1',
136
+ projectId: 'proj-1',
137
+ })
138
+
139
+ expect(count).toBe(2)
140
+ })
141
+ })
142
+
143
+ describe('getContactIds', () => {
144
+ it('returns Set of contact IDs', async () => {
145
+ const mod = new AudienceModule()
146
+ const repo = mockContactRepository(new Set(['c1', 'c2']))
147
+ mod.setRepositories({ contactRepository: repo })
148
+
149
+ const ids = await mod.getContactIds(validCriteria, 'org-1', 'proj-1')
150
+ expect(ids).toBeInstanceOf(Set)
151
+ expect(ids.size).toBe(2)
152
+ expect(repo.getContactIdsByAudienceCriteriaV2).toHaveBeenCalledWith('org-1', 'proj-1', expect.any(Object))
153
+ })
154
+
155
+ it('throws when contactRepository not configured', async () => {
156
+ const mod = new AudienceModule()
157
+ await expect(mod.getContactIds(validCriteria, 'org-1', 'proj-1'))
158
+ .rejects.toThrow('ContactRepository não configurado')
159
+ })
160
+ })
161
+
162
+ describe('matchesContact', () => {
163
+ it('returns true for matching contact', async () => {
164
+ const mod = new AudienceModule()
165
+ mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1', 'c2'])) })
166
+
167
+ const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1')
168
+ expect(result).toBe(true)
169
+ })
170
+
171
+ it('returns false for non-matching contact', async () => {
172
+ const mod = new AudienceModule()
173
+ mod.setRepositories({ contactRepository: mockContactRepository(new Set(['c1'])) })
174
+
175
+ const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c99')
176
+ expect(result).toBe(false)
177
+ })
178
+
179
+ it('falls back to getContactIds when matchesContactByAudienceCriteriaV2 is missing', async () => {
180
+ const mod = new AudienceModule()
181
+ const repo = {
182
+ getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(new Set(['c1'])),
183
+ findByIds: jest.fn(),
184
+ }
185
+ mod.setRepositories({ contactRepository: repo })
186
+
187
+ const result = await mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1')
188
+ expect(result).toBe(true)
189
+ expect(repo.getContactIdsByAudienceCriteriaV2).toHaveBeenCalled()
190
+ })
191
+
192
+ it('throws when contactRepository not configured', async () => {
193
+ const mod = new AudienceModule()
194
+ await expect(mod.matchesContact(validCriteria, 'org-1', 'proj-1', 'c1'))
195
+ .rejects.toThrow('ContactRepository não configurado')
196
+ })
197
+ })
198
+
199
+ describe('CRUD operations', () => {
200
+ it('createAudience validates and creates', async () => {
201
+ const mod = new AudienceModule()
202
+ const audienceRepo = mockAudienceRepository()
203
+ const contactRepo = mockContactRepository()
204
+ mod.setRepositories({ contactRepository: contactRepo, audienceRepository: audienceRepo })
205
+
206
+ const result = await mod.createAudience({
207
+ name: 'Test Audience',
208
+ criteria: validCriteria,
209
+ organization_id: 'org-1',
210
+ project_id: 'proj-1',
211
+ user_id: 'user-1',
212
+ })
213
+
214
+ expect(result.data).toBeDefined()
215
+ expect(audienceRepo.create).toHaveBeenCalled()
216
+ expect(audienceRepo.updateCount).toHaveBeenCalled()
217
+ })
218
+
219
+ it('createAudience throws on invalid criteria', async () => {
220
+ const mod = new AudienceModule()
221
+ mod.setRepositories({ audienceRepository: mockAudienceRepository() })
222
+
223
+ await expect(mod.createAudience({
224
+ name: 'Bad',
225
+ criteria: {} as AudienceCriteria,
226
+ organization_id: 'org-1',
227
+ project_id: 'proj-1',
228
+ user_id: 'user-1',
229
+ })).rejects.toThrow('Critérios inválidos')
230
+ })
231
+
232
+ it('createAudience throws when audienceRepository not set', async () => {
233
+ const mod = new AudienceModule()
234
+ await expect(mod.createAudience({
235
+ name: 'Test',
236
+ criteria: validCriteria,
237
+ organization_id: 'org-1',
238
+ project_id: 'proj-1',
239
+ user_id: 'user-1',
240
+ })).rejects.toThrow('AudienceRepository não configurado')
241
+ })
242
+
243
+ it('updateAudience updates and recounts', async () => {
244
+ const mod = new AudienceModule()
245
+ const audienceRepo = mockAudienceRepository()
246
+ const contactRepo = mockContactRepository()
247
+ mod.setRepositories({ contactRepository: contactRepo, audienceRepository: audienceRepo })
248
+
249
+ const result = await mod.updateAudience('aud-1', { criteria: validCriteria }, 'org-1', 'proj-1')
250
+ expect(result.data).toBeDefined()
251
+ expect(audienceRepo.update).toHaveBeenCalled()
252
+ expect(audienceRepo.updateCount).toHaveBeenCalled()
253
+ })
254
+
255
+ it('getAudienceById delegates to repository', async () => {
256
+ const mod = new AudienceModule()
257
+ const audienceRepo = mockAudienceRepository()
258
+ mod.setRepositories({ audienceRepository: audienceRepo })
259
+
260
+ await mod.getAudienceById('aud-1', 'org-1', 'proj-1')
261
+ expect(audienceRepo.findById).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1')
262
+ })
263
+
264
+ it('deleteAudience delegates to repository', async () => {
265
+ const mod = new AudienceModule()
266
+ const audienceRepo = mockAudienceRepository()
267
+ mod.setRepositories({ audienceRepository: audienceRepo })
268
+
269
+ await mod.deleteAudience('aud-1')
270
+ expect(audienceRepo.delete).toHaveBeenCalledWith('aud-1')
271
+ })
272
+ })
273
+
274
+ describe('hasEventRule', () => {
275
+ it('returns true when event rule matches', () => {
276
+ const mod = new AudienceModule()
277
+ const criteria = {
278
+ groups: [{ rules: [{ kind: 'event', eventName: 'purchase' }] }]
279
+ }
280
+ expect(mod.hasEventRule(criteria, 'purchase')).toBe(true)
281
+ })
282
+
283
+ it('returns false when event name does not match', () => {
284
+ const mod = new AudienceModule()
285
+ const criteria = {
286
+ groups: [{ rules: [{ kind: 'event', eventName: 'click' }] }]
287
+ }
288
+ expect(mod.hasEventRule(criteria, 'purchase')).toBe(false)
289
+ })
290
+
291
+ it('returns false when no event rules exist', () => {
292
+ const mod = new AudienceModule()
293
+ const criteria = {
294
+ groups: [{ rules: [{ kind: 'property', field: 'email', op: 'equals', value: 'x' }] }]
295
+ }
296
+ expect(mod.hasEventRule(criteria, 'purchase')).toBe(false)
297
+ })
298
+
299
+ it('handles JSON string input', () => {
300
+ const mod = new AudienceModule()
301
+ const criteria = JSON.stringify({
302
+ groups: [{ rules: [{ kind: 'event', eventName: 'signup' }] }]
303
+ })
304
+ expect(mod.hasEventRule(criteria, 'signup')).toBe(true)
305
+ })
306
+
307
+ it('returns false for invalid JSON', () => {
308
+ const mod = new AudienceModule()
309
+ expect(mod.hasEventRule('invalid-json', 'signup')).toBe(false)
310
+ })
311
+
312
+ it('returns false for empty criteria', () => {
313
+ const mod = new AudienceModule()
314
+ expect(mod.hasEventRule({}, 'signup')).toBe(false)
315
+ })
316
+ })
317
+
318
+ describe('members management', () => {
319
+ it('addMembers delegates to memberRepository', async () => {
320
+ const mod = new AudienceModule()
321
+ const memberRepo = mockMemberRepository()
322
+ mod.setRepositories({ memberRepository: memberRepo })
323
+
324
+ await mod.addMembers('aud-1', ['c1', 'c2'], 'org-1', 'proj-1', 'realtime')
325
+ expect(memberRepo.bulkUpsert).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', ['c1', 'c2'], 'realtime')
326
+ })
327
+
328
+ it('addMembers defaults origin to "backfill"', async () => {
329
+ const mod = new AudienceModule()
330
+ const memberRepo = mockMemberRepository()
331
+ mod.setRepositories({ memberRepository: memberRepo })
332
+
333
+ await mod.addMembers('aud-1', ['c1'], 'org-1', 'proj-1')
334
+ expect(memberRepo.bulkUpsert).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', ['c1'], 'backfill')
335
+ })
336
+
337
+ it('addMembers throws when memberRepository not set', async () => {
338
+ const mod = new AudienceModule()
339
+ await expect(mod.addMembers('aud-1', ['c1'], 'org-1', 'proj-1'))
340
+ .rejects.toThrow('MemberRepository não configurado')
341
+ })
342
+
343
+ it('getMembers delegates to memberRepository', async () => {
344
+ const mod = new AudienceModule()
345
+ const memberRepo = mockMemberRepository()
346
+ mod.setRepositories({ memberRepository: memberRepo })
347
+
348
+ await mod.getMembers('aud-1', 'org-1', 'proj-1', { page: 1, limit: 10 })
349
+ expect(memberRepo.listMembers).toHaveBeenCalledWith('aud-1', 'org-1', 'proj-1', { page: 1, limit: 10 })
350
+ })
351
+ })
352
+
353
+ describe('utilities', () => {
354
+ it('validateCriteria returns valid for good criteria', () => {
355
+ const mod = new AudienceModule()
356
+ const result = mod.validateCriteria(validCriteria)
357
+ expect(result.valid).toBe(true)
358
+ })
359
+
360
+ it('validateCriteria returns invalid for empty criteria', () => {
361
+ const mod = new AudienceModule()
362
+ const result = mod.validateCriteria({ type: 'static' })
363
+ expect(result.valid).toBe(false)
364
+ })
365
+
366
+ it('validateCriteria parses JSON string', () => {
367
+ const mod = new AudienceModule()
368
+ const result = mod.validateCriteria(JSON.stringify(validCriteria))
369
+ expect(result.valid).toBe(true)
370
+ })
371
+
372
+ it('getAudienceType returns "static" for property criteria', () => {
373
+ const mod = new AudienceModule()
374
+ expect(mod.getAudienceType(validCriteria)).toBe('static')
375
+ })
376
+
377
+ it('getAudienceType returns "live" for live criteria', () => {
378
+ const mod = new AudienceModule()
379
+ expect(mod.getAudienceType({ type: 'live-actions', groups: [] })).toBe('live')
380
+ })
381
+ })
382
+ })
@@ -0,0 +1,130 @@
1
+ import { CriteriaParser } from '../builders/CriteriaParser'
2
+ import { AudienceCriteria } from '../types'
3
+
4
+ describe('CriteriaParser', () => {
5
+ describe('parse', () => {
6
+ it('parses a valid JSON string into criteria object', () => {
7
+ const json = JSON.stringify({ groups: [{ rules: [{ field: 'email', op: 'equals', value: 'a@b.com' }] }] })
8
+ const result = CriteriaParser.parse(json)
9
+ expect(result.groups).toBeDefined()
10
+ expect(result.groups![0].rules![0].value).toBe('a@b.com')
11
+ })
12
+
13
+ it('throws on invalid JSON string', () => {
14
+ expect(() => CriteriaParser.parse('not-json')).toThrow('Critérios inválidos')
15
+ })
16
+
17
+ it('passes through an object unchanged if it has groups', () => {
18
+ const criteria: AudienceCriteria = {
19
+ groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'email', op: 'equals', value: 'x' }] }]
20
+ }
21
+ const result = CriteriaParser.parse(criteria)
22
+ expect(result).toEqual(criteria)
23
+ })
24
+
25
+ it('normalizes filters format into conditions', () => {
26
+ const criteria: AudienceCriteria = {
27
+ filters: [
28
+ {
29
+ id: 'f1',
30
+ operator: 'AND',
31
+ conditions: [
32
+ { id: 'c1', field: 'email', operator: 'equals', value: 'test@x.com', logicalOperator: 'AND' }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ const result = CriteriaParser.parse(criteria)
38
+ expect(result.conditions).toBeDefined()
39
+ expect(result.conditions!.length).toBe(1)
40
+ expect(result.conditions![0].groupId).toBe('f1')
41
+ expect(result.conditions![0].operator).toBe('AND')
42
+ expect(result.conditions![0].conditions![0].field).toBe('email')
43
+ })
44
+
45
+ it('normalizes filter conditions with customFieldKey', () => {
46
+ const criteria: AudienceCriteria = {
47
+ filters: [{
48
+ id: 'f1',
49
+ operator: 'OR',
50
+ conditions: [{
51
+ id: 'c1',
52
+ field: 'custom_field',
53
+ operator: 'contains',
54
+ value: 'vip',
55
+ logicalOperator: 'OR',
56
+ customFieldKey: 'plan_type'
57
+ }]
58
+ }]
59
+ }
60
+ const result = CriteriaParser.parse(criteria)
61
+ expect(result.conditions![0].conditions![0].customFieldKey).toBe('plan_type')
62
+ })
63
+
64
+ it('passes through conditions format unchanged', () => {
65
+ const criteria: AudienceCriteria = {
66
+ conditions: [{ groupId: 'g1', operator: 'AND', conditions: [{ field: 'name', operator: 'equals', value: 'John' }] }]
67
+ }
68
+ const result = CriteriaParser.parse(criteria)
69
+ expect(result.conditions).toEqual(criteria.conditions)
70
+ })
71
+ })
72
+
73
+ describe('getAudienceType', () => {
74
+ it('returns "static" for criteria without type', () => {
75
+ expect(CriteriaParser.getAudienceType({})).toBe('static')
76
+ })
77
+
78
+ it('returns "static" for unknown type', () => {
79
+ expect(CriteriaParser.getAudienceType({ type: 'past-behavior' })).toBe('static')
80
+ })
81
+
82
+ it.each([
83
+ 'live-actions',
84
+ 'live-page-visit',
85
+ 'live-referrer',
86
+ 'live-page-count',
87
+ ])('returns "live" for type "%s"', (type) => {
88
+ expect(CriteriaParser.getAudienceType({ type })).toBe('live')
89
+ })
90
+ })
91
+
92
+ describe('validate', () => {
93
+ it('returns valid for criteria with groups', () => {
94
+ const result = CriteriaParser.validate({ groups: [] })
95
+ expect(result.valid).toBe(true)
96
+ expect(result.errors).toHaveLength(0)
97
+ })
98
+
99
+ it('returns valid for criteria with filters', () => {
100
+ const result = CriteriaParser.validate({
101
+ filters: [{ id: '1', operator: 'AND', conditions: [] }]
102
+ } as AudienceCriteria)
103
+ expect(result.valid).toBe(true)
104
+ })
105
+
106
+ it('returns valid for criteria with conditions', () => {
107
+ const result = CriteriaParser.validate({
108
+ conditions: [{ groupId: 'g1', operator: 'AND', conditions: [] }]
109
+ })
110
+ expect(result.valid).toBe(true)
111
+ })
112
+
113
+ it('returns invalid when no conditions, filters, or groups exist', () => {
114
+ const result = CriteriaParser.validate({ type: 'static' })
115
+ expect(result.valid).toBe(false)
116
+ expect(result.errors).toContain('Critérios devem conter conditions, filters ou groups')
117
+ })
118
+
119
+ it('returns invalid for null-ish input', () => {
120
+ const result = CriteriaParser.validate(null as any)
121
+ expect(result.valid).toBe(false)
122
+ expect(result.errors).toContain('Critérios devem ser um objeto')
123
+ })
124
+
125
+ it('returns invalid for non-object input', () => {
126
+ const result = CriteriaParser.validate('string' as any)
127
+ expect(result.valid).toBe(false)
128
+ })
129
+ })
130
+ })