@cosmicdrift/kumiko-bundled-features 0.1.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/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { buildDrizzleTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
defineFeature,
|
|
7
|
+
defineWriteHandler,
|
|
8
|
+
type NotifyFn,
|
|
9
|
+
qn,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import {
|
|
12
|
+
createTestUser,
|
|
13
|
+
pushTables,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
TestUsers,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { and, eq } from "drizzle-orm";
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { createChannelEmailFeature } from "../../channel-email/feature";
|
|
22
|
+
import { createInMemoryTransport, type EmailMessage } from "../../channel-email/types";
|
|
23
|
+
import { InAppHandlers, InAppQueries } from "../../channel-in-app/constants";
|
|
24
|
+
import { createChannelInAppFeature } from "../../channel-in-app/feature";
|
|
25
|
+
import { inAppMessagesTable } from "../../channel-in-app/tables";
|
|
26
|
+
import { createChannelPushFeature } from "../../channel-push/feature";
|
|
27
|
+
import { createInMemoryPushTransport } from "../../channel-push/types";
|
|
28
|
+
import { createConfigFeature } from "../../config/feature";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createRendererSimpleFeature } from "../../renderer-simple/feature";
|
|
31
|
+
import { simpleRenderer } from "../../renderer-simple/simple-renderer";
|
|
32
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
33
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
35
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
36
|
+
import { DeliveryHandlers, DeliveryQueries } from "../constants";
|
|
37
|
+
import { collectChannels, createDeliveryService } from "../delivery-service";
|
|
38
|
+
import { createDeliveryFeature } from "../feature";
|
|
39
|
+
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
40
|
+
import { createDeliveryTestContext } from "../testing";
|
|
41
|
+
import type { DeliveryService } from "../types";
|
|
42
|
+
import { createUnsubscribeRoute, signUnsubscribeToken } from "../unsubscribe";
|
|
43
|
+
|
|
44
|
+
// --- Setup ---
|
|
45
|
+
|
|
46
|
+
let stack: TestStack;
|
|
47
|
+
let db: DbConnection;
|
|
48
|
+
let deliveryService: DeliveryService;
|
|
49
|
+
const JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|
|
50
|
+
|
|
51
|
+
// Email test infrastructure
|
|
52
|
+
const emailTransport = createInMemoryTransport();
|
|
53
|
+
const testEmail = (userId: string | number) => `user-${userId}@test.com`;
|
|
54
|
+
|
|
55
|
+
// Push test infrastructure
|
|
56
|
+
const pushTransport = createInMemoryPushTransport();
|
|
57
|
+
const testPushToken = (userId: string | number) => `push-token-${userId}`;
|
|
58
|
+
const resolveTestEmail = async (userId: string) => testEmail(userId);
|
|
59
|
+
|
|
60
|
+
const admin = TestUsers.admin;
|
|
61
|
+
const user1 = createTestUser({ id: 2, roles: ["User"] });
|
|
62
|
+
const user2 = createTestUser({ id: 3, roles: ["User"] });
|
|
63
|
+
|
|
64
|
+
// Delivery service builds the rate-limit key as `{prefix}:{tenantId}:{channel}`
|
|
65
|
+
// — tests set / clear the key directly, so they need the real UUID value.
|
|
66
|
+
const RATE_KEY_EMAIL = `test:delivery:rate:${admin.tenantId}:email`;
|
|
67
|
+
|
|
68
|
+
// App feature that uses ctx.notify() in a handler
|
|
69
|
+
const appFeature = defineFeature("app", (r) => {
|
|
70
|
+
r.requires("delivery");
|
|
71
|
+
|
|
72
|
+
r.writeHandler(
|
|
73
|
+
defineWriteHandler({
|
|
74
|
+
name: "assignOrder",
|
|
75
|
+
schema: z.object({
|
|
76
|
+
orderId: z.number(),
|
|
77
|
+
driverId: z.string(),
|
|
78
|
+
}),
|
|
79
|
+
handler: async (event, ctx) => {
|
|
80
|
+
const notify = ctx.notify as NotifyFn;
|
|
81
|
+
await notify(qn("app", "notify", "order-assigned"), {
|
|
82
|
+
to: event.payload.driverId,
|
|
83
|
+
data: {
|
|
84
|
+
title: "Neuer Auftrag",
|
|
85
|
+
body: `Auftrag #${event.payload.orderId} wurde dir zugewiesen`,
|
|
86
|
+
orderId: event.payload.orderId,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return { isSuccess: true, data: { assigned: true } };
|
|
91
|
+
},
|
|
92
|
+
access: { openToAll: true },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
r.writeHandler(
|
|
97
|
+
defineWriteHandler({
|
|
98
|
+
name: "broadcast",
|
|
99
|
+
schema: z.object({
|
|
100
|
+
message: z.string(),
|
|
101
|
+
userIds: z.array(z.string()),
|
|
102
|
+
}),
|
|
103
|
+
handler: async (event, ctx) => {
|
|
104
|
+
const notify = ctx.notify as NotifyFn;
|
|
105
|
+
await notify(qn("app", "notify", "announcement"), {
|
|
106
|
+
to: event.payload.userIds,
|
|
107
|
+
data: {
|
|
108
|
+
title: "Ankuendigung",
|
|
109
|
+
body: event.payload.message,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return { isSuccess: true, data: { sent: true } };
|
|
114
|
+
},
|
|
115
|
+
access: { openToAll: true },
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Generic notify handler that lets tests pick any notificationType from
|
|
120
|
+
// the request payload. Used by the wildcard-conflict test below so it can
|
|
121
|
+
// exercise the full HTTP→dispatcher→notify path instead of poking the
|
|
122
|
+
// deliveryService directly.
|
|
123
|
+
r.writeHandler(
|
|
124
|
+
defineWriteHandler({
|
|
125
|
+
name: "sendNotification",
|
|
126
|
+
schema: z.object({
|
|
127
|
+
notificationType: z.string(),
|
|
128
|
+
toUserId: z.string(),
|
|
129
|
+
title: z.string(),
|
|
130
|
+
body: z.string(),
|
|
131
|
+
}),
|
|
132
|
+
access: { openToAll: true },
|
|
133
|
+
handler: async (event, ctx) => {
|
|
134
|
+
const notify = ctx.notify as NotifyFn;
|
|
135
|
+
await notify(event.payload.notificationType, {
|
|
136
|
+
to: event.payload.toUserId,
|
|
137
|
+
data: { title: event.payload.title, body: event.payload.body },
|
|
138
|
+
});
|
|
139
|
+
return { isSuccess: true, data: { sent: true } };
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Tenant broadcast handler
|
|
145
|
+
r.writeHandler(
|
|
146
|
+
defineWriteHandler({
|
|
147
|
+
name: "tenantAlert",
|
|
148
|
+
schema: z.object({
|
|
149
|
+
message: z.string(),
|
|
150
|
+
tenantId: z.string(),
|
|
151
|
+
}),
|
|
152
|
+
handler: async (event, ctx) => {
|
|
153
|
+
const notify = ctx.notify as NotifyFn;
|
|
154
|
+
await notify(qn("app", "notify", "tenant-alert"), {
|
|
155
|
+
to: { tenant: event.payload.tenantId },
|
|
156
|
+
data: {
|
|
157
|
+
title: "Tenant-Warnung",
|
|
158
|
+
body: event.payload.message,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return { isSuccess: true, data: { sent: true } };
|
|
163
|
+
},
|
|
164
|
+
access: { openToAll: true },
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Feature with CRUD entity + declarative r.notification()
|
|
170
|
+
const ticketEntity = createEntity({
|
|
171
|
+
fields: {
|
|
172
|
+
title: createTextField({ required: true }),
|
|
173
|
+
assigneeId: createTextField(),
|
|
174
|
+
status: createTextField({ required: true }),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const ticketTable = buildDrizzleTable("ticket", ticketEntity);
|
|
178
|
+
|
|
179
|
+
function ticketExecutor() {
|
|
180
|
+
return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const ticketFeature = defineFeature("tickets", (r) => {
|
|
184
|
+
r.requires("delivery");
|
|
185
|
+
|
|
186
|
+
r.entity("ticket", ticketEntity);
|
|
187
|
+
|
|
188
|
+
// Real CRUD handler with CrudExecutor (not stub)
|
|
189
|
+
const createHandler = r.writeHandler(
|
|
190
|
+
"ticket:create",
|
|
191
|
+
z.object({
|
|
192
|
+
title: z.string(),
|
|
193
|
+
assigneeId: z.uuid().optional(),
|
|
194
|
+
status: z.string(),
|
|
195
|
+
}),
|
|
196
|
+
async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db),
|
|
197
|
+
{ access: { openToAll: true } },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Declarative: notify assignee when ticket is created with assigneeId
|
|
201
|
+
// Uses handler ref + per-channel templates
|
|
202
|
+
r.notification("ticketAssigned", {
|
|
203
|
+
trigger: { on: createHandler },
|
|
204
|
+
recipient: (result) => {
|
|
205
|
+
const assigneeId = result.data["assigneeId"] as string | undefined;
|
|
206
|
+
return assigneeId ?? null;
|
|
207
|
+
},
|
|
208
|
+
data: (result) => ({
|
|
209
|
+
title: "Neues Ticket",
|
|
210
|
+
body: `Ticket "${result.data["title"]}" wurde dir zugewiesen`,
|
|
211
|
+
ticketId: result.id,
|
|
212
|
+
}),
|
|
213
|
+
templates: {
|
|
214
|
+
inApp: (data) => ({
|
|
215
|
+
title: data["title"],
|
|
216
|
+
body: data["body"],
|
|
217
|
+
}),
|
|
218
|
+
email: (data) => ({
|
|
219
|
+
subject: `Ticket #${data["ticketId"]} zugewiesen`,
|
|
220
|
+
header: data["title"] as string,
|
|
221
|
+
sections: [
|
|
222
|
+
{ text: data["body"] as string },
|
|
223
|
+
{ button: { label: "Ticket oeffnen", url: `/tickets/${data["ticketId"]}` } },
|
|
224
|
+
],
|
|
225
|
+
footer: "Kumiko Notifications",
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Second notification on same trigger — tests multiple notifications per handler
|
|
231
|
+
r.notification("ticketCreatedAdmin", {
|
|
232
|
+
trigger: { on: createHandler },
|
|
233
|
+
recipient: () => admin.id, // always notify admin
|
|
234
|
+
data: (result) => ({
|
|
235
|
+
title: "Ticket erstellt",
|
|
236
|
+
body: `Neues Ticket: ${result.data["title"]}`,
|
|
237
|
+
ticketId: result.id,
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const configFeature = createConfigFeature();
|
|
243
|
+
const tenantFeature = createTenantFeature();
|
|
244
|
+
const deliveryFeature = createDeliveryFeature();
|
|
245
|
+
const channelInAppFeature = createChannelInAppFeature();
|
|
246
|
+
const rendererSimpleFeature = createRendererSimpleFeature();
|
|
247
|
+
const channelEmailFeature = createChannelEmailFeature({
|
|
248
|
+
transport: emailTransport,
|
|
249
|
+
renderer: simpleRenderer,
|
|
250
|
+
resolveEmail: resolveTestEmail,
|
|
251
|
+
});
|
|
252
|
+
const channelPushFeature = createChannelPushFeature({
|
|
253
|
+
transport: pushTransport,
|
|
254
|
+
resolveToken: async (userId) => testPushToken(userId),
|
|
255
|
+
});
|
|
256
|
+
const features = [
|
|
257
|
+
configFeature,
|
|
258
|
+
tenantFeature,
|
|
259
|
+
deliveryFeature,
|
|
260
|
+
channelInAppFeature,
|
|
261
|
+
rendererSimpleFeature,
|
|
262
|
+
channelEmailFeature,
|
|
263
|
+
channelPushFeature,
|
|
264
|
+
appFeature,
|
|
265
|
+
ticketFeature,
|
|
266
|
+
] as const;
|
|
267
|
+
|
|
268
|
+
beforeAll(async () => {
|
|
269
|
+
stack = await setupTestStack({
|
|
270
|
+
features,
|
|
271
|
+
extraContext: (deps) => {
|
|
272
|
+
const ctx = createDeliveryTestContext(deps, {
|
|
273
|
+
tenantUserIdsQuery: TenantQueries.resolveUserIds,
|
|
274
|
+
rateLimit: { redis: deps.redis, maxPerHour: 100, keyPrefix: "test:delivery:rate" },
|
|
275
|
+
isChannelKilled: async (tenantId, channelName) => {
|
|
276
|
+
const key = `test:delivery:kill:${tenantId}:${channelName}`;
|
|
277
|
+
return (await deps.redis.get(key)) === "1";
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
deliveryService = ctx.deliveryService;
|
|
281
|
+
return ctx;
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
db = stack.db;
|
|
285
|
+
|
|
286
|
+
// Mount unsubscribe route BEFORE any requests (Hono router locks after first match)
|
|
287
|
+
stack.app.route("/delivery", createUnsubscribeRoute({ db, jwtSecret: JWT_SECRET }));
|
|
288
|
+
|
|
289
|
+
// deliveryAttemptsTable is auto-pushed by setupTestStack as MSP-projection-table;
|
|
290
|
+
// notificationPreferencesTable is an ES-entity, so it still needs explicit
|
|
291
|
+
// push here (entity-tables are not auto-provisioned — only projection ones).
|
|
292
|
+
await pushTables(db, {
|
|
293
|
+
configValuesTable,
|
|
294
|
+
tenantMembershipsTable,
|
|
295
|
+
notificationPreferencesTable,
|
|
296
|
+
inAppMessagesTable,
|
|
297
|
+
ticketTable,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Create tenant entity table + seed memberships for tenant broadcast tests
|
|
301
|
+
const { createEntityTable } = await import("@cosmicdrift/kumiko-framework/stack");
|
|
302
|
+
await createEntityTable(db, tenantEntity, "tenant");
|
|
303
|
+
|
|
304
|
+
// Create tenant + members via real API
|
|
305
|
+
await stack.http.writeOk(
|
|
306
|
+
"tenant:write:create",
|
|
307
|
+
{ key: "test", name: "Test Tenant" },
|
|
308
|
+
TestUsers.systemAdmin,
|
|
309
|
+
);
|
|
310
|
+
for (const user of [admin, user1, user2]) {
|
|
311
|
+
await stack.http.writeOk(
|
|
312
|
+
"tenant:write:add-member",
|
|
313
|
+
{ userId: user.id, tenantId: "00000000-0000-4000-8000-000000000001", roles: ["User"] },
|
|
314
|
+
TestUsers.systemAdmin,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
afterAll(async () => {
|
|
320
|
+
await stack.cleanup();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Reset transient state between tests (DB state persists intentionally —
|
|
324
|
+
// tests filter explicitly. Transports + SSE events get cleared.)
|
|
325
|
+
beforeEach(() => {
|
|
326
|
+
stack.events.reset();
|
|
327
|
+
emailTransport.sent.length = 0;
|
|
328
|
+
pushTransport.sent.length = 0;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// --- Flow 1: Handler → ctx.notify() → InApp in DB + SSE + DeliveryLog ---
|
|
332
|
+
|
|
333
|
+
describe("flow 1: handler sends notification via ctx.notify()", () => {
|
|
334
|
+
test("notification creates InApp message + SSE event + DeliveryLog entries", async () => {
|
|
335
|
+
const result = await stack.http.writeOk(
|
|
336
|
+
"app:write:assign-order",
|
|
337
|
+
{ orderId: 42, driverId: user1.id },
|
|
338
|
+
admin,
|
|
339
|
+
);
|
|
340
|
+
expect(result).toEqual({ assigned: true });
|
|
341
|
+
|
|
342
|
+
// InApp message in DB
|
|
343
|
+
const messages = await db
|
|
344
|
+
.select()
|
|
345
|
+
.from(inAppMessagesTable)
|
|
346
|
+
.where(eq(inAppMessagesTable.userId, user1.id));
|
|
347
|
+
expect(messages).toHaveLength(1);
|
|
348
|
+
expect(messages[0]?.["title"]).toBe("Neuer Auftrag");
|
|
349
|
+
expect(messages[0]?.["body"]).toBe("Auftrag #42 wurde dir zugewiesen");
|
|
350
|
+
expect(messages[0]?.["isRead"]).toBe(false);
|
|
351
|
+
|
|
352
|
+
// SSE event fired
|
|
353
|
+
const sseEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
354
|
+
expect(sseEvents).toHaveLength(1);
|
|
355
|
+
expect(sseEvents[0]?.data["userId"]).toBe(user1.id);
|
|
356
|
+
|
|
357
|
+
// DeliveryLog entries for all 3 channels
|
|
358
|
+
const logs = await db
|
|
359
|
+
.select()
|
|
360
|
+
.from(deliveryAttemptsTable)
|
|
361
|
+
.where(eq(deliveryAttemptsTable.notificationType, "app:notify:order-assigned"));
|
|
362
|
+
expect(logs).toHaveLength(3);
|
|
363
|
+
const channels = logs.map((l) => l["channel"]);
|
|
364
|
+
expect(channels).toContain("inApp");
|
|
365
|
+
expect(channels).toContain("email");
|
|
366
|
+
expect(channels).toContain("push");
|
|
367
|
+
expect(logs.every((l) => l["status"] === "sent")).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// --- Flow 2: Inbox lifecycle — query, markRead, unreadCount ---
|
|
372
|
+
|
|
373
|
+
describe("flow 2: inbox lifecycle", () => {
|
|
374
|
+
test("inbox returns user's messages", async () => {
|
|
375
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
376
|
+
InAppQueries.inbox,
|
|
377
|
+
{},
|
|
378
|
+
user1,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(result.rows).toHaveLength(1);
|
|
382
|
+
expect(result.rows[0]?.["title"]).toBe("Neuer Auftrag");
|
|
383
|
+
expect(result.rows[0]?.["isRead"]).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("unreadCount reflects unread messages", async () => {
|
|
387
|
+
const result = await stack.http.queryOk<{ count: number }>(InAppQueries.unreadCount, {}, user1);
|
|
388
|
+
expect(result.count).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("markRead marks single message as read", async () => {
|
|
392
|
+
const inbox = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
393
|
+
InAppQueries.inbox,
|
|
394
|
+
{},
|
|
395
|
+
user1,
|
|
396
|
+
);
|
|
397
|
+
const messageId = inbox.rows[0]?.["id"] as number;
|
|
398
|
+
|
|
399
|
+
await stack.http.writeOk(InAppHandlers.markRead, { id: messageId }, user1);
|
|
400
|
+
|
|
401
|
+
const updated = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
402
|
+
InAppQueries.inbox,
|
|
403
|
+
{},
|
|
404
|
+
user1,
|
|
405
|
+
);
|
|
406
|
+
expect(updated.rows[0]?.["isRead"]).toBe(true);
|
|
407
|
+
expect(updated.rows[0]?.["readAt"]).toBeDefined();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("unreadCount is 0 after marking read", async () => {
|
|
411
|
+
const result = await stack.http.queryOk<{ count: number }>(InAppQueries.unreadCount, {}, user1);
|
|
412
|
+
expect(result.count).toBe(0);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("other user sees empty inbox", async () => {
|
|
416
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
417
|
+
InAppQueries.inbox,
|
|
418
|
+
{},
|
|
419
|
+
user2,
|
|
420
|
+
);
|
|
421
|
+
expect(result.rows).toHaveLength(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("markRead on other user's message returns not_found", async () => {
|
|
425
|
+
const inbox = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
426
|
+
InAppQueries.inbox,
|
|
427
|
+
{},
|
|
428
|
+
user1,
|
|
429
|
+
);
|
|
430
|
+
const messageId = inbox.rows[0]?.["id"] as number;
|
|
431
|
+
|
|
432
|
+
const error = await stack.http.writeErr(InAppHandlers.markRead, { id: messageId }, user2);
|
|
433
|
+
expect(error.code).toBe("not_found");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// --- Flow 3: Broadcast + markAllRead ---
|
|
438
|
+
|
|
439
|
+
describe("flow 3: broadcast to multiple users + markAllRead", () => {
|
|
440
|
+
test("broadcast creates messages and fires SSE events for all recipients", async () => {
|
|
441
|
+
await stack.http.writeOk(
|
|
442
|
+
"app:write:broadcast",
|
|
443
|
+
{ message: "Wartung heute Nacht", userIds: [user1.id, user2.id] },
|
|
444
|
+
admin,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Both users have messages in DB
|
|
448
|
+
for (const user of [user1, user2]) {
|
|
449
|
+
const messages = await db
|
|
450
|
+
.select()
|
|
451
|
+
.from(inAppMessagesTable)
|
|
452
|
+
.where(
|
|
453
|
+
and(
|
|
454
|
+
eq(inAppMessagesTable.userId, user.id),
|
|
455
|
+
eq(inAppMessagesTable.notificationType, "app:notify:announcement"),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
expect(messages).toHaveLength(1);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// SSE events fired for both users
|
|
462
|
+
const sseEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
463
|
+
expect(sseEvents).toHaveLength(2);
|
|
464
|
+
const userIds = sseEvents.map((e) => e.data["userId"]);
|
|
465
|
+
expect(userIds).toContain(user1.id);
|
|
466
|
+
expect(userIds).toContain(user2.id);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("delivery log has entries for all recipients and channels", async () => {
|
|
470
|
+
const logs = await db
|
|
471
|
+
.select()
|
|
472
|
+
.from(deliveryAttemptsTable)
|
|
473
|
+
.where(eq(deliveryAttemptsTable.notificationType, "app:notify:announcement"));
|
|
474
|
+
|
|
475
|
+
// 2 users × 3 channels (inApp + email + push) = 6 entries
|
|
476
|
+
expect(logs).toHaveLength(6);
|
|
477
|
+
expect(logs.every((l) => l["status"] === "sent")).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("markAllRead marks all unread messages", async () => {
|
|
481
|
+
const beforeCount = await stack.http.queryOk<{ count: number }>(
|
|
482
|
+
InAppQueries.unreadCount,
|
|
483
|
+
{},
|
|
484
|
+
user1,
|
|
485
|
+
);
|
|
486
|
+
expect(beforeCount.count).toBe(1); // only announcement is unread
|
|
487
|
+
|
|
488
|
+
const result = await stack.http.writeOk<{ marked: number }>(
|
|
489
|
+
InAppHandlers.markAllRead,
|
|
490
|
+
{},
|
|
491
|
+
user1,
|
|
492
|
+
);
|
|
493
|
+
expect(result.marked).toBe(1);
|
|
494
|
+
|
|
495
|
+
const afterCount = await stack.http.queryOk<{ count: number }>(
|
|
496
|
+
InAppQueries.unreadCount,
|
|
497
|
+
{},
|
|
498
|
+
user1,
|
|
499
|
+
);
|
|
500
|
+
expect(afterCount.count).toBe(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("delivery log query returns all entries (admin only)", async () => {
|
|
504
|
+
const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
505
|
+
"delivery:query:log",
|
|
506
|
+
{ limit: 100 },
|
|
507
|
+
admin,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// 1 orderAssigned × 3 channels + 2 announcement × 3 channels = 9 total
|
|
511
|
+
expect(result.rows.length).toBe(9);
|
|
512
|
+
expect(result.rows[0]?.["notificationType"]).toBe("app:notify:announcement");
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// --- Flow 4: Declarative r.notification() — auto fires on CRUD handler ---
|
|
517
|
+
|
|
518
|
+
describe("flow 4: declarative notification via r.notification()", () => {
|
|
519
|
+
test("CRUD create triggers both notifications with SSE events", async () => {
|
|
520
|
+
await stack.http.writeOk(
|
|
521
|
+
"tickets:write:ticket:create",
|
|
522
|
+
{ title: "Server down", assigneeId: user1.id, status: "open" },
|
|
523
|
+
admin,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// user1 gets ticketAssigned, admin gets ticketCreatedAdmin
|
|
527
|
+
const user1Messages = await db
|
|
528
|
+
.select()
|
|
529
|
+
.from(inAppMessagesTable)
|
|
530
|
+
.where(
|
|
531
|
+
and(
|
|
532
|
+
eq(inAppMessagesTable.userId, user1.id),
|
|
533
|
+
eq(inAppMessagesTable.notificationType, "tickets:notify:ticket-assigned"),
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
expect(user1Messages).toHaveLength(1);
|
|
537
|
+
expect(user1Messages[0]?.["title"]).toBe("Neues Ticket");
|
|
538
|
+
expect(user1Messages[0]?.["body"]).toContain("Server down");
|
|
539
|
+
|
|
540
|
+
const adminMessages = await db
|
|
541
|
+
.select()
|
|
542
|
+
.from(inAppMessagesTable)
|
|
543
|
+
.where(
|
|
544
|
+
and(
|
|
545
|
+
eq(inAppMessagesTable.userId, admin.id),
|
|
546
|
+
eq(inAppMessagesTable.notificationType, "tickets:notify:ticket-created-admin"),
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
expect(adminMessages).toHaveLength(1);
|
|
550
|
+
expect(adminMessages[0]?.["title"]).toBe("Ticket erstellt");
|
|
551
|
+
|
|
552
|
+
// SSE events for both recipients
|
|
553
|
+
const sseEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
554
|
+
expect(sseEvents).toHaveLength(2);
|
|
555
|
+
const userIds = sseEvents.map((e) => e.data["userId"]);
|
|
556
|
+
expect(userIds).toContain(user1.id);
|
|
557
|
+
expect(userIds).toContain(admin.id);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("delivery log entries for both notifications", async () => {
|
|
561
|
+
const logs = await db
|
|
562
|
+
.select()
|
|
563
|
+
.from(deliveryAttemptsTable)
|
|
564
|
+
.where(
|
|
565
|
+
and(
|
|
566
|
+
eq(deliveryAttemptsTable.channel, "inApp"),
|
|
567
|
+
eq(deliveryAttemptsTable.recipientId, user1.id),
|
|
568
|
+
eq(deliveryAttemptsTable.notificationType, "tickets:notify:ticket-assigned"),
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
expect(logs).toHaveLength(1);
|
|
572
|
+
expect(logs[0]?.["status"]).toBe("sent");
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// --- Flow 5: recipient returns null → notification skipped ---
|
|
577
|
+
|
|
578
|
+
describe("flow 5: notification skipped when recipient is null", () => {
|
|
579
|
+
test("ticket without assigneeId skips ticketAssigned but still sends ticketCreatedAdmin", async () => {
|
|
580
|
+
stack.events.reset();
|
|
581
|
+
|
|
582
|
+
await stack.http.writeOk(
|
|
583
|
+
"tickets:write:ticket:create",
|
|
584
|
+
{ title: "Docs update", status: "open" },
|
|
585
|
+
admin,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// No ticketAssigned notification (no assignee)
|
|
589
|
+
const assigneeNotifs = await db
|
|
590
|
+
.select()
|
|
591
|
+
.from(inAppMessagesTable)
|
|
592
|
+
.where(eq(inAppMessagesTable.notificationType, "tickets:notify:ticket-assigned"));
|
|
593
|
+
// Only the one from flow 4 should exist
|
|
594
|
+
expect(assigneeNotifs).toHaveLength(1);
|
|
595
|
+
|
|
596
|
+
// But admin still gets ticketCreatedAdmin
|
|
597
|
+
const adminMessages = await db
|
|
598
|
+
.select()
|
|
599
|
+
.from(inAppMessagesTable)
|
|
600
|
+
.where(eq(inAppMessagesTable.notificationType, "tickets:notify:ticket-created-admin"));
|
|
601
|
+
expect(adminMessages).toHaveLength(2); // flow 4 + flow 5
|
|
602
|
+
|
|
603
|
+
// SSE: only 1 event (admin only, no assignee)
|
|
604
|
+
const notifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
605
|
+
expect(notifs).toHaveLength(1);
|
|
606
|
+
expect(notifs[0]?.data["userId"]).toBe(admin.id);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// --- Flow 6: User Preferences — disable channel, notification skipped ---
|
|
611
|
+
|
|
612
|
+
describe("flow 6: user preferences", () => {
|
|
613
|
+
test("setPreference disables inApp for a notification type", async () => {
|
|
614
|
+
await stack.http.writeOk(
|
|
615
|
+
DeliveryHandlers.setPreference,
|
|
616
|
+
{ notificationType: "app:notify:order-assigned", channel: "inApp", enabled: false },
|
|
617
|
+
user1,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Verify preference is stored
|
|
621
|
+
const prefs = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
622
|
+
DeliveryQueries.preferences,
|
|
623
|
+
{},
|
|
624
|
+
user1,
|
|
625
|
+
);
|
|
626
|
+
expect(prefs.rows).toHaveLength(1);
|
|
627
|
+
expect(prefs.rows[0]?.["notificationType"]).toBe("app:notify:order-assigned");
|
|
628
|
+
expect(prefs.rows[0]?.["channel"]).toBe("inApp");
|
|
629
|
+
expect(prefs.rows[0]?.["enabled"]).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("notification is skipped when channel is disabled by preference", async () => {
|
|
633
|
+
stack.events.reset();
|
|
634
|
+
|
|
635
|
+
// Count messages before
|
|
636
|
+
const before = await db
|
|
637
|
+
.select()
|
|
638
|
+
.from(inAppMessagesTable)
|
|
639
|
+
.where(eq(inAppMessagesTable.userId, user1.id));
|
|
640
|
+
const beforeCount = before.length;
|
|
641
|
+
|
|
642
|
+
// Send notification to user1 who has disabled inApp for orderAssigned
|
|
643
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 99, driverId: user1.id }, admin);
|
|
644
|
+
|
|
645
|
+
// No new InApp message for user1
|
|
646
|
+
const after = await db
|
|
647
|
+
.select()
|
|
648
|
+
.from(inAppMessagesTable)
|
|
649
|
+
.where(eq(inAppMessagesTable.userId, user1.id));
|
|
650
|
+
expect(after.length).toBe(beforeCount);
|
|
651
|
+
|
|
652
|
+
// No SSE event
|
|
653
|
+
const notifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
654
|
+
expect(notifs).toHaveLength(0);
|
|
655
|
+
|
|
656
|
+
// DeliveryLog shows skipped with preference_disabled
|
|
657
|
+
const logs = await db
|
|
658
|
+
.select()
|
|
659
|
+
.from(deliveryAttemptsTable)
|
|
660
|
+
.where(
|
|
661
|
+
and(
|
|
662
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:order-assigned"),
|
|
663
|
+
eq(deliveryAttemptsTable.recipientId, user1.id),
|
|
664
|
+
eq(deliveryAttemptsTable.status, "skipped"),
|
|
665
|
+
eq(deliveryAttemptsTable.error, "preference_disabled"),
|
|
666
|
+
),
|
|
667
|
+
);
|
|
668
|
+
expect(logs.length).toBeGreaterThanOrEqual(1);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("critical priority ignores preferences", async () => {
|
|
672
|
+
stack.events.reset();
|
|
673
|
+
|
|
674
|
+
// user1 still has inApp disabled for orderAssigned
|
|
675
|
+
// But a critical notification should go through
|
|
676
|
+
await deliveryService.notify(
|
|
677
|
+
"app:notify:order-assigned",
|
|
678
|
+
{
|
|
679
|
+
to: user1.id,
|
|
680
|
+
data: { title: "CRITICAL: Order storniert", body: "Sofort reagieren" },
|
|
681
|
+
priority: "critical",
|
|
682
|
+
},
|
|
683
|
+
admin,
|
|
684
|
+
admin.tenantId,
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Should have created a message despite disabled preference
|
|
688
|
+
const notifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
689
|
+
expect(notifs).toHaveLength(1);
|
|
690
|
+
expect(notifs[0]?.data["title"]).toBe("CRITICAL: Order storniert");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("re-enable preference restores delivery", async () => {
|
|
694
|
+
stack.events.reset();
|
|
695
|
+
|
|
696
|
+
// Re-enable
|
|
697
|
+
await stack.http.writeOk(
|
|
698
|
+
DeliveryHandlers.setPreference,
|
|
699
|
+
{ notificationType: "app:notify:order-assigned", channel: "inApp", enabled: true },
|
|
700
|
+
user1,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// Send notification again
|
|
704
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 100, driverId: user1.id }, admin);
|
|
705
|
+
|
|
706
|
+
// Should work again
|
|
707
|
+
const notifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
708
|
+
expect(notifs).toHaveLength(1);
|
|
709
|
+
expect(notifs[0]?.data["userId"]).toBe(user1.id);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("exact preference overrides wildcard", async () => {
|
|
713
|
+
stack.events.reset();
|
|
714
|
+
|
|
715
|
+
// Disable ALL inApp notifications via wildcard
|
|
716
|
+
await stack.http.writeOk(
|
|
717
|
+
DeliveryHandlers.setPreference,
|
|
718
|
+
{ notificationType: "*", channel: "inApp", enabled: false },
|
|
719
|
+
user1,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// user1 still has { orderAssigned, inApp, enabled: true } from re-enable test
|
|
723
|
+
// Send notification — exact match (enabled: true) should win over wildcard (enabled: false)
|
|
724
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 200, driverId: user1.id }, admin);
|
|
725
|
+
|
|
726
|
+
const notifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
727
|
+
expect(notifs).toHaveLength(1);
|
|
728
|
+
expect(notifs[0]?.data["userId"]).toBe(user1.id);
|
|
729
|
+
|
|
730
|
+
// Clean up wildcard preference
|
|
731
|
+
await stack.http.writeOk(
|
|
732
|
+
DeliveryHandlers.setPreference,
|
|
733
|
+
{ notificationType: "*", channel: "inApp", enabled: true },
|
|
734
|
+
user1,
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// --- Flow 7: Unsubscribe endpoint ---
|
|
740
|
+
|
|
741
|
+
describe("flow 7: unsubscribe endpoint", () => {
|
|
742
|
+
test("signed unsubscribe token disables preference", async () => {
|
|
743
|
+
const token = await signUnsubscribeToken(
|
|
744
|
+
{
|
|
745
|
+
userId: user2.id,
|
|
746
|
+
tenantId: user2.tenantId,
|
|
747
|
+
notificationType: "app:notify:announcement",
|
|
748
|
+
channel: "inApp",
|
|
749
|
+
},
|
|
750
|
+
JWT_SECRET,
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const res = await stack.app.request(`/delivery/unsubscribe?token=${token}`);
|
|
754
|
+
expect(res.status).toBe(200);
|
|
755
|
+
const text = await res.text();
|
|
756
|
+
expect(text).toContain("unsubscribed");
|
|
757
|
+
|
|
758
|
+
// Verify preference was created
|
|
759
|
+
const prefs = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
|
|
760
|
+
DeliveryQueries.preferences,
|
|
761
|
+
{},
|
|
762
|
+
user2,
|
|
763
|
+
);
|
|
764
|
+
const pref = prefs.rows.find(
|
|
765
|
+
(r) => r["notificationType"] === "app:notify:announcement" && r["channel"] === "inApp",
|
|
766
|
+
);
|
|
767
|
+
expect(pref).toBeDefined();
|
|
768
|
+
expect(pref?.["enabled"]).toBe(false);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("invalid token returns 400", async () => {
|
|
772
|
+
const res = await stack.app.request("/delivery/unsubscribe?token=invalid-jwt-token");
|
|
773
|
+
expect(res.status).toBe(400);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("missing token returns 400", async () => {
|
|
777
|
+
const res = await stack.app.request("/delivery/unsubscribe");
|
|
778
|
+
expect(res.status).toBe(400);
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// --- Flow 9: Email Channel + Renderer end-to-end ---
|
|
783
|
+
|
|
784
|
+
describe("flow 9: email channel with renderer", () => {
|
|
785
|
+
test("declarative notification fires on all channels with rendered email", async () => {
|
|
786
|
+
// Create ticket with assignee — triggers ticketAssigned notification
|
|
787
|
+
await stack.http.writeOk(
|
|
788
|
+
"tickets:write:ticket:create",
|
|
789
|
+
{ title: "Login kaputt", assigneeId: user1.id, status: "open" },
|
|
790
|
+
admin,
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
// Email sent via transport with rendered HTML
|
|
794
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user1.id));
|
|
795
|
+
expect(emails).toHaveLength(1);
|
|
796
|
+
const email = emails[0] as EmailMessage;
|
|
797
|
+
expect(email.subject).toMatch(/Ticket #[0-9a-f-]+ zugewiesen/);
|
|
798
|
+
expect(email.html).toContain("Neues Ticket");
|
|
799
|
+
expect(email.html).toContain("Login kaputt");
|
|
800
|
+
expect(email.html).toContain("Ticket oeffnen");
|
|
801
|
+
expect(email.html).toContain("/tickets/");
|
|
802
|
+
expect(email.html).toContain("<!DOCTYPE html>");
|
|
803
|
+
expect(email.html).toContain("</html>");
|
|
804
|
+
|
|
805
|
+
// InApp also fired — ticketAssigned for user1 + ticketCreatedAdmin for admin
|
|
806
|
+
const sseEvents = stack.events.sse.filter(
|
|
807
|
+
(e) => e.type === "channel-in-app:event:delivered" && e.data["title"] === "Neues Ticket",
|
|
808
|
+
);
|
|
809
|
+
expect(sseEvents.length).toBeGreaterThanOrEqual(1);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("delivery log has entries for both channels", async () => {
|
|
813
|
+
const logs = await db
|
|
814
|
+
.select()
|
|
815
|
+
.from(deliveryAttemptsTable)
|
|
816
|
+
.where(eq(deliveryAttemptsTable.notificationType, "tickets:notify:ticket-assigned"));
|
|
817
|
+
|
|
818
|
+
const channels = logs.map((l) => l["channel"]);
|
|
819
|
+
expect(channels).toContain("inApp");
|
|
820
|
+
expect(channels).toContain("email");
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("notification without email template skips email rendering", async () => {
|
|
824
|
+
emailTransport.sent.length = 0;
|
|
825
|
+
|
|
826
|
+
// The manual app.assignOrder handler has no email template
|
|
827
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 300, driverId: user1.id }, admin);
|
|
828
|
+
|
|
829
|
+
// Email still sent (fallback to plain text) since email channel resolves the user
|
|
830
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user1.id));
|
|
831
|
+
expect(emails).toHaveLength(1);
|
|
832
|
+
// But it uses the simple fallback (title as h1)
|
|
833
|
+
// Renderer always runs — falls back to title/body as header + section
|
|
834
|
+
expect(emails[0]?.html).toContain("<!DOCTYPE html>");
|
|
835
|
+
expect(emails[0]?.html).toContain("<h1"); // title as header
|
|
836
|
+
expect(emails[0]?.html).toContain("Neuer Auftrag");
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// --- Flow 10: Complete end-to-end path ---
|
|
841
|
+
// CRUD → postSave → r.notification() with templates → both channels → DB + SSE + Email + Log
|
|
842
|
+
|
|
843
|
+
describe("flow 10: complete end-to-end", () => {
|
|
844
|
+
test("single CRUD operation triggers InApp + Email with per-channel templates", async () => {
|
|
845
|
+
stack.events.reset();
|
|
846
|
+
emailTransport.sent.length = 0;
|
|
847
|
+
|
|
848
|
+
// Create ticket with assignee — fires ticketAssigned notification
|
|
849
|
+
// which has both inApp and email templates defined
|
|
850
|
+
await stack.http.writeOk(
|
|
851
|
+
"tickets:write:ticket:create",
|
|
852
|
+
{ title: "Datenbank Backup fehlgeschlagen", assigneeId: user2.id, status: "critical" },
|
|
853
|
+
admin,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
// --- InApp Channel ---
|
|
857
|
+
|
|
858
|
+
// InApp message in DB with template-transformed data
|
|
859
|
+
const inAppMessages = await db
|
|
860
|
+
.select()
|
|
861
|
+
.from(inAppMessagesTable)
|
|
862
|
+
.where(
|
|
863
|
+
and(
|
|
864
|
+
eq(inAppMessagesTable.userId, user2.id),
|
|
865
|
+
eq(inAppMessagesTable.notificationType, "tickets:notify:ticket-assigned"),
|
|
866
|
+
),
|
|
867
|
+
);
|
|
868
|
+
// Filter to this specific ticket by checking title
|
|
869
|
+
const thisMessage = inAppMessages.find((m) =>
|
|
870
|
+
(m["body"] as string)?.includes("Datenbank Backup"),
|
|
871
|
+
);
|
|
872
|
+
expect(thisMessage).toBeDefined();
|
|
873
|
+
expect(thisMessage?.["title"]).toBe("Neues Ticket");
|
|
874
|
+
expect(thisMessage?.["body"]).toContain("Datenbank Backup fehlgeschlagen");
|
|
875
|
+
|
|
876
|
+
// SSE event fired
|
|
877
|
+
const sseNotifs = stack.events.sse.filter(
|
|
878
|
+
(e) =>
|
|
879
|
+
e.type === "channel-in-app:event:delivered" &&
|
|
880
|
+
e.data["userId"] === user2.id &&
|
|
881
|
+
e.data["title"] === "Neues Ticket",
|
|
882
|
+
);
|
|
883
|
+
expect(sseNotifs.length).toBeGreaterThanOrEqual(1);
|
|
884
|
+
|
|
885
|
+
// --- Email Channel ---
|
|
886
|
+
|
|
887
|
+
// Email sent via transport with rendered HTML
|
|
888
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user2.id));
|
|
889
|
+
const thisEmail = emails.find((e) => e.html.includes("Datenbank Backup"));
|
|
890
|
+
expect(thisEmail).toBeDefined();
|
|
891
|
+
// Subject from email template
|
|
892
|
+
expect(thisEmail?.subject).toMatch(/Ticket #[0-9a-f-]+ zugewiesen/);
|
|
893
|
+
// HTML from Simple Renderer (has DOCTYPE, header, sections, button, footer)
|
|
894
|
+
expect(thisEmail?.html).toContain("<!DOCTYPE html>");
|
|
895
|
+
expect(thisEmail?.html).toContain("Neues Ticket"); // header
|
|
896
|
+
expect(thisEmail?.html).toContain("Datenbank Backup fehlgeschlagen"); // text section
|
|
897
|
+
expect(thisEmail?.html).toContain("Ticket oeffnen"); // button label
|
|
898
|
+
expect(thisEmail?.html).toContain("/tickets/"); // button URL
|
|
899
|
+
expect(thisEmail?.html).toContain("Kumiko Notifications"); // footer
|
|
900
|
+
|
|
901
|
+
// --- DeliveryLog ---
|
|
902
|
+
|
|
903
|
+
const logs = await db
|
|
904
|
+
.select()
|
|
905
|
+
.from(deliveryAttemptsTable)
|
|
906
|
+
.where(
|
|
907
|
+
and(
|
|
908
|
+
eq(deliveryAttemptsTable.notificationType, "tickets:notify:ticket-assigned"),
|
|
909
|
+
eq(deliveryAttemptsTable.recipientId, user2.id),
|
|
910
|
+
),
|
|
911
|
+
);
|
|
912
|
+
// Filter to logs from this test (there may be prior entries)
|
|
913
|
+
const inAppLog = logs.find((l) => l["channel"] === "inApp");
|
|
914
|
+
const emailLog = logs.find((l) => l["channel"] === "email");
|
|
915
|
+
expect(inAppLog).toBeDefined();
|
|
916
|
+
expect(inAppLog?.["status"]).toBe("sent");
|
|
917
|
+
expect(emailLog).toBeDefined();
|
|
918
|
+
expect(emailLog?.["status"]).toBe("sent");
|
|
919
|
+
expect(emailLog?.["recipientAddress"]).toBe(testEmail(user2.id));
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// --- Flow 8: Tenant broadcast ---
|
|
924
|
+
|
|
925
|
+
describe("flow 8: tenant broadcast via to: { tenant }", () => {
|
|
926
|
+
test("broadcasts to all users with SSE events", async () => {
|
|
927
|
+
await stack.http.writeOk(
|
|
928
|
+
"app:write:tenant-alert",
|
|
929
|
+
{ message: "Server-Wartung um 22:00", tenantId: "00000000-0000-4000-8000-000000000001" },
|
|
930
|
+
admin,
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
// All 3 tenant users get a message
|
|
934
|
+
const messages = await db
|
|
935
|
+
.select()
|
|
936
|
+
.from(inAppMessagesTable)
|
|
937
|
+
.where(eq(inAppMessagesTable.notificationType, "app:notify:tenant-alert"));
|
|
938
|
+
const recipientIds = messages.map((m) => m["userId"]);
|
|
939
|
+
expect(recipientIds).toContain(admin.id);
|
|
940
|
+
expect(recipientIds).toContain(user1.id);
|
|
941
|
+
expect(recipientIds).toContain(user2.id);
|
|
942
|
+
|
|
943
|
+
// SSE events for all 3 users
|
|
944
|
+
const sseEvents = stack.events.sse.filter(
|
|
945
|
+
(e) =>
|
|
946
|
+
e.type === "channel-in-app:event:delivered" &&
|
|
947
|
+
e.data["notificationType"] === "app:notify:tenant-alert",
|
|
948
|
+
);
|
|
949
|
+
expect(sseEvents).toHaveLength(3);
|
|
950
|
+
const userIds = sseEvents.map((e) => e.data["userId"]);
|
|
951
|
+
expect(userIds).toContain(admin.id);
|
|
952
|
+
expect(userIds).toContain(user1.id);
|
|
953
|
+
expect(userIds).toContain(user2.id);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("delivery log has entries for all recipients and channels", async () => {
|
|
957
|
+
const logs = await db
|
|
958
|
+
.select()
|
|
959
|
+
.from(deliveryAttemptsTable)
|
|
960
|
+
.where(eq(deliveryAttemptsTable.notificationType, "app:notify:tenant-alert"));
|
|
961
|
+
|
|
962
|
+
// 3 users × 3 channels (inApp + email + push) = 9
|
|
963
|
+
expect(logs).toHaveLength(9);
|
|
964
|
+
expect(logs.every((l) => l["status"] === "sent")).toBe(true);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// --- Flow 11: Push channel end-to-end ---
|
|
969
|
+
|
|
970
|
+
describe("flow 11: push channel", () => {
|
|
971
|
+
test("notification sends push via transport", async () => {
|
|
972
|
+
pushTransport.sent.length = 0;
|
|
973
|
+
|
|
974
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 500, driverId: user1.id }, admin);
|
|
975
|
+
|
|
976
|
+
const pushes = pushTransport.sent.filter((p) => p.token === testPushToken(user1.id));
|
|
977
|
+
expect(pushes).toHaveLength(1);
|
|
978
|
+
expect(pushes[0]?.title).toBe("Neuer Auftrag");
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// --- Flow 12: Rate limiting ---
|
|
983
|
+
|
|
984
|
+
describe("flow 12: rate limiting", () => {
|
|
985
|
+
test("notifications are skipped after rate limit is reached", async () => {
|
|
986
|
+
// Set a very low rate limit via Redis key manipulation
|
|
987
|
+
// The rate limit key is "test:delivery:rate:{tenantId}:{channel}"
|
|
988
|
+
// Set it to maxPerHour (100) so the next send is over limit
|
|
989
|
+
await stack.redis.redis.set(RATE_KEY_EMAIL, "100");
|
|
990
|
+
await stack.redis.redis.expire(RATE_KEY_EMAIL, 3600);
|
|
991
|
+
|
|
992
|
+
stack.events.reset();
|
|
993
|
+
emailTransport.sent.length = 0;
|
|
994
|
+
|
|
995
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 501, driverId: user1.id }, admin);
|
|
996
|
+
|
|
997
|
+
// Email should be skipped (rate limited), but inApp + push should work
|
|
998
|
+
const emailLogs = await db
|
|
999
|
+
.select()
|
|
1000
|
+
.from(deliveryAttemptsTable)
|
|
1001
|
+
.where(
|
|
1002
|
+
and(
|
|
1003
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:order-assigned"),
|
|
1004
|
+
eq(deliveryAttemptsTable.recipientId, user1.id),
|
|
1005
|
+
eq(deliveryAttemptsTable.channel, "email"),
|
|
1006
|
+
eq(deliveryAttemptsTable.error, "rate_limited"),
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
expect(emailLogs.length).toBeGreaterThanOrEqual(1);
|
|
1010
|
+
|
|
1011
|
+
// InApp still works
|
|
1012
|
+
const sseNotifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
1013
|
+
expect(sseNotifs.length).toBeGreaterThanOrEqual(1);
|
|
1014
|
+
|
|
1015
|
+
// Clean up
|
|
1016
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// --- Flow 12b: Rate-limit atomicity under concurrent dispatch ---
|
|
1021
|
+
|
|
1022
|
+
describe("flow 12b: rate-limit under concurrent load", () => {
|
|
1023
|
+
test("exactly maxPerHour deliveries allowed, rest rejected (no counter drift)", async () => {
|
|
1024
|
+
// Fresh counter
|
|
1025
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
1026
|
+
|
|
1027
|
+
// Fire 250 concurrent notify calls against an email channel with max=100/h.
|
|
1028
|
+
// The atomic Lua check must allow exactly 100 through and reject 150.
|
|
1029
|
+
const CONCURRENT = 250;
|
|
1030
|
+
const MAX = 100;
|
|
1031
|
+
|
|
1032
|
+
emailTransport.sent.length = 0;
|
|
1033
|
+
stack.events.reset();
|
|
1034
|
+
|
|
1035
|
+
await Promise.all(
|
|
1036
|
+
Array.from({ length: CONCURRENT }, (_, i) =>
|
|
1037
|
+
deliveryService.notify(
|
|
1038
|
+
"app:notify:rate-race",
|
|
1039
|
+
{
|
|
1040
|
+
route: { email: `race-${i}@test.com` },
|
|
1041
|
+
data: { title: "Race", body: "Test" },
|
|
1042
|
+
},
|
|
1043
|
+
admin,
|
|
1044
|
+
admin.tenantId,
|
|
1045
|
+
),
|
|
1046
|
+
),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
// Exactly MAX emails actually sent. The real proof of atomicity: if the
|
|
1050
|
+
// old non-atomic INCR+DECR had a race, we'd see either MORE than MAX
|
|
1051
|
+
// (two checks both seeing count <= max and slipping through) or LESS
|
|
1052
|
+
// than MAX (DECR rolling back counts that were legitimately used).
|
|
1053
|
+
const raceEmails = emailTransport.sent.filter((e) => e.to.startsWith("race-"));
|
|
1054
|
+
expect(raceEmails.length).toBe(MAX);
|
|
1055
|
+
|
|
1056
|
+
// Redis counter must sit at exactly MAX — never above (would mean two
|
|
1057
|
+
// INCRs slipped past the check), never below (would mean a DECR rolled
|
|
1058
|
+
// back a legitimate hit).
|
|
1059
|
+
const counter = Number(await stack.redis.redis.get(RATE_KEY_EMAIL));
|
|
1060
|
+
expect(counter).toBe(MAX);
|
|
1061
|
+
|
|
1062
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// --- Flow 12c: Idempotency key ---
|
|
1067
|
+
|
|
1068
|
+
describe("flow 12c: idempotency key dedup", () => {
|
|
1069
|
+
test("same idempotencyKey fires only once even when called twice", async () => {
|
|
1070
|
+
emailTransport.sent.length = 0;
|
|
1071
|
+
stack.events.reset();
|
|
1072
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
1073
|
+
|
|
1074
|
+
const idemKey = `idem-${Date.now()}`;
|
|
1075
|
+
|
|
1076
|
+
// First call: should deliver
|
|
1077
|
+
await deliveryService.notify(
|
|
1078
|
+
"app:notify:idem-test",
|
|
1079
|
+
{
|
|
1080
|
+
to: user2.id,
|
|
1081
|
+
data: { title: "Idem", body: "First" },
|
|
1082
|
+
idempotencyKey: idemKey,
|
|
1083
|
+
},
|
|
1084
|
+
admin,
|
|
1085
|
+
admin.tenantId,
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
// Second call with same key: should be deduped → no new delivery
|
|
1089
|
+
await deliveryService.notify(
|
|
1090
|
+
"app:notify:idem-test",
|
|
1091
|
+
{
|
|
1092
|
+
to: user2.id,
|
|
1093
|
+
data: { title: "Idem", body: "Second (ignored)" },
|
|
1094
|
+
idempotencyKey: idemKey,
|
|
1095
|
+
},
|
|
1096
|
+
admin,
|
|
1097
|
+
admin.tenantId,
|
|
1098
|
+
);
|
|
1099
|
+
|
|
1100
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user2.id));
|
|
1101
|
+
expect(emails.length).toBe(1);
|
|
1102
|
+
|
|
1103
|
+
// Dup attempt is recorded in the log for audit
|
|
1104
|
+
const dupLogs = await db
|
|
1105
|
+
.select()
|
|
1106
|
+
.from(deliveryAttemptsTable)
|
|
1107
|
+
.where(
|
|
1108
|
+
and(
|
|
1109
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:idem-test"),
|
|
1110
|
+
eq(deliveryAttemptsTable.error, "duplicate_idempotency_key"),
|
|
1111
|
+
),
|
|
1112
|
+
);
|
|
1113
|
+
expect(dupLogs.length).toBe(1);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("different idempotencyKey fires separately", async () => {
|
|
1117
|
+
emailTransport.sent.length = 0;
|
|
1118
|
+
stack.events.reset();
|
|
1119
|
+
await stack.redis.redis.del(RATE_KEY_EMAIL);
|
|
1120
|
+
|
|
1121
|
+
const a = `idem-a-${Date.now()}`;
|
|
1122
|
+
const b = `idem-b-${Date.now()}`;
|
|
1123
|
+
|
|
1124
|
+
await deliveryService.notify(
|
|
1125
|
+
"app:notify:idem-separate",
|
|
1126
|
+
{ to: user2.id, data: { title: "A", body: "A" }, idempotencyKey: a },
|
|
1127
|
+
admin,
|
|
1128
|
+
admin.tenantId,
|
|
1129
|
+
);
|
|
1130
|
+
await deliveryService.notify(
|
|
1131
|
+
"app:notify:idem-separate",
|
|
1132
|
+
{ to: user2.id, data: { title: "B", body: "B" }, idempotencyKey: b },
|
|
1133
|
+
admin,
|
|
1134
|
+
admin.tenantId,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user2.id));
|
|
1138
|
+
expect(emails.length).toBe(2);
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// --- Flow 12d: Channel error paths ---
|
|
1143
|
+
|
|
1144
|
+
describe("flow 12d: channel error paths", () => {
|
|
1145
|
+
test("transport throws → delivery log status=failed with error message", async () => {
|
|
1146
|
+
emailTransport.sent.length = 0;
|
|
1147
|
+
stack.events.reset();
|
|
1148
|
+
|
|
1149
|
+
// Arm the transport to fail on the next send
|
|
1150
|
+
emailTransport.failNext = { message: "smtp_timeout_simulated" };
|
|
1151
|
+
|
|
1152
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 700, driverId: user1.id }, admin);
|
|
1153
|
+
|
|
1154
|
+
// Email was attempted but not delivered
|
|
1155
|
+
const emails = emailTransport.sent.filter((e) => e.to === testEmail(user1.id));
|
|
1156
|
+
expect(emails.length).toBe(0);
|
|
1157
|
+
|
|
1158
|
+
// Log shows the failure with the original error string
|
|
1159
|
+
const failedLogs = await db
|
|
1160
|
+
.select()
|
|
1161
|
+
.from(deliveryAttemptsTable)
|
|
1162
|
+
.where(
|
|
1163
|
+
and(
|
|
1164
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:order-assigned"),
|
|
1165
|
+
eq(deliveryAttemptsTable.recipientId, user1.id),
|
|
1166
|
+
eq(deliveryAttemptsTable.channel, "email"),
|
|
1167
|
+
eq(deliveryAttemptsTable.status, "failed"),
|
|
1168
|
+
),
|
|
1169
|
+
);
|
|
1170
|
+
expect(failedLogs.length).toBeGreaterThanOrEqual(1);
|
|
1171
|
+
expect(failedLogs.at(-1)?.["error"]).toContain("smtp_timeout_simulated");
|
|
1172
|
+
|
|
1173
|
+
// Other channels still work — one failure does not poison the rest
|
|
1174
|
+
const inAppNotifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
1175
|
+
expect(inAppNotifs.some((e) => e.data["userId"] === user1.id)).toBe(true);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
test("transport failure on one recipient does not block others", async () => {
|
|
1179
|
+
emailTransport.sent.length = 0;
|
|
1180
|
+
stack.events.reset();
|
|
1181
|
+
|
|
1182
|
+
// Fail the NEXT send — which corresponds to the first recipient processed
|
|
1183
|
+
emailTransport.failNext = { message: "smtp_transient" };
|
|
1184
|
+
|
|
1185
|
+
await stack.http.writeOk(
|
|
1186
|
+
"app:write:broadcast",
|
|
1187
|
+
{ message: "Partial outage test", userIds: [user1.id, user2.id] },
|
|
1188
|
+
admin,
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
// Exactly one email succeeded; the other was logged as failed
|
|
1192
|
+
const broadcastEmails = emailTransport.sent.filter(
|
|
1193
|
+
(e) => e.to === testEmail(user1.id) || e.to === testEmail(user2.id),
|
|
1194
|
+
);
|
|
1195
|
+
expect(broadcastEmails.length).toBe(1);
|
|
1196
|
+
|
|
1197
|
+
const failedLogs = await db
|
|
1198
|
+
.select()
|
|
1199
|
+
.from(deliveryAttemptsTable)
|
|
1200
|
+
.where(
|
|
1201
|
+
and(
|
|
1202
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:announcement"),
|
|
1203
|
+
eq(deliveryAttemptsTable.channel, "email"),
|
|
1204
|
+
eq(deliveryAttemptsTable.status, "failed"),
|
|
1205
|
+
eq(deliveryAttemptsTable.error, "smtp_transient"),
|
|
1206
|
+
),
|
|
1207
|
+
);
|
|
1208
|
+
expect(failedLogs.length).toBe(1);
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// --- Flow 13: Kill switch ---
|
|
1213
|
+
|
|
1214
|
+
describe("flow 13: tenant kill switch", () => {
|
|
1215
|
+
test("killed channel is skipped with channel_disabled", async () => {
|
|
1216
|
+
// Kill the push channel for tenant 1 (UUID key matches what the service builds)
|
|
1217
|
+
await stack.redis.redis.set(`test:delivery:kill:${admin.tenantId}:push`, "1");
|
|
1218
|
+
|
|
1219
|
+
stack.events.reset();
|
|
1220
|
+
pushTransport.sent.length = 0;
|
|
1221
|
+
|
|
1222
|
+
await stack.http.writeOk("app:write:assign-order", { orderId: 502, driverId: user1.id }, admin);
|
|
1223
|
+
|
|
1224
|
+
// Push should be skipped
|
|
1225
|
+
const pushes = pushTransport.sent.filter((p) => p.token === testPushToken(user1.id));
|
|
1226
|
+
expect(pushes).toHaveLength(0);
|
|
1227
|
+
|
|
1228
|
+
// DeliveryLog shows channel_disabled
|
|
1229
|
+
const pushLogs = await db
|
|
1230
|
+
.select()
|
|
1231
|
+
.from(deliveryAttemptsTable)
|
|
1232
|
+
.where(
|
|
1233
|
+
and(
|
|
1234
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:order-assigned"),
|
|
1235
|
+
eq(deliveryAttemptsTable.recipientId, user1.id),
|
|
1236
|
+
eq(deliveryAttemptsTable.channel, "push"),
|
|
1237
|
+
eq(deliveryAttemptsTable.error, "channel_disabled"),
|
|
1238
|
+
),
|
|
1239
|
+
);
|
|
1240
|
+
expect(pushLogs.length).toBeGreaterThanOrEqual(1);
|
|
1241
|
+
|
|
1242
|
+
// InApp + Email still work
|
|
1243
|
+
const sseNotifs = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
1244
|
+
expect(sseNotifs.length).toBeGreaterThanOrEqual(1);
|
|
1245
|
+
|
|
1246
|
+
// Clean up
|
|
1247
|
+
await stack.redis.redis.del("test:delivery:kill:1:push");
|
|
1248
|
+
});
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// --- Flow 14: Preference wildcard conflict — deny wins ---
|
|
1252
|
+
|
|
1253
|
+
describe("flow 14: wildcard-only preference conflicts resolve deterministically", () => {
|
|
1254
|
+
test("conflicting wildcards (type=*, false vs channel=*, true) → disabled wins", async () => {
|
|
1255
|
+
// Clean slate for user2 on this type/channel
|
|
1256
|
+
await db
|
|
1257
|
+
.delete(notificationPreferencesTable)
|
|
1258
|
+
.where(eq(notificationPreferencesTable.userId, user2.id));
|
|
1259
|
+
|
|
1260
|
+
// Wildcard A: disable inApp globally
|
|
1261
|
+
await stack.http.writeOk(
|
|
1262
|
+
DeliveryHandlers.setPreference,
|
|
1263
|
+
{ notificationType: "*", channel: "inApp", enabled: false },
|
|
1264
|
+
user2,
|
|
1265
|
+
);
|
|
1266
|
+
// Wildcard B: enable this specific type on every channel
|
|
1267
|
+
await stack.http.writeOk(
|
|
1268
|
+
DeliveryHandlers.setPreference,
|
|
1269
|
+
{ notificationType: "app:notify:wildcard-conflict", channel: "*", enabled: true },
|
|
1270
|
+
user2,
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
stack.events.reset();
|
|
1274
|
+
await stack.http.writeOk(
|
|
1275
|
+
"app:write:send-notification",
|
|
1276
|
+
{
|
|
1277
|
+
notificationType: "app:notify:wildcard-conflict",
|
|
1278
|
+
toUserId: user2.id,
|
|
1279
|
+
title: "Konflikt",
|
|
1280
|
+
body: "Test",
|
|
1281
|
+
},
|
|
1282
|
+
admin,
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
// No InApp delivery — "disabled wins" over the enabling wildcard
|
|
1286
|
+
const inAppEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
1287
|
+
expect(inAppEvents.filter((e) => e.data["userId"] === user2.id)).toHaveLength(0);
|
|
1288
|
+
|
|
1289
|
+
const skipped = await db
|
|
1290
|
+
.select()
|
|
1291
|
+
.from(deliveryAttemptsTable)
|
|
1292
|
+
.where(
|
|
1293
|
+
and(
|
|
1294
|
+
eq(deliveryAttemptsTable.notificationType, "app:notify:wildcard-conflict"),
|
|
1295
|
+
eq(deliveryAttemptsTable.recipientId, user2.id),
|
|
1296
|
+
eq(deliveryAttemptsTable.channel, "inApp"),
|
|
1297
|
+
eq(deliveryAttemptsTable.error, "preference_disabled"),
|
|
1298
|
+
),
|
|
1299
|
+
);
|
|
1300
|
+
expect(skipped.length).toBeGreaterThanOrEqual(1);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("exact-match preference still punches through both wildcards", async () => {
|
|
1304
|
+
// Keep the two conflicting wildcards from the previous test, add an exact override
|
|
1305
|
+
await stack.http.writeOk(
|
|
1306
|
+
DeliveryHandlers.setPreference,
|
|
1307
|
+
{
|
|
1308
|
+
notificationType: "app:notify:wildcard-conflict",
|
|
1309
|
+
channel: "inApp",
|
|
1310
|
+
enabled: true,
|
|
1311
|
+
},
|
|
1312
|
+
user2,
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
stack.events.reset();
|
|
1316
|
+
await stack.http.writeOk(
|
|
1317
|
+
"app:write:send-notification",
|
|
1318
|
+
{
|
|
1319
|
+
notificationType: "app:notify:wildcard-conflict",
|
|
1320
|
+
toUserId: user2.id,
|
|
1321
|
+
title: "Konflikt",
|
|
1322
|
+
body: "Override",
|
|
1323
|
+
},
|
|
1324
|
+
admin,
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
const inAppEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
1328
|
+
expect(inAppEvents.filter((e) => e.data["userId"] === user2.id)).toHaveLength(1);
|
|
1329
|
+
|
|
1330
|
+
// Clean up for later tests
|
|
1331
|
+
await db
|
|
1332
|
+
.delete(notificationPreferencesTable)
|
|
1333
|
+
.where(eq(notificationPreferencesTable.userId, user2.id));
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// --- Flow 15: Idempotency without Redis throws (no silent no-op) ---
|
|
1338
|
+
|
|
1339
|
+
describe("flow 15: idempotency requires Redis", () => {
|
|
1340
|
+
test("notify() throws when idempotencyKey is used without a Redis handle", async () => {
|
|
1341
|
+
// Build a service with no rateLimit and no idempotencyRedis.
|
|
1342
|
+
// sseBroker is left off — the throw happens before channel dispatch.
|
|
1343
|
+
const bareService = createDeliveryService({
|
|
1344
|
+
db,
|
|
1345
|
+
registry: stack.registry,
|
|
1346
|
+
channels: collectChannels(stack.registry),
|
|
1347
|
+
tenantUserIdsQuery: TenantQueries.resolveUserIds,
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
await expect(
|
|
1351
|
+
bareService.notify(
|
|
1352
|
+
"app:notify:idem-no-redis",
|
|
1353
|
+
{
|
|
1354
|
+
to: user2.id,
|
|
1355
|
+
data: { title: "X", body: "X" },
|
|
1356
|
+
idempotencyKey: "key-without-redis",
|
|
1357
|
+
},
|
|
1358
|
+
admin,
|
|
1359
|
+
admin.tenantId,
|
|
1360
|
+
),
|
|
1361
|
+
).rejects.toThrow(/idempotencyRedis/);
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
// --- Flow 16: Unsubscribe race — ON CONFLICT makes repeated clicks safe ---
|
|
1366
|
+
|
|
1367
|
+
describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
|
|
1368
|
+
test("clicking the same unsubscribe link twice concurrently does not error", async () => {
|
|
1369
|
+
const token = await signUnsubscribeToken(
|
|
1370
|
+
{
|
|
1371
|
+
userId: user1.id,
|
|
1372
|
+
tenantId: user1.tenantId,
|
|
1373
|
+
notificationType: "app:notify:concurrent-unsub",
|
|
1374
|
+
channel: "email",
|
|
1375
|
+
},
|
|
1376
|
+
JWT_SECRET,
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
const url = `/delivery/unsubscribe?token=${token}`;
|
|
1380
|
+
const results = await Promise.all([
|
|
1381
|
+
stack.app.request(url),
|
|
1382
|
+
stack.app.request(url),
|
|
1383
|
+
stack.app.request(url),
|
|
1384
|
+
]);
|
|
1385
|
+
|
|
1386
|
+
// All three requests complete with 200 — no duplicate-key crashes
|
|
1387
|
+
for (const res of results) {
|
|
1388
|
+
expect(res.status).toBe(200);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Exactly one row exists, marked disabled
|
|
1392
|
+
const rows = await db
|
|
1393
|
+
.select()
|
|
1394
|
+
.from(notificationPreferencesTable)
|
|
1395
|
+
.where(
|
|
1396
|
+
and(
|
|
1397
|
+
eq(notificationPreferencesTable.userId, user1.id),
|
|
1398
|
+
eq(notificationPreferencesTable.notificationType, "app:notify:concurrent-unsub"),
|
|
1399
|
+
eq(notificationPreferencesTable.channel, "email"),
|
|
1400
|
+
),
|
|
1401
|
+
);
|
|
1402
|
+
expect(rows).toHaveLength(1);
|
|
1403
|
+
expect(rows[0]?.["enabled"]).toBe(false);
|
|
1404
|
+
});
|
|
1405
|
+
});
|