@learncard/network-brain-service 4.0.4 → 4.1.1

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.
Files changed (175) hide show
  1. package/.env.example +14 -0
  2. package/AGENTS.md +16 -0
  3. package/CHANGELOG.md +54 -0
  4. package/config/config.dev.json +5 -0
  5. package/config/config.example.json +5 -0
  6. package/config/config.production.json +5 -0
  7. package/config/config.scouts.dev.json +5 -0
  8. package/config/config.scouts.production.json +5 -0
  9. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  10. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  11. package/dist/accesslayer/campaign/create.d.ts +3 -0
  12. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  13. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  14. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/campaign/read.d.ts +6 -0
  16. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  17. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  18. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  19. package/dist/accesslayer/campaign/state.d.ts +4 -0
  20. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/update.d.ts +3 -0
  22. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  24. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  25. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  26. package/dist/accesslayer/skill/read.d.ts +12 -2
  27. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  28. package/dist/accesslayer/skill/update.d.ts +11 -1
  29. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  30. package/dist/app.d.ts +4 -0
  31. package/dist/app.d.ts.map +1 -1
  32. package/dist/cache/app-store.caches.d.ts +2 -1
  33. package/dist/cache/app-store.caches.d.ts.map +1 -1
  34. package/dist/config/environment.d.ts +268 -178
  35. package/dist/config/environment.d.ts.map +1 -1
  36. package/dist/config/skillEmbedding.d.ts +70 -0
  37. package/dist/config/skillEmbedding.d.ts.map +1 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  39. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  40. package/dist/config/stageConfig.d.ts +20 -0
  41. package/dist/config/stageConfig.d.ts.map +1 -1
  42. package/dist/constants/campaigns.d.ts +3 -0
  43. package/dist/constants/campaigns.d.ts.map +1 -0
  44. package/dist/didkit_wasm_bg.wasm +0 -0
  45. package/dist/helpers/app-credential-delivery.helpers.d.ts +4 -0
  46. package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
  47. package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
  48. package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -0
  49. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  50. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  51. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  52. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  53. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  54. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  55. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  56. package/dist/helpers/sentry.helpers.d.ts.map +1 -1
  57. package/dist/helpers/sentry.helpers.test.d.ts +2 -0
  58. package/dist/helpers/sentry.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  60. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  61. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  63. package/dist/index.js +2 -2
  64. package/dist/models/Campaign.d.ts +10 -0
  65. package/dist/models/Campaign.d.ts.map +1 -0
  66. package/dist/models/CredentialSharePreview.d.ts +14 -0
  67. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  68. package/dist/models/Integration.d.ts +1 -1
  69. package/dist/models/Profile.d.ts.map +1 -1
  70. package/dist/models/campaign-constraints.d.ts +2 -0
  71. package/dist/models/campaign-constraints.d.ts.map +1 -0
  72. package/dist/models/index.d.ts +2 -0
  73. package/dist/models/index.d.ts.map +1 -1
  74. package/dist/publish-metafile.json +1 -1
  75. package/dist/routes/app-store.d.ts +1 -1
  76. package/dist/routes/app-store.d.ts.map +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  103. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  104. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  106. package/dist/types/app-manifest.d.ts +2 -2
  107. package/dist/types/campaign.d.ts +19 -0
  108. package/dist/types/campaign.d.ts.map +1 -0
  109. package/dist/types/consentflowcontract.d.ts +1 -1
  110. package/dist/types/integration.d.ts +3 -3
  111. package/dist/types/profile.d.ts +9 -0
  112. package/dist/types/profile.d.ts.map +1 -1
  113. package/dist/types/skill.d.ts.map +1 -1
  114. package/package.json +21 -18
  115. package/project.json +15 -1
  116. package/scripts/skill-embeddings-backfill.ts +161 -0
  117. package/serverless-local.yml +4 -0
  118. package/serverless.function-env.cjs +3 -0
  119. package/serverless.local.yml +4 -0
  120. package/serverless.yml +12 -1
  121. package/src/accesslayer/campaign/README.md +174 -0
  122. package/src/accesslayer/campaign/audience.ts +152 -0
  123. package/src/accesslayer/campaign/create.ts +47 -0
  124. package/src/accesslayer/campaign/delete.ts +18 -0
  125. package/src/accesslayer/campaign/read.ts +125 -0
  126. package/src/accesslayer/campaign/shared.ts +92 -0
  127. package/src/accesslayer/campaign/state.ts +80 -0
  128. package/src/accesslayer/campaign/update.ts +50 -0
  129. package/src/accesslayer/credential-share-previews.ts +76 -0
  130. package/src/accesslayer/profile/delete.ts +6 -0
  131. package/src/accesslayer/skill/read.ts +56 -16
  132. package/src/accesslayer/skill/update.ts +43 -1
  133. package/src/app.ts +9 -0
  134. package/src/config/environment.test.ts +208 -0
  135. package/src/config/environment.ts +166 -11
  136. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  137. package/src/config/skillEmbedding.ts +202 -0
  138. package/src/constants/campaigns.ts +9 -0
  139. package/src/helpers/activity.helpers.ts +3 -3
  140. package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
  141. package/src/helpers/app-credential-delivery.helpers.ts +11 -0
  142. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  143. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  144. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  145. package/src/helpers/boost-storage.helpers.test.ts +63 -0
  146. package/src/helpers/boost.helpers.ts +23 -18
  147. package/src/helpers/sentry.helpers.test.ts +56 -0
  148. package/src/helpers/sentry.helpers.ts +4 -3
  149. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  150. package/src/helpers/skill-embedding.helpers.ts +611 -123
  151. package/src/models/Campaign.ts +32 -0
  152. package/src/models/CredentialSharePreview.ts +30 -0
  153. package/src/models/Profile.ts +1 -0
  154. package/src/models/campaign-constraints.ts +22 -0
  155. package/src/models/index.ts +29 -1
  156. package/src/routes/app-store.ts +9 -16
  157. package/src/routes/campaigns.ts +179 -0
  158. package/src/routes/credential-share-previews.ts +74 -0
  159. package/src/routes/profiles.ts +7 -0
  160. package/src/routes/skills-semantic-search.test.ts +168 -0
  161. package/src/routes/skills.ts +42 -6
  162. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  163. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  164. package/src/seed/seedSkillFrameworks.ts +83 -18
  165. package/src/types/campaign.ts +17 -0
  166. package/src/types/skill.ts +6 -0
  167. package/test/app-credential-delivery.neo4j.spec.ts +110 -0
  168. package/test/campaigns.spec.ts +789 -0
  169. package/test/credential-share-previews.spec.ts +97 -0
  170. package/test/verification/campaigns.ts +49 -0
  171. package/tsconfig.json +13 -13
  172. package/vitest.campaigns.config.ts +21 -0
  173. package/vitest.config.ts +12 -0
  174. package/vitest.inbox-e2e.config.ts +1 -0
  175. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,789 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
3
+ import {
4
+ CampaignAudienceSourceValidator,
5
+ type CampaignAudienceInput,
6
+ type CreateCampaignInput,
7
+ } from '@learncard/types';
8
+
9
+ import { neogma } from '@instance';
10
+ import cache from '@cache';
11
+ import { campaignsRouter } from '@routes/campaigns';
12
+ import { getLiveCampaignsForProfile } from '@accesslayer/campaign/read';
13
+ import { campaignTimeParams } from '@accesslayer/campaign/shared';
14
+ import { constructUri } from '@helpers/uri.helpers';
15
+ import { ensureCampaignConstraints } from '../src/models/campaign-constraints';
16
+
17
+ const domain = 'localhost%3A3000';
18
+ const tenant = resolveTenantFromRequest({}, 'learncard');
19
+ const did = (profileId: string): string => `did:key:${profileId}`;
20
+ const caller = (profileId: string, scope = '*:*', callerDomain = domain) =>
21
+ campaignsRouter.createCaller({
22
+ domain: callerDomain,
23
+ tenant,
24
+ user: { did: did(profileId), scope, isChallengeValid: true },
25
+ });
26
+ const admin = caller('campaign-admin');
27
+ const learner = caller('learner');
28
+ const other = caller('other');
29
+ const draft = (overrides: Partial<CreateCampaignInput> = {}): CreateCampaignInput => ({
30
+ id: 'pilot-clr',
31
+ name: 'Pilot campaign',
32
+ channels: ['inApp'],
33
+ message: { title: 'Welcome' },
34
+ ...overrides,
35
+ });
36
+ const audience = (source: CampaignAudienceInput['source']): CampaignAudienceInput => ({
37
+ campaignId: 'pilot-clr',
38
+ source,
39
+ });
40
+ const addLearner = async (): Promise<void> => {
41
+ await admin.addAudience(audience({ type: 'dids', dids: [did('learner')] }));
42
+ };
43
+
44
+ beforeAll(async () => {
45
+ await ensureCampaignConstraints();
46
+ });
47
+ beforeEach(async () => {
48
+ await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
49
+ await neogma.queryRunner.run(
50
+ `UNWIND $profiles AS properties CREATE (p:Profile) SET p = properties`,
51
+ {
52
+ profiles: [
53
+ 'campaign-admin',
54
+ 'learner',
55
+ 'other',
56
+ 'pending',
57
+ 'revoked',
58
+ 'minor',
59
+ 'child',
60
+ 'unknown-dob',
61
+ 'managed-adult',
62
+ 'managed-service',
63
+ ].map(profileId => ({
64
+ profileId,
65
+ did: did(profileId),
66
+ displayName: profileId,
67
+ shortBio: '',
68
+ bio: '',
69
+ type:
70
+ profileId === 'child'
71
+ ? 'child'
72
+ : profileId === 'managed-service'
73
+ ? 'organization'
74
+ : 'person',
75
+ isServiceProfile: profileId === 'managed-service',
76
+ dob:
77
+ profileId === 'minor'
78
+ ? '2020-01-01'
79
+ : profileId === 'unknown-dob'
80
+ ? ''
81
+ : '1990-01-01',
82
+ })),
83
+ }
84
+ );
85
+ for (const profileId of [
86
+ 'campaign-admin',
87
+ 'learner',
88
+ 'other',
89
+ 'minor',
90
+ 'child',
91
+ 'unknown-dob',
92
+ 'managed-adult',
93
+ 'managed-service',
94
+ ]) {
95
+ await cache.delete([`campaigns:user:${profileId}`]);
96
+ }
97
+ });
98
+ afterAll(async () => {
99
+ await neogma.driver.close();
100
+ });
101
+
102
+ describe('campaign lifecycle and access', () => {
103
+ it('requires a signed-in admin for every management procedure', async () => {
104
+ await expect(
105
+ campaignsRouter.createCaller({ domain, tenant }).getMyCampaigns()
106
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
107
+ await expect(other.isAdmin()).resolves.toBe(false);
108
+ await expect(admin.isAdmin()).resolves.toBe(true);
109
+ for (const request of [
110
+ () => other.create(draft()),
111
+ () => other.get({ campaignId: 'pilot-clr' }),
112
+ () => other.update({ campaignId: 'pilot-clr', updates: { name: 'No' } }),
113
+ () => other.delete({ campaignId: 'pilot-clr' }),
114
+ () => other.list(),
115
+ () => other.setStatus({ campaignId: 'pilot-clr', status: 'live' }),
116
+ () => other.previewAudience(audience({ type: 'dids', dids: [did('learner')] })),
117
+ () => other.addAudience(audience({ type: 'dids', dids: [did('learner')] })),
118
+ () => other.pageAudience({ campaignId: 'pilot-clr' }),
119
+ () => other.getStats({ campaignId: 'pilot-clr' }),
120
+ ])
121
+ await expect(request()).rejects.toMatchObject({ code: 'FORBIDDEN' });
122
+ await expect(caller('campaign-admin', '*:read').create(draft())).rejects.toMatchObject({
123
+ code: 'UNAUTHORIZED',
124
+ });
125
+ await expect(
126
+ caller('learner', 'campaigns:read').enroll({ campaignId: 'pilot-clr' })
127
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
128
+ });
129
+
130
+ it('forces message identity, enforces uniqueness, validates merged updates, and keeps archived campaigns immutable', async () => {
131
+ const campaign = await admin.create(
132
+ draft({ message: { id: 'different', title: 'Welcome' } })
133
+ );
134
+ expect(campaign.message.id).toBe('pilot-clr');
135
+ await expect(admin.create(draft())).rejects.toMatchObject({ code: 'CONFLICT' });
136
+ await expect(
137
+ admin.update({ campaignId: campaign.id, updates: { channels: ['notify'] } })
138
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
139
+ const updated = await admin.update({
140
+ campaignId: campaign.id,
141
+ updates: {
142
+ message: { id: 'wrong', title: 'Changed' },
143
+ startsAt: '2026-01-01T00:00:00Z',
144
+ endsAt: '2027-01-01T00:00:00Z',
145
+ },
146
+ });
147
+ expect(updated.message).toMatchObject({ id: campaign.id, title: 'Changed' });
148
+ await expect(
149
+ admin.update({ campaignId: campaign.id, updates: { startsAt: '2028-01-01T00:00:00Z' } })
150
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
151
+ expect((await admin.get({ campaignId: campaign.id })).startsAt).toBe(
152
+ '2026-01-01T00:00:00.000Z'
153
+ );
154
+ await admin.update({ campaignId: campaign.id, updates: { startsAt: null, endsAt: null } });
155
+ expect(await admin.get({ campaignId: campaign.id })).not.toHaveProperty('startsAt');
156
+ await admin.setStatus({ campaignId: campaign.id, status: 'archived' });
157
+ await expect(
158
+ admin.update({ campaignId: campaign.id, updates: { name: 'Changed' } })
159
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
160
+ await expect(
161
+ admin.setStatus({ campaignId: campaign.id, status: 'live' })
162
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
163
+ await expect(addLearner()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
164
+ await admin.delete({ campaignId: campaign.id });
165
+ await expect(admin.get({ campaignId: campaign.id })).rejects.toMatchObject({
166
+ code: 'NOT_FOUND',
167
+ });
168
+ });
169
+
170
+ it.each(['live', 'paused'] as const)(
171
+ 'requires archiving a %s campaign before deletion and preserves audience and engagement on refusal',
172
+ async status => {
173
+ await admin.create(draft({ status: 'live' }));
174
+ await addLearner();
175
+ await learner.recordEvent({ campaignId: 'pilot-clr', event: 'shown' });
176
+ if (status === 'paused') await admin.setStatus({ campaignId: 'pilot-clr', status });
177
+ const before = await admin.get({ campaignId: 'pilot-clr' });
178
+ const stats = await admin.getStats({ campaignId: 'pilot-clr' });
179
+
180
+ await expect(admin.delete({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
181
+ code: 'BAD_REQUEST',
182
+ message: 'Archive live or paused campaigns before deleting them',
183
+ });
184
+ expect(await admin.get({ campaignId: 'pilot-clr' })).toEqual(before);
185
+ expect(await admin.getStats({ campaignId: 'pilot-clr' })).toEqual(stats);
186
+ const state = await neogma.queryRunner.run(
187
+ `MATCH (:Profile {profileId: 'learner'})-[state:CAMPAIGN_STATE]->(:Campaign {id: 'pilot-clr'})
188
+ RETURN state.shownCount AS shownCount`
189
+ );
190
+ expect(state.records[0]!.get('shownCount').toNumber()).toBe(1);
191
+
192
+ await admin.setStatus({ campaignId: 'pilot-clr', status: 'archived' });
193
+ expect(await admin.delete({ campaignId: 'pilot-clr' })).toBe(true);
194
+ await expect(admin.get({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
195
+ code: 'NOT_FOUND',
196
+ });
197
+ }
198
+ );
199
+
200
+ it('deletes draft campaigns and reports missing campaigns', async () => {
201
+ await admin.create(draft());
202
+ expect(await admin.delete({ campaignId: 'pilot-clr' })).toBe(true);
203
+ await expect(admin.delete({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
204
+ code: 'NOT_FOUND',
205
+ });
206
+ });
207
+
208
+ it.each(['live', 'paused'] as const)(
209
+ 'does not let a %s campaign roll back to draft and bypass archive-before-delete',
210
+ async status => {
211
+ await admin.create(draft({ status: 'live' }));
212
+ if (status === 'paused') await admin.setStatus({ campaignId: 'pilot-clr', status });
213
+
214
+ await expect(
215
+ admin.setStatus({ campaignId: 'pilot-clr', status: 'draft' })
216
+ ).rejects.toMatchObject({
217
+ code: 'BAD_REQUEST',
218
+ message: `Campaign status cannot transition from ${status} to draft`,
219
+ });
220
+ await expect(
221
+ admin.update({ campaignId: 'pilot-clr', updates: { status: 'draft' } })
222
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
223
+ await expect(admin.delete({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
224
+ code: 'BAD_REQUEST',
225
+ });
226
+ expect(await admin.get({ campaignId: 'pilot-clr' })).toMatchObject({ status });
227
+ }
228
+ );
229
+
230
+ it('paginates campaigns by immutable ID and filters status', async () => {
231
+ await admin.create(draft({ id: 'aaa', status: 'live' }));
232
+ await admin.create(draft({ id: 'bbb' }));
233
+ const first = await admin.list({ limit: 1 });
234
+ expect(first).toMatchObject({ hasMore: true, cursor: 'aaa', records: [{ id: 'aaa' }] });
235
+ expect(await admin.list({ limit: 1, cursor: first.cursor })).toMatchObject({
236
+ hasMore: false,
237
+ records: [{ id: 'bbb' }],
238
+ });
239
+ expect((await admin.list({ status: 'live' })).records.map(item => item.id)).toEqual([
240
+ 'aaa',
241
+ ]);
242
+ });
243
+
244
+ it('serializes concurrent creation and partial edits without losing fields', async () => {
245
+ const creates = await Promise.allSettled([admin.create(draft()), admin.create(draft())]);
246
+ expect(creates.filter(result => result.status === 'fulfilled')).toHaveLength(1);
247
+ expect(creates.find(result => result.status === 'rejected')).toMatchObject({
248
+ reason: { code: 'CONFLICT' },
249
+ });
250
+ await admin.update({
251
+ campaignId: 'pilot-clr',
252
+ updates: { status: 'live', selfEnroll: true, excludeMinors: false },
253
+ });
254
+ await Promise.all([
255
+ admin.update({ campaignId: 'pilot-clr', updates: { name: 'Renamed' } }),
256
+ admin.update({ campaignId: 'pilot-clr', updates: { message: { title: 'Updated' } } }),
257
+ ]);
258
+ expect(await admin.get({ campaignId: 'pilot-clr' })).toMatchObject({
259
+ name: 'Renamed',
260
+ status: 'live',
261
+ selfEnroll: true,
262
+ excludeMinors: false,
263
+ message: { title: 'Updated' },
264
+ });
265
+ });
266
+ });
267
+
268
+ describe('campaign audiences', () => {
269
+ it('deduplicates DID aliases and verified emails, applies minor policy, and pages eligible profiles', async () => {
270
+ await admin.create(draft());
271
+ await neogma.queryRunner.run(
272
+ `MATCH (p:Profile {profileId: 'learner'}) CREATE (p)-[:HAS_CONTACT_METHOD]->(:ContactMethod {type:'email', value:' Learner@Example.COM ', isVerified:true})`
273
+ );
274
+ await neogma.queryRunner.run(
275
+ `MATCH (p:Profile {profileId: 'other'}) CREATE (p)-[:HAS_CONTACT_METHOD]->(:ContactMethod {type:'email', value:'unverified@example.com', isVerified:false})`
276
+ );
277
+ const inputs = audience({
278
+ type: 'dids',
279
+ dids: [
280
+ did('learner'),
281
+ did('other'),
282
+ `did:web:${domain}:users:learner`,
283
+ did('minor'),
284
+ did('child'),
285
+ did('unknown-dob'),
286
+ ],
287
+ });
288
+ expect(await admin.previewAudience(inputs)).toEqual({ count: 2 });
289
+ expect((await admin.getStats({ campaignId: 'pilot-clr' })).audience).toBe(0);
290
+ expect(await admin.addAudience(inputs)).toEqual({ count: 2 });
291
+ expect(
292
+ await admin.previewAudience(
293
+ audience({
294
+ type: 'emails',
295
+ emails: ['LEARNER@example.com', 'unverified@example.com'],
296
+ })
297
+ )
298
+ ).toEqual({ count: 1 });
299
+ expect(
300
+ await admin.addAudience(
301
+ audience({
302
+ type: 'emails',
303
+ emails: ['LEARNER@example.com', 'unverified@example.com'],
304
+ })
305
+ )
306
+ ).toEqual({ count: 1 });
307
+ expect((await admin.getStats({ campaignId: 'pilot-clr' })).audience).toBe(2);
308
+ const page = await admin.pageAudience({ campaignId: 'pilot-clr', limit: 1 });
309
+ expect(page).toMatchObject({
310
+ hasMore: true,
311
+ cursor: 'learner',
312
+ records: [{ profileId: 'learner' }],
313
+ });
314
+ expect(
315
+ await admin.pageAudience({ campaignId: 'pilot-clr', cursor: page.cursor })
316
+ ).toMatchObject({ hasMore: false, records: [{ profileId: 'other' }] });
317
+ await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: false } });
318
+ expect(await admin.addAudience(inputs)).toEqual({ count: 4 });
319
+ await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: true } });
320
+ expect((await admin.pageAudience({ campaignId: 'pilot-clr' })).records).toHaveLength(2);
321
+ });
322
+
323
+ it.each([
324
+ ['localhost%3A3000', 'did:web:localhost%3A3000:users:learner'],
325
+ ['network.learncard.app', 'did:web:network.learncard.app:users:learner'],
326
+ [
327
+ 'pr-99.preview.learncard.ai:brain',
328
+ 'did:web:pr-99.preview.learncard.ai%3Abrain:users:learner',
329
+ ],
330
+ ])('matches the canonical did:web alias for %s', async (callerDomain, learnerDid) => {
331
+ await admin.create(draft());
332
+ const domainAdmin = caller('campaign-admin', '*:*', callerDomain);
333
+
334
+ expect(
335
+ await domainAdmin.previewAudience(audience({ type: 'dids', dids: [learnerDid] }))
336
+ ).toEqual({ count: 1 });
337
+ });
338
+
339
+ it('treats managed human profiles as minors without classifying managed services as children', async () => {
340
+ await admin.create(draft({ status: 'live', selfEnroll: true }));
341
+ await neogma.queryRunner.run(
342
+ `MATCH (adult:Profile {profileId: 'managed-adult'}),
343
+ (service:Profile {profileId: 'managed-service'}),
344
+ (admin:Profile {profileId: 'campaign-admin'})
345
+ CREATE (:ProfileManager {id: 'guardian-manager', created: $now, managerType: 'guardian'})-[:MANAGES]->(adult)
346
+ CREATE (service)-[:MANAGED_BY]->(admin)`,
347
+ { now: new Date().toISOString() }
348
+ );
349
+ const input = audience({
350
+ type: 'dids',
351
+ dids: [did('managed-adult'), did('managed-service')],
352
+ });
353
+
354
+ expect(await admin.previewAudience(input)).toEqual({ count: 1 });
355
+ await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: false } });
356
+ expect(await admin.previewAudience(input)).toEqual({ count: 2 });
357
+ expect(await admin.addAudience(input)).toEqual({ count: 2 });
358
+ await expect(
359
+ caller('managed-adult').enroll({ campaignId: 'pilot-clr' })
360
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
361
+ await expect(caller('managed-service').enroll({ campaignId: 'pilot-clr' })).resolves.toBe(
362
+ true
363
+ );
364
+
365
+ await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: true } });
366
+ expect(await caller('managed-adult').getMyCampaigns()).toEqual([]);
367
+ expect(await caller('managed-service').getMyCampaigns()).toHaveLength(1);
368
+ });
369
+
370
+ it.each([null, '', 'unknown'])(
371
+ 'excludes unavailable birthdays (%s) from targeting, discovery, enrollment and events',
372
+ async dob => {
373
+ await neogma.queryRunner.run(
374
+ `MATCH (p:Profile {profileId: 'unknown-dob'}) SET p.dob = $dob`,
375
+ { dob }
376
+ );
377
+ const unknown = caller('unknown-dob');
378
+ for (const excludeMinors of [true, false]) {
379
+ const campaignId = `birthday-${excludeMinors}`;
380
+ await admin.create(
381
+ draft({ id: campaignId, status: 'live', selfEnroll: true, excludeMinors })
382
+ );
383
+ const input = {
384
+ campaignId,
385
+ source: { type: 'dids' as const, dids: [did('unknown-dob')] },
386
+ };
387
+ expect(await admin.previewAudience(input)).toEqual({ count: 0 });
388
+ expect(await admin.addAudience(input)).toEqual({ count: 0 });
389
+ expect(await unknown.getMyCampaigns()).toEqual([]);
390
+ await expect(unknown.enroll({ campaignId })).rejects.toMatchObject({
391
+ code: 'FORBIDDEN',
392
+ });
393
+ expect((await admin.getStats({ campaignId })).audience).toBe(0);
394
+
395
+ // Existing membership must not bypass a missing birthday at read/event time.
396
+ await neogma.queryRunner.run(
397
+ `MATCH (p:Profile {profileId: 'unknown-dob'}), (c:Campaign {id: $campaignId})
398
+ CREATE (p)-[:IN_CAMPAIGN_AUDIENCE]->(c)`,
399
+ { campaignId }
400
+ );
401
+ expect((await admin.pageAudience({ campaignId })).records).toEqual([]);
402
+ expect(await unknown.getMyCampaigns()).toEqual([]);
403
+ expect(await unknown.recordEvent({ campaignId, event: 'shown' })).toBe(false);
404
+ expect((await admin.getStats({ campaignId })).shown).toBe(0);
405
+ }
406
+ }
407
+ );
408
+
409
+ it('matches profile and listing senders, deduplicates dual attribution, and preserves recipient identity', async () => {
410
+ await admin.create(draft());
411
+ await neogma.queryRunner.run(
412
+ `CREATE (listing:AppStoreListing {listing_id:'transcript-app'}), (boost:Boost {id:'test-boost'})
413
+ WITH listing, boost MATCH (sender:Profile {profileId:'campaign-admin'})
414
+ UNWIND $ids AS id MATCH (p:Profile {profileId:id})
415
+ CREATE (credential:Credential {id:id})-[:INSTANCE_OF]->(boost)
416
+ CREATE (sender)-[sent:CREDENTIAL_SENT]->(credential)
417
+ SET sent.to = id, sent.date = '2026-10-20T00:00:00.000Z', sent.\`metadata.kind\` = 'transcript',
418
+ sent.status = CASE WHEN id = 'revoked' THEN 'revoked' ELSE null END
419
+ CREATE (listing)-[appSent:CREDENTIAL_SENT]->(credential) SET appSent = properties(sent)
420
+ FOREACH (unused IN CASE WHEN id = 'pending' THEN [] ELSE [1] END |
421
+ CREATE (credential)-[:CREDENTIAL_RECEIVED]->(p))`,
422
+ { ids: ['learner', 'pending', 'revoked'] }
423
+ );
424
+ // A shared credential can have other recipients: only sent.to belongs to this send.
425
+ await neogma.queryRunner.run(
426
+ `MATCH (c:Credential {id:'learner'}), (p:Profile {profileId:'other'}) CREATE (c)-[:CREDENTIAL_RECEIVED]->(p)`
427
+ );
428
+ // Legacy and specialized paths can write a listing-only sent edge. The
429
+ // shared learner credential above has both edges and must still count once.
430
+ await neogma.queryRunner.run(
431
+ `MATCH (listing:AppStoreListing {listing_id:'transcript-app'}),
432
+ (boost:Boost {id:'test-boost'}), (other:Profile {profileId:'other'})
433
+ CREATE (listingOnly:Credential {id:'listing-only'})-[:INSTANCE_OF]->(boost)
434
+ CREATE (listing)-[:CREDENTIAL_SENT {
435
+ to:'other', date:'2026-10-20T00:00:00.000Z', \`metadata.kind\`:'listing-only'
436
+ }]->(listingOnly)
437
+ CREATE (listingOnly)-[:CREDENTIAL_RECEIVED]->(other)`
438
+ );
439
+ for (const identity of [
440
+ { senderProfileId: 'campaign-admin' },
441
+ { listingId: 'transcript-app' },
442
+ ]) {
443
+ const source = {
444
+ type: 'credentialsSentBy' as const,
445
+ ...identity,
446
+ metadata: { kind: 'transcript' },
447
+ since: '2026-10-19T00:00:00Z',
448
+ };
449
+ expect(await admin.previewAudience(audience(source))).toEqual({ count: 1 });
450
+ expect(await admin.addAudience(audience(source))).toEqual({ count: 1 });
451
+ expect(
452
+ await admin.previewAudience(audience({ ...source, includePending: true }))
453
+ ).toEqual({ count: 2 });
454
+ expect(
455
+ await admin.previewAudience(audience({ ...source, metadata: { kind: 'badge' } }))
456
+ ).toEqual({ count: 0 });
457
+ expect(
458
+ await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
459
+ ).toEqual({ count: 0 });
460
+ }
461
+ expect(
462
+ await admin.previewAudience(
463
+ audience({ type: 'credentialsSentBy', listingId: 'other-app' })
464
+ )
465
+ ).toEqual({ count: 0 });
466
+ const boostSource = {
467
+ type: 'boostRecipients' as const,
468
+ boostUri: `lc:network:${domain}/trpc:boost:test-boost`,
469
+ };
470
+ expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 2 });
471
+ expect(await admin.addAudience(audience({ ...boostSource, includePending: true }))).toEqual(
472
+ { count: 3 }
473
+ );
474
+ for (const [callerDomain, boostUri] of [
475
+ [
476
+ 'pr-99.preview.learncard.ai:brain',
477
+ constructUri('boost', 'test-boost', 'pr-99.preview.learncard.ai:brain'),
478
+ ],
479
+ [
480
+ 'pr-99.preview.learncard.ai:brain',
481
+ 'lc:network:pr-99.preview.learncard.ai%3Abrain/trpc:boost:test-boost',
482
+ ],
483
+ ['network.learncard.app', constructUri('boost', 'test-boost', 'network.learncard.app')],
484
+ ] as const) {
485
+ const domainAdmin = caller('campaign-admin', '*:*', callerDomain);
486
+ expect(
487
+ await domainAdmin.previewAudience(audience({ type: 'boostRecipients', boostUri }))
488
+ ).toEqual({ count: 2 });
489
+ }
490
+ expect((await admin.pageAudience({ campaignId: 'pilot-clr' })).records).toEqual([
491
+ { profileId: 'learner', did: did('learner') },
492
+ { profileId: 'other', did: did('other') },
493
+ { profileId: 'pending', did: did('pending') },
494
+ ]);
495
+ expect((await admin.getStats({ campaignId: 'pilot-clr' })).audience).toBe(3);
496
+ await expect(
497
+ admin.previewAudience(
498
+ audience({
499
+ ...boostSource,
500
+ boostUri: 'lc:network:foreign.example/trpc:boost:test-boost',
501
+ })
502
+ )
503
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
504
+ await neogma.queryRunner.run(
505
+ `MATCH (c:Credential {id:'learner'})-[received:CREDENTIAL_RECEIVED]->() SET received.status = 'revoked'`
506
+ );
507
+ expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 1 });
508
+ await neogma.queryRunner.run(
509
+ `MATCH (c:Credential {id:'pending'}), (p:Profile {profileId:'pending'}) CREATE (c)-[:CREDENTIAL_RECEIVED {status:'pending'}]->(p)`
510
+ );
511
+ expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 1 });
512
+ });
513
+
514
+ it('matches inbox claims by issuer and claim time, and rejects injection before querying', async () => {
515
+ await admin.create(draft());
516
+ await neogma.queryRunner.run(
517
+ `MATCH (p:Profile {profileId:'learner'}) CREATE (p)-[:CLAIMED_INBOX_CREDENTIAL {timestamp:'2026-10-20T00:00:00.000Z'}]->(:InboxCredential {id:'inbox', issuerDid:'did:key:issuer'})`
518
+ );
519
+ const source = {
520
+ type: 'inboxClaims' as const,
521
+ issuerDid: 'did:key:issuer',
522
+ since: '2026-10-19T00:00:00Z',
523
+ };
524
+ expect(await admin.previewAudience(audience(source))).toEqual({ count: 1 });
525
+ expect(await admin.addAudience(audience(source))).toEqual({ count: 1 });
526
+ expect(
527
+ await admin.previewAudience(audience({ ...source, issuerDid: 'did:key:other' }))
528
+ ).toEqual({ count: 0 });
529
+ expect(
530
+ await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
531
+ ).toEqual({ count: 0 });
532
+ const unsafe = {
533
+ type: 'credentialsSentBy' as const,
534
+ senderProfileId: 'campaign-admin',
535
+ metadata: { 'kind`]) MATCH (n) DETACH DELETE n //': 'x' },
536
+ };
537
+ expect(CampaignAudienceSourceValidator.safeParse(unsafe).success).toBe(false);
538
+ await expect(admin.addAudience(audience(unsafe))).rejects.toMatchObject({
539
+ code: 'BAD_REQUEST',
540
+ });
541
+ expect((await admin.get({ campaignId: 'pilot-clr' })).id).toBe('pilot-clr');
542
+ });
543
+ });
544
+
545
+ describe('campaign eligibility and engagement', () => {
546
+ it('applies live windows, channels, membership, message enablement and current minor eligibility', async () => {
547
+ await admin.create(
548
+ draft({
549
+ status: 'live',
550
+ startsAt: '2026-10-20T00:00:00Z',
551
+ endsAt: '2026-10-23T00:00:00Z',
552
+ })
553
+ );
554
+ await addLearner();
555
+ expect(
556
+ await getLiveCampaignsForProfile('learner', new Date('2026-10-19T23:59:59Z'))
557
+ ).toEqual([]);
558
+ expect(
559
+ await getLiveCampaignsForProfile('learner', new Date('2026-10-20T00:00:00Z'))
560
+ ).toHaveLength(1);
561
+ expect(
562
+ await getLiveCampaignsForProfile('learner', new Date('2026-10-23T00:00:00Z'))
563
+ ).toEqual([]);
564
+ expect(await other.getMyCampaigns()).toEqual([]);
565
+ await admin.update({ campaignId: 'pilot-clr', updates: { startsAt: null, endsAt: null } });
566
+ expect(await learner.getMyCampaigns()).toHaveLength(1);
567
+ await neogma.queryRunner.run(
568
+ `MATCH (p:Profile {profileId:'learner'}) SET p.type = 'child'`
569
+ );
570
+ expect(await learner.getMyCampaigns()).toEqual([]);
571
+ await neogma.queryRunner.run(
572
+ `MATCH (p:Profile {profileId:'learner'}) SET p.type = 'person'`
573
+ );
574
+ await admin.setStatus({ campaignId: 'pilot-clr', status: 'paused' });
575
+ expect(await learner.getMyCampaigns()).toEqual([]);
576
+ await admin.update({
577
+ campaignId: 'pilot-clr',
578
+ updates: { status: 'live', message: { title: 'Disabled', enabled: false } },
579
+ });
580
+ expect(await learner.getMyCampaigns()).toEqual([]);
581
+ await admin.update({
582
+ campaignId: 'pilot-clr',
583
+ updates: {
584
+ message: { title: 'Notify' },
585
+ channels: ['notify'],
586
+ notification: { title: 'Hi', body: 'Hello' },
587
+ },
588
+ });
589
+ expect(await learner.getMyCampaigns()).toEqual([]);
590
+ });
591
+
592
+ it.each(['not-json', JSON.stringify({ id: 'broken-campaign' })])(
593
+ 'skips a malformed stored definition without breaking other campaign discovery',
594
+ async invalidDefinition => {
595
+ await admin.create(draft({ id: 'valid-campaign', status: 'live' }));
596
+ await admin.create(draft({ id: 'broken-campaign', status: 'live' }));
597
+ for (const campaignId of ['valid-campaign', 'broken-campaign']) {
598
+ await admin.addAudience({
599
+ campaignId,
600
+ source: { type: 'dids', dids: [did('learner')] },
601
+ });
602
+ }
603
+ await neogma.queryRunner.run(
604
+ `MATCH (c:Campaign {id: 'broken-campaign'}) SET c.definition = $definition`,
605
+ { definition: invalidDefinition }
606
+ );
607
+
608
+ expect((await learner.getMyCampaigns()).map(item => item.campaign.id)).toEqual([
609
+ 'valid-campaign',
610
+ ]);
611
+ }
612
+ );
613
+
614
+ it('supports self-enrollment only while live/enabled and never for known minors', async () => {
615
+ await admin.create(draft({ status: 'live', selfEnroll: true, excludeMinors: false }));
616
+ expect(await learner.getMyCampaigns()).toHaveLength(1);
617
+ expect(await learner.recordEvent({ campaignId: 'pilot-clr', event: 'shown' })).toBe(false);
618
+ await expect(caller('minor').enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
619
+ code: 'FORBIDDEN',
620
+ });
621
+ await expect(caller('child').enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
622
+ code: 'FORBIDDEN',
623
+ });
624
+ await Promise.all(
625
+ Array.from({ length: 5 }, () => learner.enroll({ campaignId: 'pilot-clr' }))
626
+ );
627
+ expect(await admin.getStats({ campaignId: 'pilot-clr' })).toMatchObject({
628
+ audience: 1,
629
+ enrolled: 1,
630
+ });
631
+ await admin.addAudience(audience({ type: 'dids', dids: [did('learner')] }));
632
+ expect((await admin.getStats({ campaignId: 'pilot-clr' })).enrolled).toBe(1);
633
+ await admin.setStatus({ campaignId: 'pilot-clr', status: 'paused' });
634
+ await expect(other.enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
635
+ code: 'FORBIDDEN',
636
+ });
637
+ await admin.update({
638
+ campaignId: 'pilot-clr',
639
+ updates: { status: 'live', selfEnroll: false },
640
+ });
641
+ await expect(other.enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
642
+ code: 'FORBIDDEN',
643
+ });
644
+ });
645
+
646
+ it('rejects disabled and out-of-window enrollment and never stores a supplied credential payload', async () => {
647
+ await admin.create(
648
+ draft({ status: 'live', selfEnroll: true, startsAt: '2999-01-01T00:00:00Z' })
649
+ );
650
+ await expect(learner.enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
651
+ code: 'FORBIDDEN',
652
+ });
653
+ await admin.update({
654
+ campaignId: 'pilot-clr',
655
+ updates: { startsAt: null, endsAt: '2000-01-01T00:00:00Z' },
656
+ });
657
+ await expect(learner.enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
658
+ code: 'FORBIDDEN',
659
+ });
660
+ await admin.update({
661
+ campaignId: 'pilot-clr',
662
+ updates: { endsAt: null, message: { title: 'Disabled', enabled: false } },
663
+ });
664
+ await expect(learner.enroll({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
665
+ code: 'FORBIDDEN',
666
+ });
667
+ const unsolicited = { campaignId: 'pilot-clr', credential: { issuer: 'did:key:private' } };
668
+ await expect(learner.enroll(unsolicited)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
669
+ expect(await admin.getStats({ campaignId: 'pilot-clr' })).toMatchObject({
670
+ audience: 0,
671
+ enrolled: 0,
672
+ });
673
+ });
674
+
675
+ it.each(['missing campaign', 'nonmember', 'age-ineligible member'])(
676
+ 'does not write profile properties or campaign state for a rejected event: %s',
677
+ async scenario => {
678
+ await admin.create(draft({ status: 'live' }));
679
+ await addLearner();
680
+ const profileId = scenario === 'age-ineligible member' ? 'learner' : 'other';
681
+ const actor = caller(profileId);
682
+ if (scenario === 'age-ineligible member') {
683
+ await neogma.queryRunner.run(
684
+ `MATCH (p:Profile {profileId: $profileId}) REMOVE p.dob`,
685
+ { profileId }
686
+ );
687
+ }
688
+ await neogma.queryRunner.run(
689
+ `MATCH (p:Profile {profileId: $profileId}) SET p.campaignStateLock = 17`,
690
+ { profileId }
691
+ );
692
+ const readProfile = async (): Promise<Record<string, unknown>> => {
693
+ const result = await neogma.queryRunner.run(
694
+ `MATCH (p:Profile {profileId: $profileId}) RETURN properties(p) AS profile`,
695
+ { profileId }
696
+ );
697
+ return result.records[0]!.get('profile');
698
+ };
699
+ const before = await readProfile();
700
+ const campaignId = scenario === 'missing campaign' ? 'missing' : 'pilot-clr';
701
+ expect(await actor.recordEvent({ campaignId, event: 'shown' })).toBe(false);
702
+ expect(await readProfile()).toEqual(before);
703
+ expect((await admin.getStats({ campaignId: 'pilot-clr' })).shown).toBe(0);
704
+ }
705
+ );
706
+
707
+ it.each([
708
+ ['paused', { status: 'paused' as const }],
709
+ ['not started', { status: 'live' as const, startsAt: '2999-01-01T00:00:00Z' }],
710
+ ['ended', { status: 'live' as const, endsAt: '2000-01-01T00:00:00Z' }],
711
+ ])('does not write profile or engagement state when a campaign is %s', async (_, options) => {
712
+ await admin.create(draft(options));
713
+ await addLearner();
714
+ await neogma.queryRunner.run(
715
+ `MATCH (p:Profile {profileId: 'learner'}) SET p.campaignStateLock = 17`
716
+ );
717
+
718
+ expect(await learner.recordEvent({ campaignId: 'pilot-clr', event: 'shown' })).toBe(false);
719
+ const result = await neogma.queryRunner.run(
720
+ `MATCH (p:Profile {profileId: 'learner'})
721
+ OPTIONAL MATCH (p)-[state:CAMPAIGN_STATE]->(:Campaign {id: 'pilot-clr'})
722
+ RETURN p.campaignStateLock AS lockVersion, state`
723
+ );
724
+ expect(result.records[0]!.get('lockVersion').toNumber()).toBe(17);
725
+ expect(result.records[0]!.get('state')).toBeNull();
726
+ });
727
+
728
+ it('persists shared state and counts unique engaged profiles under concurrent shown events', async () => {
729
+ await admin.create(draft({ status: 'live' }));
730
+ await addLearner();
731
+ expect(await other.recordEvent({ campaignId: 'pilot-clr', event: 'shown' })).toBe(false);
732
+ await learner.recordEvent({ campaignId: 'pilot-clr', event: 'shown' });
733
+ const first = (await learner.getMyCampaigns())[0]!.state.firstShownAt;
734
+ await Promise.all(
735
+ Array.from({ length: 6 }, () =>
736
+ learner.recordEvent({ campaignId: 'pilot-clr', event: 'shown' })
737
+ )
738
+ );
739
+ for (const event of ['clicked', 'dismissed', 'converted'] as const)
740
+ await learner.recordEvent({ campaignId: 'pilot-clr', event });
741
+ const anotherDevice = caller('learner');
742
+ expect((await anotherDevice.getMyCampaigns())[0]!.state).toMatchObject({
743
+ firstShownAt: first,
744
+ shownCount: 7,
745
+ clickedAt: expect.any(String),
746
+ dismissedAt: expect.any(String),
747
+ convertedAt: expect.any(String),
748
+ });
749
+ expect(await admin.getStats({ campaignId: 'pilot-clr' })).toEqual({
750
+ audience: 1,
751
+ enrolled: 0,
752
+ notified: 0,
753
+ shown: 1,
754
+ clicked: 1,
755
+ dismissed: 1,
756
+ converted: 1,
757
+ });
758
+ });
759
+
760
+ it('enforces a combined 60-call user budget without counting admin operations', async () => {
761
+ for (let index = 0; index < 60; index += 1) await learner.getMyCampaigns();
762
+ await expect(learner.isAdmin()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
763
+ await expect(other.getMyCampaigns()).resolves.toEqual([]);
764
+ await expect(admin.create(draft())).resolves.toMatchObject({ id: 'pilot-clr' });
765
+ });
766
+
767
+ it('uses the exact UTC eighteenth birthday cutoff', async () => {
768
+ expect(campaignTimeParams(new Date('2026-10-22T23:59:59Z')).adultCutoff).toBe('2008-10-22');
769
+ await admin.create(draft({ status: 'live', selfEnroll: true }));
770
+ await neogma.queryRunner.run(
771
+ `MATCH (p:Profile {profileId:'learner'}) SET p.dob = '2008-10-22'`
772
+ );
773
+ expect(
774
+ await getLiveCampaignsForProfile('learner', new Date('2026-10-22T00:00:00Z'))
775
+ ).toHaveLength(1);
776
+ expect(
777
+ await getLiveCampaignsForProfile('learner', new Date('2026-10-21T23:59:59Z'))
778
+ ).toEqual([]);
779
+ await neogma.queryRunner.run(
780
+ `MATCH (p:Profile {profileId:'learner'}) SET p.dob = '2010-03-01'`
781
+ );
782
+ expect(
783
+ await getLiveCampaignsForProfile('learner', new Date('2028-02-29T23:59:59Z'))
784
+ ).toEqual([]);
785
+ expect(
786
+ await getLiveCampaignsForProfile('learner', new Date('2028-03-01T00:00:00Z'))
787
+ ).toHaveLength(1);
788
+ });
789
+ });