@reachy/audience-module 1.0.19 → 1.0.20

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,31 @@
1
+ ## Descrição
2
+
3
+ <!-- Descreva de forma clara o que foi feito e por quê -->
4
+
5
+
6
+
7
+ ## Tipo de mudança
8
+
9
+ - [ ] 🐛 Bug fix
10
+ - [ ] ✨ Nova funcionalidade
11
+ - [ ] ♻️ Refatoração
12
+ - [ ] 📦 Dependências
13
+ - [ ] ⚙️ CI/CD
14
+ - [ ] 📝 Documentação
15
+
16
+ ## O que foi alterado
17
+
18
+ -
19
+ -
20
+
21
+ ## Como testar
22
+
23
+ 1.
24
+ 2.
25
+
26
+ ## Checklist
27
+
28
+ - [ ] Código segue os padrões do projeto
29
+ - [ ] Testei localmente e funciona
30
+ - [ ] Não introduz breaking changes
31
+ - [ ] Commits seguem Conventional Commits
package/.gitlab-ci.yml CHANGED
@@ -1,60 +1,49 @@
1
1
  image: node:20
2
2
 
3
+ workflow:
4
+ rules:
5
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
6
+ - if: '$CI_COMMIT_BRANCH == "main"'
7
+ - if: '$CI_COMMIT_BRANCH'
8
+ when: always
9
+
3
10
  variables:
4
11
  GIT_DEPTH: 0
5
12
  GIT_STRATEGY: fetch
6
13
 
14
+ include:
15
+ # Módulos de qualidade
16
+ - project: 'kontrole1/cicd/pipeline'
17
+ file: 'ci/templates/claude-review.yml'
18
+ ref: 'main'
19
+ - project: 'kontrole1/cicd/pipeline'
20
+ file: 'ci/templates/mr-description-check.yml'
21
+ ref: 'main'
22
+ - project: 'kontrole1/cicd/pipeline'
23
+ file: 'ci/templates/commit-lint.yml'
24
+ ref: 'main'
25
+ - project: 'kontrole1/cicd/pipeline'
26
+ file: 'ci/templates/security.yml'
27
+ ref: 'main'
28
+ - project: 'kontrole1/cicd/pipeline'
29
+ file: 'ci/templates/review-gate.yml'
30
+ ref: 'main'
31
+ - project: 'kontrole1/cicd/pipeline'
32
+ file: 'ci/templates/approval-check.yml'
33
+ ref: 'main'
34
+ - project: 'kontrole1/cicd/pipeline'
35
+ file: 'ci/templates/notifications.yml'
36
+ ref: 'main'
37
+
7
38
  stages:
39
+ - .pre
40
+ - review
8
41
  - test
9
42
  - build
10
43
  - tag
11
44
  - publish
12
45
  - notify
13
46
 
14
- .slack-notify-template:
15
- image: alpine:3.20
16
- before_script:
17
- - apk add --no-cache curl jq
18
- - |
19
- slack_post () {
20
- [ -z "${SLACK_WEBHOOK_URL:-}" ] && { echo "SLACK_WEBHOOK_URL não configurada; pulando notificação."; return 0; }
21
- local color="$1" title="$2" emoji="$3" msg="$4"
22
- local short_sha="${CI_COMMIT_SHA:0:8}"
23
- [ -f build_info.env ] && set -a && . build_info.env && set +a || true
24
- payload="$(
25
- jq -n \
26
- --arg ch "${SLACK_CHANNEL:-}" \
27
- --arg color "$color" --arg title "$title" --arg emoji "$emoji" \
28
- --arg project "$CI_PROJECT_PATH" --arg branch "$CI_COMMIT_REF_NAME" \
29
- --arg sha "$short_sha" --arg actor "${GITLAB_USER_NAME:-ci}" \
30
- --arg job_url "$CI_JOB_URL" --arg pipe_url "$CI_PIPELINE_URL" \
31
- --arg image "${NEW_IMAGE:-${IMAGE_REPO:-}<unknown>:${IMAGE_TAG:-}}" \
32
- --arg service "${PORTAINER_SERVICE_NAME:-<desconhecido>}" \
33
- --arg msg "$msg" '
34
- def base:
35
- { attachments: [ { color: $color, blocks: [
36
- { "type":"header", "text": { "type":"plain_text", "text": ($emoji+" "+$title) } },
37
- { "type":"section", "fields": [
38
- { "type":"mrkdwn", "text": ("*Projeto:*\n"+$project) },
39
- { "type":"mrkdwn", "text": ("*Branch:*\n"+$branch) },
40
- { "type":"mrkdwn", "text": ("*Commit:*\n"+$sha) },
41
- { "type":"mrkdwn", "text": ("*Autor:*\n"+$actor) },
42
- { "type":"mrkdwn", "text": ("*Serviço:*\n"+$service) },
43
- { "type":"mrkdwn", "text": ("*Imagem:*\n"+$image) }
44
- ] },
45
- { "type":"actions", "elements": [
46
- { "type":"button", "text": { "type":"plain_text","text":"Ver Job" }, "url": $job_url },
47
- { "type":"button", "text": { "type":"plain_text","text":"Ver Pipeline" }, "url": $pipe_url }
48
- ] }
49
- ] } ] };
50
- def add_msg(obj):
51
- if ($msg|length) > 0 then obj | .attachments[0].blocks += [ { "type":"context", "elements":[ { "type":"mrkdwn", "text": $msg } ] } ] else obj end;
52
- base | (if ($ch|length) > 0 then . + {channel:$ch} else . end) | add_msg(.)
53
- '
54
- )"
55
- curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null || true
56
- }
57
-
58
47
  default:
59
48
  cache:
60
49
  key: ${CI_COMMIT_REF_SLUG}
@@ -65,6 +54,17 @@ default:
65
54
  tags:
66
55
  - docker
67
56
 
57
+ # Security scans
58
+ sast:
59
+ extends: .sast-scan
60
+
61
+ dependency-scan:
62
+ extends: .dependency-scan
63
+
64
+ secret-detection:
65
+ extends: .secret-detection
66
+
67
+ # Pipeline original preservada
68
68
  lint_and_test:
69
69
  stage: test
70
70
  script:
@@ -104,7 +104,7 @@ create_tag:
104
104
  - |
105
105
  AUTH_USER="oauth2"
106
106
  AUTH_TOKEN="$GITLAB_TOKEN"
107
- REPO_URL="https://${AUTH_USER}:${AUTH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
107
+ REPO_URL="${CI_SERVER_PROTOCOL}://${AUTH_USER}:${AUTH_TOKEN}@${CI_SERVER_HOST}${CI_SERVER_PORT:+:$CI_SERVER_PORT}/${CI_PROJECT_PATH}.git"
108
108
  git tag "$TAG"
109
109
  - git push "$REPO_URL" "$TAG"
110
110
  rules:
@@ -124,15 +124,25 @@ publish_npm:
124
124
  when: manual
125
125
  allow_failure: false
126
126
 
127
+ # Notificação unificada (Slack + Roam)
127
128
  notify:publish:
128
- stage: notify
129
- extends: [.slack-notify-template]
129
+ extends: .notify-success
130
130
  needs:
131
131
  - job: publish_npm
132
+ script:
133
+ - VERSION=$(jq -r '.version' package.json 2>/dev/null || echo "?")
134
+ - notify_all "#2EB67D" "Publicação npm @reachy/audience-module v${VERSION}" "✅" "Pacote publicado a partir da main."
132
135
  rules:
133
136
  - if: '$CI_COMMIT_BRANCH == "main"'
134
137
  when: on_success
135
138
  - when: never
139
+
140
+ notify:failure:pipeline:
141
+ extends: .notify-failure
142
+ needs: []
136
143
  script:
137
- - VERSION=$(jq -r '.version' package.json)
138
- - slack_post "#2EB67D" "Publicação npm concluída" ":white_check_mark:" "Pacote publicado a partir do branch main. Versão \`${VERSION}\`."
144
+ - notify_all "#E01E5A" "Pipeline Audience Module falhou" "❌" ""
145
+ rules:
146
+ - if: '$CI_COMMIT_BRANCH == "main"'
147
+ when: on_failure
148
+ - when: never
package/jest.config.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ roots: ['<rootDir>/src'],
6
+ testMatch: ['**/*.test.ts'],
7
+ moduleFileExtensions: ['ts', 'js', 'json'],
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reachy/audience-module",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "description": "Módulo reutilizável para consultas e criação de audiences",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,6 +8,9 @@
8
8
  "build": "tsc",
9
9
  "watch": "tsc --watch",
10
10
  "clean": "rm -rf dist",
11
+ "test": "jest",
12
+ "test:watch": "jest --watch",
13
+ "test:coverage": "jest --coverage",
11
14
  "prepublishOnly": "npm run clean && npm run build"
12
15
  },
13
16
  "keywords": [
@@ -23,7 +26,10 @@
23
26
  "@supabase/supabase-js": "^2.x"
24
27
  },
25
28
  "devDependencies": {
29
+ "@types/jest": "^30.0.0",
26
30
  "@types/node": "^20.0.0",
31
+ "jest": "^30.3.0",
32
+ "ts-jest": "^29.4.9",
27
33
  "typescript": "^5.0.0"
28
34
  },
29
35
  "repository": {
@@ -34,4 +40,3 @@
34
40
  "access": "public"
35
41
  }
36
42
  }
37
-
@@ -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
+ })