@learncard/network-brain-service 4.1.2 → 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 +164 -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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { sendCampaignEmail } from './campaign-email.helpers';
|
|
2
|
+
import { getAnnouncementPreferences } from '@accesslayer/campaign/preferences';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { TRPCError } from '@trpc/server';
|
|
5
|
+
import type { Campaign, LCNNotification } from '@learncard/types';
|
|
6
|
+
import { getCampaign } from '@accesslayer/campaign/read';
|
|
7
|
+
import { queueCampaignInAppTest } from '@accesslayer/campaign/test';
|
|
8
|
+
import {
|
|
9
|
+
acknowledgeCampaignNotification,
|
|
10
|
+
getCampaignDeliveryRecipient,
|
|
11
|
+
} from '@accesslayer/campaign/delivery';
|
|
12
|
+
import { getDidWebLearnCard } from '@helpers/learnCard.helpers';
|
|
13
|
+
import { addNotificationToQueue } from '@helpers/notifications.helpers';
|
|
14
|
+
|
|
15
|
+
export const canDispatchCampaign = (campaign: Campaign, now = new Date().toISOString()): boolean =>
|
|
16
|
+
campaign.status === 'live' &&
|
|
17
|
+
((campaign.channels.includes('notify') && Boolean(campaign.notification)) ||
|
|
18
|
+
(campaign.channels.includes('email') && Boolean(campaign.email))) &&
|
|
19
|
+
(!campaign.startsAt || campaign.startsAt <= now) &&
|
|
20
|
+
(!campaign.endsAt || campaign.endsAt > now);
|
|
21
|
+
|
|
22
|
+
export const buildCampaignNotification = async (
|
|
23
|
+
campaign: Campaign,
|
|
24
|
+
recipient: LCNNotification['to'],
|
|
25
|
+
campaignTestId?: string
|
|
26
|
+
): Promise<LCNNotification> => {
|
|
27
|
+
if (!campaign.notification)
|
|
28
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Campaign has no notification copy' });
|
|
29
|
+
const learnCard = await getDidWebLearnCard();
|
|
30
|
+
return {
|
|
31
|
+
type: 'ANNOUNCEMENT',
|
|
32
|
+
to: {
|
|
33
|
+
did: recipient.did,
|
|
34
|
+
profileId: recipient.profileId,
|
|
35
|
+
...(recipient.notificationsWebhook
|
|
36
|
+
? { notificationsWebhook: recipient.notificationsWebhook }
|
|
37
|
+
: {}),
|
|
38
|
+
},
|
|
39
|
+
from: learnCard.id.did(),
|
|
40
|
+
message: campaign.notification,
|
|
41
|
+
data: {
|
|
42
|
+
metadata: {
|
|
43
|
+
campaignId: campaign.id,
|
|
44
|
+
campaignCreatedAt: campaign.createdAt,
|
|
45
|
+
campaignHasInApp:
|
|
46
|
+
!campaignTestId &&
|
|
47
|
+
campaign.channels.includes('inApp') &&
|
|
48
|
+
campaign.message?.enabled === true,
|
|
49
|
+
...(campaignTestId ? { campaignTestId } : {}),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const authorizeCampaignNotification = async (
|
|
56
|
+
notification: LCNNotification
|
|
57
|
+
): Promise<boolean> => {
|
|
58
|
+
if (notification.type !== 'ANNOUNCEMENT') return true;
|
|
59
|
+
const metadata = notification.data?.metadata;
|
|
60
|
+
if (!metadata?.campaignId || !metadata.campaignCreatedAt || !notification.to.profileId)
|
|
61
|
+
throw new Error('Campaign delivery is missing its identity');
|
|
62
|
+
// Test jobs were addressed to the authenticated admin by sendTestToMe.
|
|
63
|
+
if (metadata.campaignTestId)
|
|
64
|
+
return !(await getAnnouncementPreferences(notification.to.profileId))
|
|
65
|
+
.announcementPushOptOut;
|
|
66
|
+
let campaign: Campaign;
|
|
67
|
+
try {
|
|
68
|
+
campaign = await getCampaign(metadata.campaignId);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof TRPCError && error.code === 'NOT_FOUND') return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
canDispatchCampaign(campaign) &&
|
|
75
|
+
Boolean(
|
|
76
|
+
await getCampaignDeliveryRecipient(
|
|
77
|
+
metadata.campaignId,
|
|
78
|
+
metadata.campaignCreatedAt,
|
|
79
|
+
notification.to.profileId
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const enqueueCampaignNotification = async (notification: LCNNotification): Promise<void> => {
|
|
86
|
+
const result = await addNotificationToQueue(notification, {
|
|
87
|
+
propagateDirectWebhookTransportErrors: true,
|
|
88
|
+
throwOnPermanentFailure: true,
|
|
89
|
+
});
|
|
90
|
+
if (result === false) throw new Error('Campaign notification was not stored');
|
|
91
|
+
// The local fallback returns a storage acknowledgement. SQS only acknowledges enqueue.
|
|
92
|
+
if (result === true) await acknowledgeCampaignNotification(notification);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const sendCampaignTestToMe = async (
|
|
96
|
+
campaignId: string,
|
|
97
|
+
caller: LCNNotification['to']
|
|
98
|
+
): Promise<boolean> => {
|
|
99
|
+
const campaign = await getCampaign(campaignId);
|
|
100
|
+
if (!caller.profileId)
|
|
101
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Profile required' });
|
|
102
|
+
const testId = randomUUID();
|
|
103
|
+
const preferences = await getAnnouncementPreferences(caller.profileId);
|
|
104
|
+
// Check before any selected channel sends so a failed test cannot report partial success.
|
|
105
|
+
if (campaign.channels.includes('notify') && preferences.announcementPushOptOut)
|
|
106
|
+
throw new TRPCError({
|
|
107
|
+
code: 'PRECONDITION_FAILED',
|
|
108
|
+
message: 'Notification tests are disabled by your announcement preferences',
|
|
109
|
+
});
|
|
110
|
+
if (campaign.channels.includes('email')) {
|
|
111
|
+
if (!(await sendCampaignEmail(campaign, caller.profileId, true)))
|
|
112
|
+
throw new TRPCError({
|
|
113
|
+
code: 'BAD_REQUEST',
|
|
114
|
+
message:
|
|
115
|
+
'A verified primary email and eligible announcement preferences are required',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (campaign.channels.includes('notify')) {
|
|
119
|
+
await enqueueCampaignNotification(
|
|
120
|
+
await buildCampaignNotification(campaign, caller, testId)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (campaign.channels.includes('inApp')) {
|
|
124
|
+
await queueCampaignInAppTest(campaign, caller.profileId, testId);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import type { Campaign, LCNNotification } from '@learncard/types';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
getCampaign: vi.fn(),
|
|
7
|
+
confirmDispatch: vi.fn(),
|
|
8
|
+
queueTest: vi.fn(),
|
|
9
|
+
email: vi.fn(),
|
|
10
|
+
prepareEmail: vi.fn(),
|
|
11
|
+
preferences: vi.fn(),
|
|
12
|
+
pageAudience: vi.fn(),
|
|
13
|
+
recipient: vi.fn(),
|
|
14
|
+
acknowledge: vi.fn(),
|
|
15
|
+
enqueue: vi.fn(),
|
|
16
|
+
send: vi.fn(),
|
|
17
|
+
sqs: vi.fn(),
|
|
18
|
+
environment: {
|
|
19
|
+
IS_OFFLINE: false,
|
|
20
|
+
CAMPAIGN_QUEUE_URL: 'https://sqs.example/campaigns',
|
|
21
|
+
CAMPAIGN_DISPATCH_PAGE_SIZE: 1000,
|
|
22
|
+
CAMPAIGN_EMAIL_PAGE_SIZE: 500,
|
|
23
|
+
CAMPAIGN_DISPATCH_DELAY_SECONDS: 60,
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('@helpers/campaign-email.helpers', () => ({
|
|
27
|
+
prepareCampaignEmailDelivery: mocks.prepareEmail,
|
|
28
|
+
CampaignEmailDeliveryError: class extends Error {},
|
|
29
|
+
sendCampaignEmail: mocks.email,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@accesslayer/campaign/preferences', () => ({
|
|
32
|
+
getAnnouncementPreferences: mocks.preferences,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@environment', () => ({ environment: mocks.environment }));
|
|
35
|
+
vi.mock('@aws-sdk/client-sqs', () => ({
|
|
36
|
+
SQSClient: class {
|
|
37
|
+
send = mocks.sqs;
|
|
38
|
+
},
|
|
39
|
+
SendMessageCommand: class {
|
|
40
|
+
constructor(public input: unknown) {}
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('@accesslayer/campaign/test', () => ({ queueCampaignInAppTest: mocks.queueTest }));
|
|
44
|
+
vi.mock('@accesslayer/campaign/dispatch', () => ({
|
|
45
|
+
authorizeConfirmedCampaignDispatch: mocks.confirmDispatch,
|
|
46
|
+
}));
|
|
47
|
+
vi.mock('@accesslayer/campaign/read', () => ({ getCampaign: mocks.getCampaign }));
|
|
48
|
+
vi.mock('@accesslayer/campaign/audience', () => ({ pageCampaignAudience: mocks.pageAudience }));
|
|
49
|
+
vi.mock('@accesslayer/campaign/delivery', () => ({
|
|
50
|
+
getCampaignDeliveryRecipient: mocks.recipient,
|
|
51
|
+
acknowledgeCampaignNotification: mocks.acknowledge,
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
54
|
+
getDidWebLearnCard: async () => ({ id: { did: () => 'did:web:brain.example' } }),
|
|
55
|
+
}));
|
|
56
|
+
vi.mock('@helpers/notifications.helpers', () => ({
|
|
57
|
+
addNotificationToQueue: mocks.enqueue,
|
|
58
|
+
sendNotification: mocks.send,
|
|
59
|
+
PermanentNotificationDeliveryError: class extends Error {},
|
|
60
|
+
}));
|
|
61
|
+
vi.mock('@helpers/contract-events.helpers', () => ({
|
|
62
|
+
authorizeContractNotification: async () => true,
|
|
63
|
+
}));
|
|
64
|
+
vi.mock('@helpers/connectionPrompt.helpers', () => ({
|
|
65
|
+
acknowledgeConnectionPromptNotificationDelivery: vi.fn(),
|
|
66
|
+
}));
|
|
67
|
+
vi.mock('@tracing', () => ({
|
|
68
|
+
trace: async (_op: string, _name: string, fn: () => unknown) => fn(),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
import {
|
|
72
|
+
dispatchCampaign,
|
|
73
|
+
processCampaignDispatchJob,
|
|
74
|
+
addCampaignDispatchToQueue,
|
|
75
|
+
} from './campaign-queue.helpers';
|
|
76
|
+
import { CampaignEmailDeliveryError } from './campaign-email.helpers';
|
|
77
|
+
import { buildCampaignNotification, sendCampaignTestToMe } from './campaign-notifications.helpers';
|
|
78
|
+
import { deliverQueuedNotification } from './notificationQueue.helpers';
|
|
79
|
+
|
|
80
|
+
const campaign = {
|
|
81
|
+
id: 'pilot-clr',
|
|
82
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
83
|
+
status: 'live',
|
|
84
|
+
channels: ['notify'],
|
|
85
|
+
notification: { title: 'Explore', body: 'Find your next step' },
|
|
86
|
+
} as Campaign;
|
|
87
|
+
const job = {
|
|
88
|
+
campaignId: campaign.id,
|
|
89
|
+
campaignCreatedAt: campaign.createdAt,
|
|
90
|
+
dispatchId: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
|
|
91
|
+
};
|
|
92
|
+
const recipient = (profileId = 'learner') => ({ profileId, did: `did:key:${profileId}` });
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.resetAllMocks();
|
|
96
|
+
mocks.prepareEmail.mockResolvedValue({ prepared: true });
|
|
97
|
+
mocks.preferences.mockResolvedValue({
|
|
98
|
+
announcementEmailOptOut: false,
|
|
99
|
+
announcementPushOptOut: false,
|
|
100
|
+
});
|
|
101
|
+
mocks.environment.IS_OFFLINE = false;
|
|
102
|
+
mocks.environment.CAMPAIGN_DISPATCH_PAGE_SIZE = 1000;
|
|
103
|
+
mocks.environment.CAMPAIGN_DISPATCH_DELAY_SECONDS = 60;
|
|
104
|
+
mocks.getCampaign.mockResolvedValue(campaign);
|
|
105
|
+
mocks.recipient.mockImplementation(async (_id, _createdAt, profileId) => recipient(profileId));
|
|
106
|
+
mocks.pageAudience.mockResolvedValue({ records: [recipient()], hasMore: false });
|
|
107
|
+
mocks.enqueue.mockResolvedValue({ MessageId: 'queued' });
|
|
108
|
+
mocks.send.mockResolvedValue(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('campaign wave dispatch', () => {
|
|
112
|
+
it('validates a reviewed preview before enqueuing and refuses stale confirmations', async () => {
|
|
113
|
+
mocks.confirmDispatch.mockResolvedValueOnce(campaign);
|
|
114
|
+
await dispatchCampaign(campaign.id, 'a'.repeat(64));
|
|
115
|
+
expect(mocks.confirmDispatch).toHaveBeenCalledWith(campaign.id, 'a'.repeat(64));
|
|
116
|
+
expect(mocks.sqs).toHaveBeenCalledTimes(1);
|
|
117
|
+
mocks.confirmDispatch.mockRejectedValueOnce(new TRPCError({ code: 'CONFLICT' }));
|
|
118
|
+
await expect(dispatchCampaign(campaign.id, 'a'.repeat(64))).rejects.toMatchObject({
|
|
119
|
+
code: 'CONFLICT',
|
|
120
|
+
});
|
|
121
|
+
expect(mocks.sqs).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('delivers 2500 recipients in three waves with stable cursors and configured delay', async () => {
|
|
125
|
+
const profiles = Array.from({ length: 2500 }, (_, i) =>
|
|
126
|
+
recipient(String(i).padStart(4, '0'))
|
|
127
|
+
);
|
|
128
|
+
mocks.pageAudience.mockImplementation(async (_id, cursor, limit) => ({
|
|
129
|
+
records: profiles.filter(p => !cursor || p.profileId > cursor).slice(0, limit),
|
|
130
|
+
}));
|
|
131
|
+
await processCampaignDispatchJob(job);
|
|
132
|
+
expect(mocks.enqueue).toHaveBeenCalledTimes(1000);
|
|
133
|
+
const second = mocks.sqs.mock.calls[0]![0].input;
|
|
134
|
+
expect(second.DelaySeconds).toBe(60);
|
|
135
|
+
expect(JSON.parse(second.MessageBody)).toEqual({
|
|
136
|
+
...job,
|
|
137
|
+
channel: 'notify',
|
|
138
|
+
afterProfileId: '0999',
|
|
139
|
+
});
|
|
140
|
+
await processCampaignDispatchJob(JSON.parse(second.MessageBody));
|
|
141
|
+
expect(mocks.enqueue).toHaveBeenCalledTimes(2000);
|
|
142
|
+
const third = mocks.sqs.mock.calls[1]![0].input;
|
|
143
|
+
expect(third.DelaySeconds).toBe(60);
|
|
144
|
+
expect(JSON.parse(third.MessageBody)).toEqual({
|
|
145
|
+
...job,
|
|
146
|
+
channel: 'notify',
|
|
147
|
+
afterProfileId: '1999',
|
|
148
|
+
});
|
|
149
|
+
await processCampaignDispatchJob(JSON.parse(third.MessageBody));
|
|
150
|
+
expect(mocks.enqueue).toHaveBeenCalledTimes(2500);
|
|
151
|
+
expect(mocks.sqs).toHaveBeenCalledTimes(2);
|
|
152
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each(['draft', 'paused', 'archived'])(
|
|
156
|
+
'stops %s campaigns and rejects new dispatch',
|
|
157
|
+
async status => {
|
|
158
|
+
mocks.getCampaign.mockResolvedValue({ ...campaign, status });
|
|
159
|
+
await processCampaignDispatchJob(job);
|
|
160
|
+
await expect(dispatchCampaign(campaign.id)).rejects.toMatchObject({
|
|
161
|
+
code: 'BAD_REQUEST',
|
|
162
|
+
});
|
|
163
|
+
expect(mocks.pageAudience).not.toHaveBeenCalled();
|
|
164
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
it.each([
|
|
169
|
+
{ channels: ['inApp'] },
|
|
170
|
+
{ startsAt: '2999-01-01T00:00:00Z' },
|
|
171
|
+
{ endsAt: '2000-01-01T00:00:00Z' },
|
|
172
|
+
{ createdAt: '2026-02-01T00:00:00Z' },
|
|
173
|
+
])('ignores out-of-window, disabled notify and replaced campaigns (%j)', async changes => {
|
|
174
|
+
mocks.getCampaign.mockResolvedValue({ ...campaign, ...changes });
|
|
175
|
+
await processCampaignDispatchJob(job);
|
|
176
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('discards deleted campaigns but retries database failures', async () => {
|
|
180
|
+
mocks.getCampaign.mockRejectedValueOnce(new TRPCError({ code: 'NOT_FOUND' }));
|
|
181
|
+
await expect(processCampaignDispatchJob(job)).resolves.toBeUndefined();
|
|
182
|
+
mocks.getCampaign.mockRejectedValueOnce(new Error('database unavailable'));
|
|
183
|
+
await expect(processCampaignDispatchJob(job)).rejects.toThrow('database unavailable');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rechecks eligibility and skips already-notified recipients on re-dispatch', async () => {
|
|
187
|
+
mocks.recipient.mockResolvedValue(undefined);
|
|
188
|
+
await processCampaignDispatchJob(job);
|
|
189
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('preserves notification identity after uncertain enqueue and concurrent retries', async () => {
|
|
193
|
+
mocks.enqueue.mockRejectedValueOnce(new Error('lost enqueue response'));
|
|
194
|
+
await expect(processCampaignDispatchJob(job)).rejects.toThrow('lost enqueue response');
|
|
195
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
196
|
+
await Promise.all([
|
|
197
|
+
processCampaignDispatchJob(job),
|
|
198
|
+
processCampaignDispatchJob({
|
|
199
|
+
...job,
|
|
200
|
+
dispatchId: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb',
|
|
201
|
+
}),
|
|
202
|
+
]);
|
|
203
|
+
const payloads = mocks.enqueue.mock.calls.map(([notification]) => notification);
|
|
204
|
+
expect(payloads).toEqual([payloads[0], payloads[0], payloads[0]]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('retries the page if scheduling its continuation fails', async () => {
|
|
208
|
+
mocks.environment.CAMPAIGN_DISPATCH_PAGE_SIZE = 1;
|
|
209
|
+
mocks.sqs.mockRejectedValueOnce(new Error('queue unavailable'));
|
|
210
|
+
await expect(processCampaignDispatchJob(job)).rejects.toThrow('queue unavailable');
|
|
211
|
+
await processCampaignDispatchJob(job);
|
|
212
|
+
expect(mocks.sqs).toHaveBeenCalledTimes(2);
|
|
213
|
+
expect(mocks.sqs.mock.calls[0]![0].input).toEqual(mocks.sqs.mock.calls[1]![0].input);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('runs the local fallback with the configured delay', async () => {
|
|
217
|
+
vi.useFakeTimers();
|
|
218
|
+
try {
|
|
219
|
+
mocks.environment.IS_OFFLINE = true;
|
|
220
|
+
const pending = addCampaignDispatchToQueue(job, 2);
|
|
221
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
222
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
223
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
224
|
+
await pending;
|
|
225
|
+
expect(mocks.enqueue).toHaveBeenCalledOnce();
|
|
226
|
+
expect(mocks.sqs).not.toHaveBeenCalled();
|
|
227
|
+
} finally {
|
|
228
|
+
vi.useRealTimers();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('campaign delivery acknowledgement', () => {
|
|
234
|
+
it('retries graph acknowledgement failure using the same receiver identity', async () => {
|
|
235
|
+
const notification = await buildCampaignNotification(campaign, recipient());
|
|
236
|
+
mocks.acknowledge.mockRejectedValueOnce(new Error('graph unavailable'));
|
|
237
|
+
await expect(deliverQueuedNotification(JSON.stringify(notification))).rejects.toThrow(
|
|
238
|
+
'graph unavailable'
|
|
239
|
+
);
|
|
240
|
+
await deliverQueuedNotification(JSON.stringify(notification));
|
|
241
|
+
expect(mocks.send).toHaveBeenCalledTimes(2);
|
|
242
|
+
expect(mocks.send.mock.calls[0]![0]).toEqual(mocks.send.mock.calls[1]![0]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does not acknowledge failed storage, and suppresses queued work after pause', async () => {
|
|
246
|
+
const body = JSON.stringify(await buildCampaignNotification(campaign, recipient()));
|
|
247
|
+
mocks.send.mockResolvedValue(false);
|
|
248
|
+
await expect(deliverQueuedNotification(body)).rejects.toThrow('not durably stored');
|
|
249
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
250
|
+
mocks.getCampaign.mockResolvedValue({ ...campaign, status: 'paused' });
|
|
251
|
+
await deliverQueuedNotification(body);
|
|
252
|
+
expect(mocks.send).toHaveBeenCalledOnce();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('queues an in-app-only test without requiring or sending notification copy', async () => {
|
|
256
|
+
mocks.preferences.mockResolvedValue({
|
|
257
|
+
announcementEmailOptOut: false,
|
|
258
|
+
announcementPushOptOut: true,
|
|
259
|
+
});
|
|
260
|
+
const privateCampaign = {
|
|
261
|
+
...campaign,
|
|
262
|
+
channels: ['inApp'],
|
|
263
|
+
notification: undefined,
|
|
264
|
+
message: { id: campaign.id, title: 'Private' },
|
|
265
|
+
};
|
|
266
|
+
mocks.getCampaign.mockResolvedValue(privateCampaign);
|
|
267
|
+
await sendCampaignTestToMe(campaign.id, recipient('admin'));
|
|
268
|
+
expect(mocks.queueTest).toHaveBeenCalledWith(privateCampaign, 'admin', expect.any(String));
|
|
269
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
it('tests both selected channels using the same private test identity', async () => {
|
|
272
|
+
const privateCampaign = {
|
|
273
|
+
...campaign,
|
|
274
|
+
channels: ['inApp', 'notify'],
|
|
275
|
+
message: { id: campaign.id, title: 'Private' },
|
|
276
|
+
};
|
|
277
|
+
mocks.getCampaign.mockResolvedValue(privateCampaign);
|
|
278
|
+
await sendCampaignTestToMe(campaign.id, recipient('admin'));
|
|
279
|
+
expect(mocks.queueTest.mock.calls[0]?.[2]).toBe(
|
|
280
|
+
mocks.enqueue.mock.calls[0]?.[0].data.metadata.campaignTestId
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
it('test-to-me ignores status/audience, targets only the caller and distinguishes explicit tests', async () => {
|
|
284
|
+
mocks.getCampaign.mockResolvedValue({
|
|
285
|
+
...campaign,
|
|
286
|
+
status: 'archived',
|
|
287
|
+
channels: ['notify'],
|
|
288
|
+
});
|
|
289
|
+
await sendCampaignTestToMe(campaign.id, recipient('admin'));
|
|
290
|
+
await sendCampaignTestToMe(campaign.id, recipient('admin'));
|
|
291
|
+
const first = mocks.enqueue.mock.calls[0]![0] as LCNNotification;
|
|
292
|
+
const second = mocks.enqueue.mock.calls[1]![0] as LCNNotification;
|
|
293
|
+
expect(first.to).toEqual(recipient('admin'));
|
|
294
|
+
expect(first.data?.metadata?.campaignTestId).not.toBe(
|
|
295
|
+
second.data?.metadata?.campaignTestId
|
|
296
|
+
);
|
|
297
|
+
await deliverQueuedNotification(JSON.stringify(first));
|
|
298
|
+
expect(mocks.send).toHaveBeenCalledOnce();
|
|
299
|
+
expect(mocks.recipient).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('email channel dispatch', () => {
|
|
304
|
+
it('fails stream preflight before paging or claiming recipients', async () => {
|
|
305
|
+
mocks.getCampaign.mockResolvedValue({
|
|
306
|
+
...campaign,
|
|
307
|
+
channels: ['email'],
|
|
308
|
+
email: { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' },
|
|
309
|
+
});
|
|
310
|
+
mocks.prepareEmail.mockRejectedValueOnce(new Error('Invalid broadcast stream'));
|
|
311
|
+
await expect(processCampaignDispatchJob({ ...job, channel: 'email' })).rejects.toThrow(
|
|
312
|
+
'Invalid broadcast stream'
|
|
313
|
+
);
|
|
314
|
+
expect(mocks.pageAudience).not.toHaveBeenCalled();
|
|
315
|
+
expect(mocks.email).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
it('continues the page and schedules the next wave after a reserved send fails', async () => {
|
|
318
|
+
mocks.getCampaign.mockResolvedValue({
|
|
319
|
+
...campaign,
|
|
320
|
+
channels: ['email'],
|
|
321
|
+
email: { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' },
|
|
322
|
+
});
|
|
323
|
+
mocks.pageAudience.mockResolvedValue({
|
|
324
|
+
records: Array.from({ length: 500 }, (_, i) => recipient(String(i).padStart(4, '0'))),
|
|
325
|
+
});
|
|
326
|
+
mocks.email.mockRejectedValueOnce(new CampaignEmailDeliveryError('private details'));
|
|
327
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
328
|
+
try {
|
|
329
|
+
await processCampaignDispatchJob({ ...job, channel: 'email' });
|
|
330
|
+
expect(mocks.prepareEmail).toHaveBeenCalledOnce();
|
|
331
|
+
expect(mocks.email).toHaveBeenCalledTimes(500);
|
|
332
|
+
expect(mocks.email).toHaveBeenLastCalledWith(expect.anything(), '0499', false, {
|
|
333
|
+
prepared: true,
|
|
334
|
+
});
|
|
335
|
+
expect(JSON.parse(mocks.sqs.mock.calls[0]![0].input.MessageBody).afterProfileId).toBe(
|
|
336
|
+
'0499'
|
|
337
|
+
);
|
|
338
|
+
expect(log).toHaveBeenCalledWith(
|
|
339
|
+
'Campaign email delivery failed; reservation retained'
|
|
340
|
+
);
|
|
341
|
+
} finally {
|
|
342
|
+
log.mockRestore();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
it('retries unclaimed preparation or database failures without advancing the cursor', async () => {
|
|
346
|
+
mocks.getCampaign.mockResolvedValue({
|
|
347
|
+
...campaign,
|
|
348
|
+
channels: ['email'],
|
|
349
|
+
email: { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' },
|
|
350
|
+
});
|
|
351
|
+
mocks.email.mockRejectedValueOnce(new Error('Preparation failed'));
|
|
352
|
+
await expect(processCampaignDispatchJob({ ...job, channel: 'email' })).rejects.toThrow(
|
|
353
|
+
'Preparation failed'
|
|
354
|
+
);
|
|
355
|
+
expect(mocks.sqs).not.toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it.each([
|
|
359
|
+
{ channels: ['notify'] },
|
|
360
|
+
{ channels: ['inApp', 'notify'] },
|
|
361
|
+
{ channels: ['notify', 'email'] },
|
|
362
|
+
{ channels: ['inApp', 'notify', 'email'] },
|
|
363
|
+
])(
|
|
364
|
+
'rejects opted-out notification tests before any channel sends ($channels)',
|
|
365
|
+
async ({ channels }) => {
|
|
366
|
+
mocks.preferences.mockResolvedValue({
|
|
367
|
+
announcementEmailOptOut: false,
|
|
368
|
+
announcementPushOptOut: true,
|
|
369
|
+
});
|
|
370
|
+
mocks.getCampaign.mockResolvedValue({ ...campaign, channels });
|
|
371
|
+
await expect(
|
|
372
|
+
sendCampaignTestToMe(campaign.id, recipient('admin'))
|
|
373
|
+
).rejects.toMatchObject({
|
|
374
|
+
code: 'PRECONDITION_FAILED',
|
|
375
|
+
message: 'Notification tests are disabled by your announcement preferences',
|
|
376
|
+
});
|
|
377
|
+
expect(mocks.email).not.toHaveBeenCalled();
|
|
378
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
379
|
+
expect(mocks.queueTest).not.toHaveBeenCalled();
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
it('uses an independent 500-recipient email wave and cursor', async () => {
|
|
383
|
+
mocks.getCampaign.mockResolvedValue({
|
|
384
|
+
...campaign,
|
|
385
|
+
channels: ['email'],
|
|
386
|
+
email: { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' },
|
|
387
|
+
});
|
|
388
|
+
mocks.pageAudience.mockResolvedValue({
|
|
389
|
+
records: Array.from({ length: 500 }, (_, i) => ({
|
|
390
|
+
profileId: String(i).padStart(4, '0'),
|
|
391
|
+
})),
|
|
392
|
+
});
|
|
393
|
+
await processCampaignDispatchJob({ ...job, channel: 'email' });
|
|
394
|
+
expect(mocks.pageAudience).toHaveBeenCalledWith(campaign.id, undefined, 500, 'email');
|
|
395
|
+
expect(mocks.email).toHaveBeenCalledTimes(500);
|
|
396
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
397
|
+
expect(JSON.parse(mocks.sqs.mock.calls[0]![0].input.MessageBody)).toMatchObject({
|
|
398
|
+
channel: 'email',
|
|
399
|
+
afterProfileId: '0499',
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
it('sends email self-tests only to the authenticated caller and fails for ineligible email', async () => {
|
|
403
|
+
mocks.getCampaign.mockResolvedValue({
|
|
404
|
+
...campaign,
|
|
405
|
+
status: 'draft',
|
|
406
|
+
channels: ['email'],
|
|
407
|
+
notification: undefined,
|
|
408
|
+
email: { subject: 'News', title: 'News', body: 'Programs', ctaLabel: 'View' },
|
|
409
|
+
});
|
|
410
|
+
mocks.email.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
411
|
+
await expect(sendCampaignTestToMe(campaign.id, recipient('admin'))).resolves.toBe(true);
|
|
412
|
+
expect(mocks.email).toHaveBeenCalledWith(
|
|
413
|
+
expect.objectContaining({ id: campaign.id }),
|
|
414
|
+
'admin',
|
|
415
|
+
true
|
|
416
|
+
);
|
|
417
|
+
await expect(sendCampaignTestToMe(campaign.id, recipient('admin'))).rejects.toThrow(
|
|
418
|
+
'verified primary email'
|
|
419
|
+
);
|
|
420
|
+
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('announcement message destinations', () => {
|
|
425
|
+
it.each([
|
|
426
|
+
{ channels: ['notify'], enabled: true, expected: false },
|
|
427
|
+
{ channels: ['inApp', 'notify'], enabled: false, expected: false },
|
|
428
|
+
{ channels: ['inApp', 'notify'], enabled: true, expected: true },
|
|
429
|
+
])('advertises only usable in-app content (%j)', async ({ channels, enabled, expected }) => {
|
|
430
|
+
const notification = await buildCampaignNotification(
|
|
431
|
+
{ ...campaign, channels, message: { enabled } } as Campaign,
|
|
432
|
+
recipient()
|
|
433
|
+
);
|
|
434
|
+
expect(notification.data?.metadata?.campaignHasInApp).toBe(expected);
|
|
435
|
+
const testNotification = await buildCampaignNotification(
|
|
436
|
+
{ ...campaign, channels, message: { enabled } } as Campaign,
|
|
437
|
+
recipient(),
|
|
438
|
+
'test-id'
|
|
439
|
+
);
|
|
440
|
+
expect(testNotification.data?.metadata?.campaignHasInApp).toBe(false);
|
|
441
|
+
});
|
|
442
|
+
});
|