@learncard/network-brain-service 4.1.3 → 5.0.0

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 (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +143 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -1,4 +1,10 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
1
+ import { claimCampaignEmail, acknowledgeCampaignEmail } from '@accesslayer/campaign/email';
2
+ import { pageCampaignAudience } from '@accesslayer/campaign/audience';
3
+ import { getAnnouncementPreferences } from '@accesslayer/campaign/preferences';
4
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
5
+ import { updateProfile } from '@accesslayer/profile/update';
6
+ import { signCampaignUnsubscribeToken } from '@helpers/campaign-unsubscribe.helpers';
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
8
  import { resolveTenantFromRequest } from '@learncard/email-templates';
3
9
  import {
4
10
  CampaignAudienceSourceValidator,
@@ -9,10 +15,19 @@ import {
9
15
  import { neogma } from '@instance';
10
16
  import cache from '@cache';
11
17
  import { campaignsRouter } from '@routes/campaigns';
18
+ import {
19
+ acknowledgeCampaignNotification,
20
+ getCampaignDeliveryRecipient,
21
+ } from '@accesslayer/campaign/delivery';
22
+ import * as notifications from '@helpers/notifications.helpers';
23
+ import * as learnCardHelpers from '@helpers/learnCard.helpers';
12
24
  import { getLiveCampaignsForProfile } from '@accesslayer/campaign/read';
25
+ import { MAX_CONFIRMED_DISPATCH_RECIPIENTS } from '@accesslayer/campaign/dispatch';
26
+ import { int } from 'neo4j-driver';
13
27
  import { campaignTimeParams } from '@accesslayer/campaign/shared';
14
28
  import { constructUri } from '@helpers/uri.helpers';
15
29
  import { ensureCampaignConstraints } from '../src/models/campaign-constraints';
30
+ import { buildCampaignNotification } from '@helpers/campaign-notifications.helpers';
16
31
 
17
32
  const domain = 'localhost%3A3000';
18
33
  const tenant = resolveTenantFromRequest({}, 'learncard');
@@ -41,6 +56,39 @@ const addLearner = async (): Promise<void> => {
41
56
  await admin.addAudience(audience({ type: 'dids', dids: [did('learner')] }));
42
57
  };
43
58
 
59
+ it.each([
60
+ { channels: ['notify'], enabled: true, expected: false },
61
+ { channels: ['inApp', 'notify'], enabled: false, expected: false },
62
+ { channels: ['inApp', 'notify'], enabled: true, expected: true },
63
+ ] as const)(
64
+ 'aligns notification destinations with learner feed eligibility (%j)',
65
+ async ({ channels, enabled, expected }) => {
66
+ const campaign = await admin.create(
67
+ draft({
68
+ status: 'live',
69
+ channels: [...channels],
70
+ message: { title: 'News', enabled },
71
+ notification: { title: 'News', body: 'Read this announcement' },
72
+ })
73
+ );
74
+ await addLearner();
75
+ const signer = vi.spyOn(learnCardHelpers, 'getDidWebLearnCard').mockResolvedValue({
76
+ id: { did: () => 'did:web:brain.example' },
77
+ } as never);
78
+ try {
79
+ const notification = await buildCampaignNotification(campaign, {
80
+ profileId: 'learner',
81
+ did: did('learner'),
82
+ });
83
+ expect(notification.data?.metadata?.campaignHasInApp).toBe(expected);
84
+ const feed = await getLiveCampaignsForProfile('learner');
85
+ expect(feed.some(item => item.campaign.id === campaign.id)).toBe(expected);
86
+ } finally {
87
+ signer.mockRestore();
88
+ }
89
+ }
90
+ );
91
+
44
92
  beforeAll(async () => {
45
93
  await ensureCampaignConstraints();
46
94
  });
@@ -117,6 +165,9 @@ describe('campaign lifecycle and access', () => {
117
165
  () => other.addAudience(audience({ type: 'dids', dids: [did('learner')] })),
118
166
  () => other.pageAudience({ campaignId: 'pilot-clr' }),
119
167
  () => other.getStats({ campaignId: 'pilot-clr' }),
168
+ () => other.sendTestToMe({ campaignId: 'pilot-clr' }),
169
+ () => other.dispatch({ campaignId: 'pilot-clr' }),
170
+ () => other.previewDispatch({ campaignId: 'pilot-clr' }),
120
171
  ])
121
172
  await expect(request()).rejects.toMatchObject({ code: 'FORBIDDEN' });
122
173
  await expect(caller('campaign-admin', '*:read').create(draft())).rejects.toMatchObject({
@@ -131,7 +182,7 @@ describe('campaign lifecycle and access', () => {
131
182
  const campaign = await admin.create(
132
183
  draft({ message: { id: 'different', title: 'Welcome' } })
133
184
  );
134
- expect(campaign.message.id).toBe('pilot-clr');
185
+ expect(campaign.message?.id).toBe('pilot-clr');
135
186
  await expect(admin.create(draft())).rejects.toMatchObject({ code: 'CONFLICT' });
136
187
  await expect(
137
188
  admin.update({ campaignId: campaign.id, updates: { channels: ['notify'] } })
@@ -227,6 +278,66 @@ describe('campaign lifecycle and access', () => {
227
278
  }
228
279
  );
229
280
 
281
+ it('rejects an editor save after another administrator pauses the campaign', async () => {
282
+ const opened = await admin.create(draft({ status: 'live' }));
283
+ const paused = await admin.setStatus({ campaignId: opened.id, status: 'paused' });
284
+ await expect(
285
+ admin.update({
286
+ campaignId: opened.id,
287
+ expectedUpdatedAt: opened.updatedAt,
288
+ updates: { name: 'Stale editor', status: 'live' },
289
+ })
290
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
291
+ expect(await admin.get({ campaignId: opened.id })).toEqual(paused);
292
+ });
293
+
294
+ it('serializes two editors saving the same revision without overwriting the winner', async () => {
295
+ const opened = await admin.create(draft());
296
+ const results = await Promise.allSettled(
297
+ ['First editor', 'Second editor'].map(name =>
298
+ admin.update({
299
+ campaignId: opened.id,
300
+ expectedUpdatedAt: opened.updatedAt,
301
+ updates: { name },
302
+ })
303
+ )
304
+ );
305
+ const accepted = results.filter(result => result.status === 'fulfilled');
306
+ const rejected = results.filter(result => result.status === 'rejected');
307
+ expect(accepted).toHaveLength(1);
308
+ expect(rejected).toHaveLength(1);
309
+ expect(rejected[0]?.reason).toMatchObject({ code: 'CONFLICT' });
310
+ expect(await admin.get({ campaignId: opened.id })).toEqual(accepted[0]?.value);
311
+ });
312
+
313
+ it('advances the revision even when writes share a clock tick', async () => {
314
+ const opened = await admin.create(draft());
315
+ const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(opened.updatedAt));
316
+ try {
317
+ const updated = await admin.update({
318
+ campaignId: opened.id,
319
+ expectedUpdatedAt: opened.updatedAt,
320
+ updates: { name: 'First edit' },
321
+ });
322
+ expect(Date.parse(updated.updatedAt)).toBe(Date.parse(opened.updatedAt) + 1);
323
+ await expect(
324
+ admin.update({
325
+ campaignId: opened.id,
326
+ expectedUpdatedAt: opened.updatedAt,
327
+ updates: { name: 'Stale edit' },
328
+ })
329
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
330
+ const next = await admin.update({
331
+ campaignId: opened.id,
332
+ expectedUpdatedAt: updated.updatedAt,
333
+ updates: { name: 'Fresh edit' },
334
+ });
335
+ expect(Date.parse(next.updatedAt)).toBe(Date.parse(updated.updatedAt) + 1);
336
+ } finally {
337
+ clock.mockRestore();
338
+ }
339
+ });
340
+
230
341
  it('paginates campaigns by immutable ID and filters status', async () => {
231
342
  await admin.create(draft({ id: 'aaa', status: 'live' }));
232
343
  await admin.create(draft({ id: 'bbb' }));
@@ -266,6 +377,34 @@ describe('campaign lifecycle and access', () => {
266
377
  });
267
378
 
268
379
  describe('campaign audiences', () => {
380
+ it('reports newly added profiles and missing DIDs across aliases and retries', async () => {
381
+ await admin.create(draft());
382
+ const input = audience({
383
+ type: 'dids',
384
+ dids: [
385
+ did('learner'),
386
+ `did:web:${domain}:users:learner`,
387
+ 'did:key:missing',
388
+ did('minor'),
389
+ ],
390
+ });
391
+ expect(await admin.previewAudience(input)).toEqual({
392
+ count: 1,
393
+ added: 0,
394
+ notFound: ['did:key:missing'],
395
+ });
396
+ expect(await admin.addAudience(input)).toEqual({
397
+ count: 1,
398
+ added: 1,
399
+ notFound: ['did:key:missing'],
400
+ });
401
+ expect(await admin.addAudience(input)).toEqual({
402
+ count: 1,
403
+ added: 0,
404
+ notFound: ['did:key:missing'],
405
+ });
406
+ });
407
+
269
408
  it('deduplicates DID aliases and verified emails, applies minor policy, and pages eligible profiles', async () => {
270
409
  await admin.create(draft());
271
410
  await neogma.queryRunner.run(
@@ -285,9 +424,9 @@ describe('campaign audiences', () => {
285
424
  did('unknown-dob'),
286
425
  ],
287
426
  });
288
- expect(await admin.previewAudience(inputs)).toEqual({ count: 2 });
427
+ expect(await admin.previewAudience(inputs)).toMatchObject({ count: 2 });
289
428
  expect((await admin.getStats({ campaignId: 'pilot-clr' })).audience).toBe(0);
290
- expect(await admin.addAudience(inputs)).toEqual({ count: 2 });
429
+ expect(await admin.addAudience(inputs)).toMatchObject({ count: 2 });
291
430
  expect(
292
431
  await admin.previewAudience(
293
432
  audience({
@@ -295,7 +434,7 @@ describe('campaign audiences', () => {
295
434
  emails: ['LEARNER@example.com', 'unverified@example.com'],
296
435
  })
297
436
  )
298
- ).toEqual({ count: 1 });
437
+ ).toMatchObject({ count: 1 });
299
438
  expect(
300
439
  await admin.addAudience(
301
440
  audience({
@@ -303,7 +442,7 @@ describe('campaign audiences', () => {
303
442
  emails: ['LEARNER@example.com', 'unverified@example.com'],
304
443
  })
305
444
  )
306
- ).toEqual({ count: 1 });
445
+ ).toMatchObject({ count: 1 });
307
446
  expect((await admin.getStats({ campaignId: 'pilot-clr' })).audience).toBe(2);
308
447
  const page = await admin.pageAudience({ campaignId: 'pilot-clr', limit: 1 });
309
448
  expect(page).toMatchObject({
@@ -315,7 +454,7 @@ describe('campaign audiences', () => {
315
454
  await admin.pageAudience({ campaignId: 'pilot-clr', cursor: page.cursor })
316
455
  ).toMatchObject({ hasMore: false, records: [{ profileId: 'other' }] });
317
456
  await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: false } });
318
- expect(await admin.addAudience(inputs)).toEqual({ count: 4 });
457
+ expect(await admin.addAudience(inputs)).toMatchObject({ count: 4 });
319
458
  await admin.update({ campaignId: 'pilot-clr', updates: { excludeMinors: true } });
320
459
  expect((await admin.pageAudience({ campaignId: 'pilot-clr' })).records).toHaveLength(2);
321
460
  });
@@ -333,7 +472,7 @@ describe('campaign audiences', () => {
333
472
 
334
473
  expect(
335
474
  await domainAdmin.previewAudience(audience({ type: 'dids', dids: [learnerDid] }))
336
- ).toEqual({ count: 1 });
475
+ ).toMatchObject({ count: 1 });
337
476
  });
338
477
 
339
478
  it('treats managed human profiles as minors without classifying managed services as children', async () => {
@@ -351,10 +490,10 @@ describe('campaign audiences', () => {
351
490
  dids: [did('managed-adult'), did('managed-service')],
352
491
  });
353
492
 
354
- expect(await admin.previewAudience(input)).toEqual({ count: 1 });
493
+ expect(await admin.previewAudience(input)).toMatchObject({ count: 1 });
355
494
  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 });
495
+ expect(await admin.previewAudience(input)).toMatchObject({ count: 2 });
496
+ expect(await admin.addAudience(input)).toMatchObject({ count: 2 });
358
497
  await expect(
359
498
  caller('managed-adult').enroll({ campaignId: 'pilot-clr' })
360
499
  ).rejects.toMatchObject({ code: 'FORBIDDEN' });
@@ -384,8 +523,8 @@ describe('campaign audiences', () => {
384
523
  campaignId,
385
524
  source: { type: 'dids' as const, dids: [did('unknown-dob')] },
386
525
  };
387
- expect(await admin.previewAudience(input)).toEqual({ count: 0 });
388
- expect(await admin.addAudience(input)).toEqual({ count: 0 });
526
+ expect(await admin.previewAudience(input)).toMatchObject({ count: 0 });
527
+ expect(await admin.addAudience(input)).toMatchObject({ count: 0 });
389
528
  expect(await unknown.getMyCampaigns()).toEqual([]);
390
529
  await expect(unknown.enroll({ campaignId })).rejects.toMatchObject({
391
530
  code: 'FORBIDDEN',
@@ -446,31 +585,31 @@ describe('campaign audiences', () => {
446
585
  metadata: { kind: 'transcript' },
447
586
  since: '2026-10-19T00:00:00Z',
448
587
  };
449
- expect(await admin.previewAudience(audience(source))).toEqual({ count: 1 });
450
- expect(await admin.addAudience(audience(source))).toEqual({ count: 1 });
588
+ expect(await admin.previewAudience(audience(source))).toMatchObject({ count: 1 });
589
+ expect(await admin.addAudience(audience(source))).toMatchObject({ count: 1 });
451
590
  expect(
452
591
  await admin.previewAudience(audience({ ...source, includePending: true }))
453
- ).toEqual({ count: 2 });
592
+ ).toMatchObject({ count: 2 });
454
593
  expect(
455
594
  await admin.previewAudience(audience({ ...source, metadata: { kind: 'badge' } }))
456
- ).toEqual({ count: 0 });
595
+ ).toMatchObject({ count: 0 });
457
596
  expect(
458
597
  await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
459
- ).toEqual({ count: 0 });
598
+ ).toMatchObject({ count: 0 });
460
599
  }
461
600
  expect(
462
601
  await admin.previewAudience(
463
602
  audience({ type: 'credentialsSentBy', listingId: 'other-app' })
464
603
  )
465
- ).toEqual({ count: 0 });
604
+ ).toMatchObject({ count: 0 });
466
605
  const boostSource = {
467
606
  type: 'boostRecipients' as const,
468
607
  boostUri: `lc:network:${domain}/trpc:boost:test-boost`,
469
608
  };
470
- expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 2 });
471
- expect(await admin.addAudience(audience({ ...boostSource, includePending: true }))).toEqual(
472
- { count: 3 }
473
- );
609
+ expect(await admin.previewAudience(audience(boostSource))).toMatchObject({ count: 2 });
610
+ expect(
611
+ await admin.addAudience(audience({ ...boostSource, includePending: true }))
612
+ ).toMatchObject({ count: 3 });
474
613
  for (const [callerDomain, boostUri] of [
475
614
  [
476
615
  'pr-99.preview.learncard.ai:brain',
@@ -485,7 +624,7 @@ describe('campaign audiences', () => {
485
624
  const domainAdmin = caller('campaign-admin', '*:*', callerDomain);
486
625
  expect(
487
626
  await domainAdmin.previewAudience(audience({ type: 'boostRecipients', boostUri }))
488
- ).toEqual({ count: 2 });
627
+ ).toMatchObject({ count: 2 });
489
628
  }
490
629
  expect((await admin.pageAudience({ campaignId: 'pilot-clr' })).records).toEqual([
491
630
  { profileId: 'learner', did: did('learner') },
@@ -504,11 +643,11 @@ describe('campaign audiences', () => {
504
643
  await neogma.queryRunner.run(
505
644
  `MATCH (c:Credential {id:'learner'})-[received:CREDENTIAL_RECEIVED]->() SET received.status = 'revoked'`
506
645
  );
507
- expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 1 });
646
+ expect(await admin.previewAudience(audience(boostSource))).toMatchObject({ count: 1 });
508
647
  await neogma.queryRunner.run(
509
648
  `MATCH (c:Credential {id:'pending'}), (p:Profile {profileId:'pending'}) CREATE (c)-[:CREDENTIAL_RECEIVED {status:'pending'}]->(p)`
510
649
  );
511
- expect(await admin.previewAudience(audience(boostSource))).toEqual({ count: 1 });
650
+ expect(await admin.previewAudience(audience(boostSource))).toMatchObject({ count: 1 });
512
651
  });
513
652
 
514
653
  it('matches inbox claims by issuer and claim time, and rejects injection before querying', async () => {
@@ -521,14 +660,14 @@ describe('campaign audiences', () => {
521
660
  issuerDid: 'did:key:issuer',
522
661
  since: '2026-10-19T00:00:00Z',
523
662
  };
524
- expect(await admin.previewAudience(audience(source))).toEqual({ count: 1 });
525
- expect(await admin.addAudience(audience(source))).toEqual({ count: 1 });
663
+ expect(await admin.previewAudience(audience(source))).toMatchObject({ count: 1 });
664
+ expect(await admin.addAudience(audience(source))).toMatchObject({ count: 1 });
526
665
  expect(
527
666
  await admin.previewAudience(audience({ ...source, issuerDid: 'did:key:other' }))
528
- ).toEqual({ count: 0 });
667
+ ).toMatchObject({ count: 0 });
529
668
  expect(
530
669
  await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
531
- ).toEqual({ count: 0 });
670
+ ).toMatchObject({ count: 0 });
532
671
  const unsafe = {
533
672
  type: 'credentialsSentBy' as const,
534
673
  senderProfileId: 'campaign-admin',
@@ -750,6 +889,7 @@ describe('campaign eligibility and engagement', () => {
750
889
  audience: 1,
751
890
  enrolled: 0,
752
891
  notified: 0,
892
+ emailed: 0,
753
893
  shown: 1,
754
894
  clicked: 1,
755
895
  dismissed: 1,
@@ -787,3 +927,637 @@ describe('campaign eligibility and engagement', () => {
787
927
  ).toHaveLength(1);
788
928
  });
789
929
  });
930
+
931
+ describe('campaign notification delivery state', () => {
932
+ it('rechecks delivery eligibility and atomically records concurrent acknowledgements', async () => {
933
+ const campaign = await admin.create(
934
+ draft({
935
+ status: 'live',
936
+ channels: ['inApp', 'notify'],
937
+ notification: { title: 'Explore', body: 'Your next step' },
938
+ })
939
+ );
940
+ await addLearner();
941
+ await expect(
942
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
943
+ ).resolves.toMatchObject({ profileId: 'learner' });
944
+ await expect(
945
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'other')
946
+ ).resolves.toBeUndefined();
947
+ const notification = {
948
+ type: 'ANNOUNCEMENT' as const,
949
+ from: 'did:web:brain.example',
950
+ to: { profileId: 'learner', did: did('learner') },
951
+ data: { metadata: { campaignId: campaign.id, campaignCreatedAt: campaign.createdAt } },
952
+ };
953
+ await Promise.all([
954
+ ...Array.from({ length: 10 }, () => acknowledgeCampaignNotification(notification)),
955
+ learner.recordEvent({ campaignId: campaign.id, event: 'shown' }),
956
+ ]);
957
+ const states = await neogma.queryRunner.run(
958
+ `MATCH (:Profile {profileId: 'learner'})-[state:CAMPAIGN_STATE]->(:Campaign {id: $id})
959
+ RETURN count(state) AS count`,
960
+ { id: campaign.id }
961
+ );
962
+ expect(states.records[0]!.get('count').toNumber()).toBe(1);
963
+ await expect(admin.getStats({ campaignId: campaign.id })).resolves.toMatchObject({
964
+ notified: 1,
965
+ shown: 1,
966
+ });
967
+ await expect(
968
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
969
+ ).resolves.toBeUndefined();
970
+ });
971
+
972
+ it('keeps in-app tests private, durable, snapshot-based and separate from campaign state', async () => {
973
+ await admin.create(draft());
974
+ await admin.sendTestToMe({ campaignId: 'pilot-clr' });
975
+ const [first] = await admin.getMyCampaigns();
976
+ expect(first?.testId).toBeTruthy();
977
+ expect(first?.campaign.message.title).toBe('Welcome');
978
+ expect(await learner.getMyCampaigns()).toEqual([]);
979
+ expect(await other.getMyCampaigns()).toEqual([]);
980
+ await admin.update({ campaignId: 'pilot-clr', updates: { message: { title: 'Edited' } } });
981
+ expect((await caller('campaign-admin').getMyCampaigns())[0]?.campaign.message.title).toBe(
982
+ 'Welcome'
983
+ );
984
+ await learner.recordEvent({
985
+ campaignId: 'pilot-clr',
986
+ event: 'shown',
987
+ testId: first!.testId,
988
+ });
989
+ expect(await admin.getMyCampaigns()).toHaveLength(1);
990
+ await admin.sendTestToMe({ campaignId: 'pilot-clr' });
991
+ const [latest] = await admin.getMyCampaigns();
992
+ expect(latest?.testId).not.toBe(first?.testId);
993
+ await admin.recordEvent({ campaignId: 'pilot-clr', event: 'shown', testId: first!.testId });
994
+ expect((await admin.getMyCampaigns())[0]?.testId).toBe(latest?.testId);
995
+ await admin.recordEvent({
996
+ campaignId: 'pilot-clr',
997
+ event: 'shown',
998
+ testId: latest!.testId,
999
+ });
1000
+ expect(await admin.getMyCampaigns()).toEqual([]);
1001
+ expect(await admin.getStats({ campaignId: 'pilot-clr' })).toMatchObject({
1002
+ audience: 0,
1003
+ shown: 0,
1004
+ notified: 0,
1005
+ clicked: 0,
1006
+ });
1007
+ expect((await admin.get({ campaignId: 'pilot-clr' })).status).toBe('draft');
1008
+ });
1009
+ it('keeps live campaigns and valid tests visible when another private snapshot is corrupt', async () => {
1010
+ await admin.create(draft({ status: 'live', selfEnroll: true }));
1011
+ await admin.create(draft({ id: 'valid-test' }));
1012
+ await admin.sendTestToMe({ campaignId: 'valid-test' });
1013
+ await admin.sendTestToMe({ campaignId: 'pilot-clr' });
1014
+ for (const message of ['{', JSON.stringify({ id: 'old', presentation: 'obsolete' })]) {
1015
+ await neogma.queryRunner.run(
1016
+ `MATCH (:Profile {profileId: 'campaign-admin'})-[test:CAMPAIGN_TEST]->(:Campaign {id: 'pilot-clr'})
1017
+ SET test.message = $message`,
1018
+ { message }
1019
+ );
1020
+ const campaigns = await admin.getMyCampaigns();
1021
+ expect(campaigns).toHaveLength(2);
1022
+ expect(
1023
+ campaigns.find(item => item.campaign.id === 'pilot-clr')?.testId
1024
+ ).toBeUndefined();
1025
+ expect(campaigns.find(item => item.campaign.id === 'valid-test')?.testId).toBeTruthy();
1026
+ }
1027
+ });
1028
+ it('clears inactive message content and rejects missing active content on merged updates', async () => {
1029
+ await admin.create(draft());
1030
+ const notification = { title: 'Hello', body: 'Notification only' };
1031
+ const updated = await admin.update({
1032
+ campaignId: 'pilot-clr',
1033
+ updates: { channels: ['notify'], message: null, notification },
1034
+ });
1035
+ expect(updated.message).toBeUndefined();
1036
+ await expect(
1037
+ admin.update({ campaignId: 'pilot-clr', updates: { channels: ['inApp'] } })
1038
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1039
+ await expect(
1040
+ admin.update({
1041
+ campaignId: 'pilot-clr',
1042
+ updates: {
1043
+ channels: ['inApp'],
1044
+ message: { title: 'Restored' },
1045
+ notification: null,
1046
+ },
1047
+ })
1048
+ ).resolves.toMatchObject({ message: { title: 'Restored' } });
1049
+ });
1050
+ it('test delivery does not increment stats and only targets the authenticated administrator', async () => {
1051
+ await admin.create(
1052
+ draft({
1053
+ channels: ['notify'],
1054
+ message: undefined,
1055
+ notification: { title: 'Test', body: 'Preview' },
1056
+ })
1057
+ );
1058
+ const queue = vi.spyOn(notifications, 'addNotificationToQueue').mockResolvedValue(true);
1059
+ const signer = vi.spyOn(learnCardHelpers, 'getDidWebLearnCard').mockResolvedValue({
1060
+ id: { did: () => 'did:web:brain.example' },
1061
+ } as never);
1062
+ try {
1063
+ await expect(admin.sendTestToMe({ campaignId: 'pilot-clr' })).resolves.toBe(true);
1064
+ expect(queue).toHaveBeenCalledWith(
1065
+ expect.objectContaining({
1066
+ type: 'ANNOUNCEMENT',
1067
+ to: expect.objectContaining({ profileId: 'campaign-admin' }),
1068
+ data: {
1069
+ metadata: expect.objectContaining({
1070
+ campaignId: 'pilot-clr',
1071
+ campaignTestId: expect.any(String),
1072
+ }),
1073
+ },
1074
+ }),
1075
+ expect.anything()
1076
+ );
1077
+ await expect(admin.getStats({ campaignId: 'pilot-clr' })).resolves.toMatchObject({
1078
+ notified: 0,
1079
+ emailed: 0,
1080
+ });
1081
+ await expect(admin.dispatch({ campaignId: 'pilot-clr' })).rejects.toMatchObject({
1082
+ code: 'BAD_REQUEST',
1083
+ });
1084
+ } finally {
1085
+ queue.mockRestore();
1086
+ signer.mockRestore();
1087
+ }
1088
+ });
1089
+
1090
+ it('suppresses paused, removed-channel and age-ineligible recipients', async () => {
1091
+ const campaign = await admin.create(
1092
+ draft({
1093
+ status: 'live',
1094
+ channels: ['notify'],
1095
+ notification: { title: 'Test', body: 'Preview' },
1096
+ })
1097
+ );
1098
+ await addLearner();
1099
+ await admin.setStatus({ campaignId: campaign.id, status: 'paused' });
1100
+ await expect(
1101
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
1102
+ ).resolves.toBeUndefined();
1103
+ await admin.setStatus({ campaignId: campaign.id, status: 'live' });
1104
+ await admin.update({ campaignId: campaign.id, updates: { channels: ['inApp'] } });
1105
+ await expect(
1106
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
1107
+ ).resolves.toBeUndefined();
1108
+ await admin.update({ campaignId: campaign.id, updates: { channels: ['notify'] } });
1109
+ await neogma.queryRunner.run(
1110
+ "MATCH (p:Profile {profileId: 'learner'}) SET p.dob = '2020-01-01'"
1111
+ );
1112
+ await expect(
1113
+ getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
1114
+ ).resolves.toBeUndefined();
1115
+ });
1116
+ });
1117
+
1118
+ describe('confirmed audience notification dispatch', () => {
1119
+ const live = () =>
1120
+ draft({
1121
+ status: 'live',
1122
+ channels: ['notify'],
1123
+ notification: { title: 'Campaign notice', body: 'A test message' },
1124
+ });
1125
+ it('reviews both channels and invalidates confirmation when the primary email changes', async () => {
1126
+ await admin.create(live());
1127
+ await addLearner();
1128
+ await admin.update({
1129
+ campaignId: 'pilot-clr',
1130
+ updates: {
1131
+ channels: ['notify', 'email'],
1132
+ email: { subject: 'News', title: 'News', body: 'News', ctaLabel: 'View' },
1133
+ },
1134
+ });
1135
+ await neogma.queryRunner.run(`MATCH (p:Profile {profileId:'learner'})
1136
+ CREATE (p)-[:HAS_CONTACT_METHOD]->(:ContactMethod {id:'review-email', type:'email',
1137
+ value:'learner@example.com', isPrimary:true, isVerified:true})`);
1138
+ const preview = await admin.previewDispatch({ campaignId: 'pilot-clr' });
1139
+ expect(preview).toMatchObject({
1140
+ recipientCount: 1,
1141
+ notificationRecipientCount: 1,
1142
+ emailRecipientCount: 1,
1143
+ });
1144
+ expect(preview.blockedReason).toBeUndefined();
1145
+ await neogma.queryRunner.run(
1146
+ `MATCH (e:ContactMethod {id:'review-email'}) SET e.value = 'changed@example.com'`
1147
+ );
1148
+ await expect(
1149
+ admin.dispatch({ campaignId: 'pilot-clr', expectedPreviewToken: preview.previewToken })
1150
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
1151
+ });
1152
+ it('excludes push opt-outs from notification confirmation counts', async () => {
1153
+ await admin.create(live());
1154
+ await addLearner();
1155
+ expect((await admin.previewDispatch({ campaignId: 'pilot-clr' })).recipientCount).toBe(1);
1156
+ await learner.updateAnnouncementPreferences({ announcementPushOptOut: true });
1157
+ const preview = await admin.previewDispatch({ campaignId: 'pilot-clr' });
1158
+ expect(preview.recipientCount).toBe(0);
1159
+ expect(preview.blockedReason).toBe('no_recipients');
1160
+ });
1161
+ const id = { campaignId: 'pilot-clr' };
1162
+ const deliveryMocks = () => {
1163
+ const enqueue = vi.spyOn(notifications, 'addNotificationToQueue').mockResolvedValue(true);
1164
+ const signer = vi
1165
+ .spyOn(learnCardHelpers, 'getDidWebLearnCard')
1166
+ .mockResolvedValue({ id: { did: () => 'did:web:brain.test' } } as never);
1167
+ return {
1168
+ enqueue,
1169
+ restore: () => {
1170
+ enqueue.mockRestore();
1171
+ signer.mockRestore();
1172
+ },
1173
+ };
1174
+ };
1175
+ it('counts the same delivery-eligible members, excluding already notified and ineligible profiles', async () => {
1176
+ const campaign = await admin.create(live());
1177
+ await neogma.queryRunner.run(
1178
+ `MATCH (c:Campaign {id: $id}), (p:Profile)
1179
+ WHERE p.profileId IN $profiles CREATE (p)-[:IN_CAMPAIGN_AUDIENCE]->(c)`,
1180
+ {
1181
+ id: campaign.id,
1182
+ profiles: [
1183
+ 'learner',
1184
+ 'other',
1185
+ 'minor',
1186
+ 'child',
1187
+ 'unknown-dob',
1188
+ 'managed-adult',
1189
+ 'managed-service',
1190
+ ],
1191
+ }
1192
+ );
1193
+ await neogma.queryRunner
1194
+ .run(`MATCH (owner:Profile {profileId: 'campaign-admin'}), (p:Profile)
1195
+ WHERE p.profileId IN ['managed-adult', 'managed-service'] CREATE (p)-[:MANAGED_BY]->(owner)`);
1196
+ await acknowledgeCampaignNotification({
1197
+ type: 'ANNOUNCEMENT',
1198
+ from: 'did:web:brain.test',
1199
+ to: { profileId: 'other', did: did('other') },
1200
+ data: { metadata: { campaignId: campaign.id, campaignCreatedAt: campaign.createdAt } },
1201
+ });
1202
+ const preview = await admin.previewDispatch(id);
1203
+ expect(preview).toMatchObject({
1204
+ audienceCount: 7,
1205
+ recipientCount: 2,
1206
+ alreadyNotifiedCount: 1,
1207
+ });
1208
+ expect(preview.previewToken).toMatch(/^[a-f0-9]{64}$/);
1209
+ expect(await admin.previewDispatch(id)).toEqual(preview);
1210
+ for (const profile of ['learner', 'managed-service'])
1211
+ expect(
1212
+ await getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, profile)
1213
+ ).toBeTruthy();
1214
+ for (const profile of ['minor', 'child', 'unknown-dob', 'managed-adult', 'other'])
1215
+ expect(
1216
+ await getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, profile)
1217
+ ).toBeUndefined();
1218
+ await admin.update({ ...id, updates: { excludeMinors: false } });
1219
+ expect(await admin.previewDispatch(id)).toMatchObject({ recipientCount: 5 });
1220
+ });
1221
+ it('accepts the recipient limit and rejects larger previews and confirmations without enqueueing', async () => {
1222
+ await admin.create(live());
1223
+ await neogma.queryRunner.run(
1224
+ `MATCH (c:Campaign {id: $campaignId})
1225
+ UNWIND range(1, $count) AS index
1226
+ CREATE (p:Profile {profileId: 'limit-' + toString(index), dob: '1990-01-01'})
1227
+ CREATE (p)-[:IN_CAMPAIGN_AUDIENCE]->(c)`,
1228
+ { ...id, count: int(MAX_CONFIRMED_DISPATCH_RECIPIENTS) }
1229
+ );
1230
+ const preview = await admin.previewDispatch(id);
1231
+ expect(preview.recipientCount).toBe(MAX_CONFIRMED_DISPATCH_RECIPIENTS);
1232
+ await addLearner();
1233
+ const mocks = deliveryMocks();
1234
+ try {
1235
+ await expect(admin.previewDispatch(id)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1236
+ await expect(
1237
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken })
1238
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1239
+ expect(mocks.enqueue).not.toHaveBeenCalled();
1240
+ } finally {
1241
+ mocks.restore();
1242
+ }
1243
+ });
1244
+ it.each([
1245
+ [{ status: 'draft' }, 'not_live'],
1246
+ [{ status: 'paused' }, 'not_live'],
1247
+ [{ status: 'archived' }, 'not_live'],
1248
+ [{ channels: ['inApp'], message: { title: 'In-app only' } }, 'notify_disabled'],
1249
+ [{ startsAt: '2999-01-01T00:00:00Z' }, 'not_started'],
1250
+ [{ endsAt: '2000-01-01T00:00:00Z' }, 'ended'],
1251
+ ] as const)('explains non-dispatchable campaigns (%j)', async (updates, reason) => {
1252
+ await admin.create({ ...live(), ...updates } as CreateCampaignInput);
1253
+ const preview = await admin.previewDispatch(id);
1254
+ expect(preview).toMatchObject({ blockedReason: reason, recipientCount: 0 });
1255
+ await expect(
1256
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken })
1257
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1258
+ });
1259
+ it('dispatches to the stored audience and requires a fresh eligible preview after delivery', async () => {
1260
+ await admin.create(live());
1261
+ await addLearner();
1262
+ const mocks = deliveryMocks();
1263
+ try {
1264
+ const preview = await admin.previewDispatch(id);
1265
+ expect(preview.recipientCount).toBe(1);
1266
+ expect(mocks.enqueue).not.toHaveBeenCalled();
1267
+ await expect(
1268
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken })
1269
+ ).resolves.toHaveProperty('dispatchId');
1270
+ expect(mocks.enqueue).toHaveBeenCalledTimes(1);
1271
+ expect(mocks.enqueue.mock.calls[0][0]).toMatchObject({
1272
+ to: { profileId: 'learner' },
1273
+ type: 'ANNOUNCEMENT',
1274
+ });
1275
+ expect(await admin.getStats(id)).toMatchObject({ audience: 1, notified: 1 });
1276
+ await expect(
1277
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken })
1278
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
1279
+ const empty = await admin.previewDispatch(id);
1280
+ expect(empty).toMatchObject({
1281
+ recipientCount: 0,
1282
+ alreadyNotifiedCount: 1,
1283
+ blockedReason: 'no_recipients',
1284
+ });
1285
+ await expect(
1286
+ admin.dispatch({ ...id, expectedPreviewToken: empty.previewToken })
1287
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1288
+ expect(mocks.enqueue).toHaveBeenCalledTimes(1);
1289
+ } finally {
1290
+ mocks.restore();
1291
+ }
1292
+ });
1293
+ it('rejects stale recipient identities even when their count is unchanged', async () => {
1294
+ await admin.create(live());
1295
+ await addLearner();
1296
+ const preview = await admin.previewDispatch(id);
1297
+ await neogma.queryRunner.run(
1298
+ `MATCH (p:Profile {profileId:'learner'})-[a:IN_CAMPAIGN_AUDIENCE]->(c:Campaign {id:$id}), (other:Profile {profileId:'other'}) DELETE a CREATE (other)-[:IN_CAMPAIGN_AUDIENCE]->(c)`,
1299
+ { id: id.campaignId }
1300
+ );
1301
+ expect((await admin.previewDispatch(id)).recipientCount).toBe(preview.recipientCount);
1302
+ await expect(
1303
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken })
1304
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
1305
+ });
1306
+ it('rejects a changed campaign definition and concurrent reuse of the confirmation', async () => {
1307
+ await admin.create(live());
1308
+ await addLearner();
1309
+ const before = await admin.previewDispatch(id);
1310
+ await admin.update({
1311
+ ...id,
1312
+ updates: { notification: { title: 'Changed', body: 'Changed' } },
1313
+ });
1314
+ await expect(
1315
+ admin.dispatch({ ...id, expectedPreviewToken: before.previewToken })
1316
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
1317
+ const preview = await admin.previewDispatch(id);
1318
+ const mocks = deliveryMocks();
1319
+ try {
1320
+ const results = await Promise.allSettled([
1321
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken }),
1322
+ admin.dispatch({ ...id, expectedPreviewToken: preview.previewToken }),
1323
+ ]);
1324
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
1325
+ expect(results.filter(result => result.status === 'rejected')).toHaveLength(1);
1326
+ expect(mocks.enqueue).toHaveBeenCalledTimes(1);
1327
+ } finally {
1328
+ mocks.restore();
1329
+ }
1330
+ });
1331
+ it('recovers from an enqueue failure with a fresh preview without marking a recipient notified', async () => {
1332
+ await admin.create(live());
1333
+ await addLearner();
1334
+ const mocks = deliveryMocks();
1335
+ mocks.enqueue.mockRejectedValueOnce(new Error('temporary transport failure'));
1336
+ try {
1337
+ const first = await admin.previewDispatch(id);
1338
+ await expect(
1339
+ admin.dispatch({ ...id, expectedPreviewToken: first.previewToken })
1340
+ ).rejects.toThrow();
1341
+ expect(await admin.getStats(id)).toMatchObject({ notified: 0 });
1342
+ const next = await admin.previewDispatch(id);
1343
+ expect(next.recipientCount).toBe(1);
1344
+ expect(next.previewToken).not.toBe(first.previewToken);
1345
+ await expect(
1346
+ admin.dispatch({ ...id, expectedPreviewToken: next.previewToken })
1347
+ ).resolves.toHaveProperty('dispatchId');
1348
+ expect(await admin.getStats(id)).toMatchObject({ notified: 1 });
1349
+ } finally {
1350
+ mocks.restore();
1351
+ }
1352
+ });
1353
+ });
1354
+
1355
+ describe('campaign email database boundaries', () => {
1356
+ const email = { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' };
1357
+ const setup = async () => {
1358
+ const campaign = await admin.create(
1359
+ draft({
1360
+ channels: ['inApp', 'notify', 'email'],
1361
+ notification: { title: 'News', body: 'Programs' },
1362
+ email,
1363
+ status: 'live',
1364
+ })
1365
+ );
1366
+ await addLearner();
1367
+ await neogma.queryRunner.run(
1368
+ `MATCH (p:Profile {profileId: 'learner'}) CREATE (p)-[:HAS_CONTACT_METHOD]->(:ContactMethod {id:'primary', type:'email', value:'learner@example.com', isVerified:true, isPrimary:true})`
1369
+ );
1370
+ return campaign;
1371
+ };
1372
+ it('keeps old links bound to the original recipient after renames and name reuse', async () => {
1373
+ const campaign = await setup();
1374
+ const recipient = await claimCampaignEmail(
1375
+ campaign.id,
1376
+ campaign.createdAt,
1377
+ 'learner',
1378
+ true
1379
+ );
1380
+ const token = signCampaignUnsubscribeToken(recipient!.unsubscribeId, campaign.id);
1381
+ await updateProfile((await getProfileByProfileId('learner'))!, {
1382
+ profileId: 'renamed-learner',
1383
+ });
1384
+ const anonymous = campaignsRouter.createCaller({ domain });
1385
+ await anonymous.unsubscribe({ token });
1386
+ expect((await getAnnouncementPreferences('renamed-learner')).announcementEmailOptOut).toBe(
1387
+ true
1388
+ );
1389
+
1390
+ await neogma.queryRunner.run(
1391
+ "MATCH (p:Profile {profileId:'renamed-learner'}) SET p.announcementEmailOptOut = false"
1392
+ );
1393
+ await updateProfile((await getProfileByProfileId('other'))!, { profileId: 'learner' });
1394
+ await anonymous.unsubscribe({ token });
1395
+ expect((await getAnnouncementPreferences('renamed-learner')).announcementEmailOptOut).toBe(
1396
+ true
1397
+ );
1398
+ expect((await getAnnouncementPreferences('learner')).announcementEmailOptOut).toBe(false);
1399
+ });
1400
+
1401
+ it('does not transfer an old link to a new holder after recipient deletion', async () => {
1402
+ const campaign = await setup();
1403
+ const recipient = await claimCampaignEmail(
1404
+ campaign.id,
1405
+ campaign.createdAt,
1406
+ 'learner',
1407
+ true
1408
+ );
1409
+ const token = signCampaignUnsubscribeToken(recipient!.unsubscribeId, campaign.id);
1410
+ await neogma.queryRunner.run("MATCH (p:Profile {profileId:'learner'}) DETACH DELETE p");
1411
+ await updateProfile((await getProfileByProfileId('other'))!, { profileId: 'learner' });
1412
+ await campaignsRouter.createCaller({ domain }).unsubscribe({ token });
1413
+ expect((await getAnnouncementPreferences('learner')).announcementEmailOptOut).toBe(false);
1414
+ });
1415
+
1416
+ it('allocates one durable unsubscribe identity across concurrent self-tests', async () => {
1417
+ const campaign = await setup();
1418
+ const recipients = await Promise.all(
1419
+ Array.from({ length: 8 }, () =>
1420
+ claimCampaignEmail(campaign.id, campaign.createdAt, 'learner', true)
1421
+ )
1422
+ );
1423
+ expect(new Set(recipients.map(recipient => recipient!.unsubscribeId)).size).toBe(1);
1424
+ expect(recipients[0]!.unsubscribeId).toMatch(/^[0-9a-f-]{36}$/);
1425
+ });
1426
+
1427
+ it('filters email opt-outs before pagination without hiding them from audience management', async () => {
1428
+ const campaign = await setup();
1429
+ await admin.addAudience(audience({ type: 'dids', dids: [did('other')] }));
1430
+ await neogma.queryRunner.run(
1431
+ "MATCH (p:Profile {profileId:'learner'}) SET p.announcementEmailOptOut = true"
1432
+ );
1433
+ const page = await pageCampaignAudience(campaign.id, undefined, 1, 'email');
1434
+ expect(page.records.map(row => row.profileId)).toEqual(['other']);
1435
+ expect(page.hasMore).toBe(false);
1436
+ expect((await pageCampaignAudience(campaign.id, 'other', 1, 'email')).records).toEqual([]);
1437
+ expect((await pageCampaignAudience(campaign.id)).records.map(row => row.profileId)).toEqual(
1438
+ ['learner', 'other']
1439
+ );
1440
+ // A preference change after paging must still be enforced by the locked claim.
1441
+ await neogma.queryRunner.run(
1442
+ "MATCH (p:Profile {profileId:'learner'}) SET p.announcementEmailOptOut = false"
1443
+ );
1444
+ expect(
1445
+ (await pageCampaignAudience(campaign.id, undefined, 1, 'email')).records[0]?.profileId
1446
+ ).toBe('learner');
1447
+ await neogma.queryRunner.run(
1448
+ "MATCH (p:Profile {profileId:'learner'}) SET p.announcementEmailOptOut = true"
1449
+ );
1450
+ expect(
1451
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1452
+ ).toBeUndefined();
1453
+ });
1454
+
1455
+ it('leaves a recipient claimable after local preparation fails', async () => {
1456
+ const campaign = await setup();
1457
+ await expect(
1458
+ claimCampaignEmail(campaign.id, campaign.createdAt, 'learner', false, async () => {
1459
+ throw new Error('Cannot render');
1460
+ })
1461
+ ).rejects.toThrow('Cannot render');
1462
+ expect((await admin.getStats({ campaignId: campaign.id })).emailed).toBe(0);
1463
+ expect(await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')).toMatchObject({
1464
+ email: 'learner@example.com',
1465
+ });
1466
+ });
1467
+ it('atomically claims once across concurrent dispatches and only counts acknowledged sends', async () => {
1468
+ const campaign = await setup();
1469
+ const claims = await Promise.all(
1470
+ Array.from({ length: 8 }, () =>
1471
+ claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1472
+ )
1473
+ );
1474
+ expect(claims.filter(Boolean)).toHaveLength(1);
1475
+ expect((await admin.previewDispatch({ campaignId: campaign.id })).emailRecipientCount).toBe(
1476
+ 0
1477
+ );
1478
+ expect((await admin.getStats({ campaignId: campaign.id })).emailed).toBe(0);
1479
+ await acknowledgeCampaignEmail(campaign.id, campaign.createdAt, 'learner');
1480
+ await acknowledgeCampaignEmail(campaign.id, campaign.createdAt, 'learner');
1481
+ expect((await admin.getStats({ campaignId: campaign.id })).emailed).toBe(1);
1482
+ expect(
1483
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1484
+ ).toBeUndefined();
1485
+ });
1486
+ it.each([
1487
+ 'SET p.announcementEmailOptOut = true',
1488
+ "SET p.dob = '2020-01-01'",
1489
+ 'REMOVE p.dob',
1490
+ "SET p.type = 'child'",
1491
+ 'SET email.isVerified = false',
1492
+ 'SET email.isPrimary = false',
1493
+ "SET email.value = 'invalid'",
1494
+ ])('skips ineligible recipients: %s', async change => {
1495
+ const campaign = await setup();
1496
+ await neogma.queryRunner.run(
1497
+ `MATCH (p:Profile {profileId:'learner'})-[:HAS_CONTACT_METHOD]->(email:ContactMethod) ${change}`
1498
+ );
1499
+ expect(
1500
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1501
+ ).toBeUndefined();
1502
+ expect(
1503
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner', true)
1504
+ ).toBeUndefined();
1505
+ expect((await admin.previewDispatch({ campaignId: campaign.id })).emailRecipientCount).toBe(
1506
+ 0
1507
+ );
1508
+ });
1509
+ it('keeps minors excluded from email even when the campaign relaxes the older age flag', async () => {
1510
+ const campaign = await setup();
1511
+ await admin.update({ campaignId: campaign.id, updates: { excludeMinors: false } });
1512
+ await neogma.queryRunner.run(
1513
+ "MATCH (p:Profile {profileId:'learner'}) SET p.dob = '2020-01-01'"
1514
+ );
1515
+ expect(
1516
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1517
+ ).toBeUndefined();
1518
+ });
1519
+ it('checks pause, membership and campaign generation at claim time', async () => {
1520
+ const campaign = await setup();
1521
+ expect(
1522
+ await claimCampaignEmail(campaign.id, '2000-01-01T00:00:00.000Z', 'learner')
1523
+ ).toBeUndefined();
1524
+ await admin.setStatus({ campaignId: campaign.id, status: 'paused' });
1525
+ expect(
1526
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1527
+ ).toBeUndefined();
1528
+ expect(
1529
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner', true)
1530
+ ).toBeDefined();
1531
+ await admin.setStatus({ campaignId: campaign.id, status: 'live' });
1532
+ await neogma.queryRunner.run(
1533
+ "MATCH (:Profile {profileId:'learner'})-[a:IN_CAMPAIGN_AUDIENCE]->(:Campaign) DELETE a"
1534
+ );
1535
+ expect(
1536
+ await claimCampaignEmail(campaign.id, campaign.createdAt, 'learner')
1537
+ ).toBeUndefined();
1538
+ });
1539
+ it('authenticates preferences, patches choices independently, and leaves in-app messages available', async () => {
1540
+ const campaign = await setup();
1541
+ await expect(
1542
+ campaignsRouter.createCaller({ domain }).getAnnouncementPreferences()
1543
+ ).rejects.toThrow();
1544
+ expect(await learner.getAnnouncementPreferences()).toEqual({
1545
+ announcementEmailOptOut: false,
1546
+ announcementPushOptOut: false,
1547
+ });
1548
+ await learner.updateAnnouncementPreferences({ announcementPushOptOut: true });
1549
+ expect(
1550
+ await getCampaignDeliveryRecipient(campaign.id, campaign.createdAt, 'learner')
1551
+ ).toBeUndefined();
1552
+ expect(await learner.getMyCampaigns()).toHaveLength(1);
1553
+ await learner.updateAnnouncementPreferences({ announcementEmailOptOut: true });
1554
+ expect(await learner.getAnnouncementPreferences()).toEqual({
1555
+ announcementEmailOptOut: true,
1556
+ announcementPushOptOut: true,
1557
+ });
1558
+ expect(await other.getAnnouncementPreferences()).toEqual({
1559
+ announcementEmailOptOut: false,
1560
+ announcementPushOptOut: false,
1561
+ });
1562
+ });
1563
+ });