@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.
Files changed (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. 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
+ });