@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.
- package/.env.example +14 -0
- package/CHANGELOG.md +143 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
package/test/campaigns.spec.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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)).
|
|
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)).
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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)).
|
|
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
|
-
).
|
|
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)).
|
|
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)).
|
|
357
|
-
expect(await admin.addAudience(input)).
|
|
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)).
|
|
388
|
-
expect(await admin.addAudience(input)).
|
|
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))).
|
|
450
|
-
expect(await admin.addAudience(audience(source))).
|
|
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
|
-
).
|
|
592
|
+
).toMatchObject({ count: 2 });
|
|
454
593
|
expect(
|
|
455
594
|
await admin.previewAudience(audience({ ...source, metadata: { kind: 'badge' } }))
|
|
456
|
-
).
|
|
595
|
+
).toMatchObject({ count: 0 });
|
|
457
596
|
expect(
|
|
458
597
|
await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
|
|
459
|
-
).
|
|
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
|
-
).
|
|
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))).
|
|
471
|
-
expect(
|
|
472
|
-
{
|
|
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
|
-
).
|
|
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))).
|
|
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))).
|
|
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))).
|
|
525
|
-
expect(await admin.addAudience(audience(source))).
|
|
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
|
-
).
|
|
667
|
+
).toMatchObject({ count: 0 });
|
|
529
668
|
expect(
|
|
530
669
|
await admin.previewAudience(audience({ ...source, since: '2026-10-21T00:00:00Z' }))
|
|
531
|
-
).
|
|
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
|
+
});
|