@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.
- package/.gitlab/merge_request_templates/Default.md +31 -0
- package/.gitlab-ci.yml +59 -49
- package/jest.config.js +8 -0
- package/package.json +7 -2
- package/src/__tests__/AudienceModule.test.ts +382 -0
- package/src/__tests__/CriteriaParser.test.ts +130 -0
- package/src/__tests__/QueryBuilder.test.ts +198 -0
- package/src/__tests__/RfmEngine.test.ts +284 -0
- package/src/__tests__/RfmSegmentBuilder.test.ts +210 -0
- package/src/__tests__/StaticAudienceExecutor.test.ts +134 -0
- package/src/__tests__/SupabaseContactRepository.test.ts +81 -0
|
@@ -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="
|
|
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
|
-
|
|
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
|
-
-
|
|
138
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reachy/audience-module",
|
|
3
|
-
"version": "1.0.
|
|
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
|
+
})
|