@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,422 @@
1
+ // Pure unit tests for the enforce-cap helpers. Mocks ctx.db's
2
+ // select-chains via tiny in-memory stubs so we hit every branch
3
+ // without spinning up the test-stack — the real event-store +
4
+ // dispatcher integration is exercised in cap-counter.integration.ts.
5
+
6
+ import { describe, expect, test, vi } from "vitest";
7
+
8
+ // Temporal: rely on the global ambient declaration from temporal-spec.
9
+ // The framework polyfill is loaded by setupTestStack, but pure unit
10
+ // tests (no stack) need a manual polyfill — vitest.setup.ts does that.
11
+ import {
12
+ CAP_TOLERANCES,
13
+ CapExceededError,
14
+ currentCalendarMonthStartIso,
15
+ enforceCap,
16
+ enforceCapAndMaybeNotify,
17
+ enforceRollingCap,
18
+ enforceRollingCapAndMaybeNotify,
19
+ } from "../enforce-cap";
20
+
21
+ // --- Calendar-Period stub: db.select().from(...).where(...).limit(1) ---
22
+
23
+ function stubCalendarCtx(rows: { value: number; lastSoftWarnedAt: unknown }[]) {
24
+ const ctx = {
25
+ db: {
26
+ select: () => ({
27
+ from: () => ({
28
+ where: () => ({
29
+ limit: async () => rows,
30
+ }),
31
+ }),
32
+ }),
33
+ },
34
+ user: { tenantId: "tenant-test" },
35
+ };
36
+ return ctx as unknown as Parameters<typeof enforceCap>[0];
37
+ }
38
+
39
+ // --- Rolling-Window stub: db.select(...).from(...).where(...) returns rows ---
40
+
41
+ function stubRollingCtx(eventPayloads: { amount: number }[]) {
42
+ const ctx = {
43
+ db: {
44
+ select: () => ({
45
+ from: () => ({
46
+ where: async () => eventPayloads.map((p) => ({ payload: p })),
47
+ }),
48
+ }),
49
+ },
50
+ user: { tenantId: "tenant-test" },
51
+ };
52
+ return ctx as unknown as Parameters<typeof enforceRollingCap>[0];
53
+ }
54
+
55
+ const PERIOD = "2026-05-01T00:00:00Z";
56
+
57
+ // =============================================================================
58
+ // enforceCap — calendar-period
59
+ // =============================================================================
60
+
61
+ describe("enforceCap — burstable profile (mails / tokens)", () => {
62
+ const opts = {
63
+ capName: "mails-per-month",
64
+ periodStartIso: PERIOD,
65
+ limit: 1000,
66
+ profile: "burstable" as const,
67
+ };
68
+
69
+ test("value below soft-threshold → ok", async () => {
70
+ const ctx = stubCalendarCtx([{ value: 500, lastSoftWarnedAt: null }]);
71
+ const result = await enforceCap(ctx, opts);
72
+ expect(result.state).toBe("ok");
73
+ if (result.state === "ok") {
74
+ expect(result.value).toBe(500);
75
+ }
76
+ });
77
+
78
+ test("no row exists yet → value=0, ok", async () => {
79
+ const ctx = stubCalendarCtx([]);
80
+ const result = await enforceCap(ctx, opts);
81
+ expect(result.state).toBe("ok");
82
+ if (result.state === "ok") {
83
+ expect(result.value).toBe(0);
84
+ }
85
+ });
86
+
87
+ test("value at soft-threshold (1100, soft=1.1) → soft-hit, crossed=true on first warn", async () => {
88
+ const ctx = stubCalendarCtx([{ value: 1100, lastSoftWarnedAt: null }]);
89
+ const result = await enforceCap(ctx, opts);
90
+ expect(result.state).toBe("soft-hit");
91
+ if (result.state === "soft-hit") {
92
+ expect(result.value).toBe(1100);
93
+ expect(result.crossed).toBe(true);
94
+ }
95
+ });
96
+
97
+ test("value past soft, already warned → soft-hit, crossed=false (no re-notification)", async () => {
98
+ const ctx = stubCalendarCtx([{ value: 1150, lastSoftWarnedAt: "2026-05-15T12:00:00Z" }]);
99
+ const result = await enforceCap(ctx, opts);
100
+ expect(result.state).toBe("soft-hit");
101
+ if (result.state === "soft-hit") {
102
+ expect(result.crossed).toBe(false);
103
+ }
104
+ });
105
+
106
+ test("value at hard-threshold (1200, hard=1.2) → throws CapExceededError", async () => {
107
+ const ctx = stubCalendarCtx([{ value: 1200, lastSoftWarnedAt: null }]);
108
+ await expect(enforceCap(ctx, opts)).rejects.toThrow(CapExceededError);
109
+ });
110
+
111
+ test("CapExceededError carries cap-name + limit + currentValue", async () => {
112
+ const ctx = stubCalendarCtx([{ value: 1500, lastSoftWarnedAt: null }]);
113
+ try {
114
+ await enforceCap(ctx, opts);
115
+ throw new Error("expected throw");
116
+ } catch (e) {
117
+ expect(e).toBeInstanceOf(CapExceededError);
118
+ const err = e as CapExceededError;
119
+ expect(err.code).toBe("cap_exceeded");
120
+ expect(err.capName).toBe("mails-per-month");
121
+ expect(err.limit).toBe(1000);
122
+ expect(err.currentValue).toBe(1500);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe("enforceCap — storage profile (DB / files)", () => {
128
+ test("storage profile is stricter — soft@100% hard@105%", async () => {
129
+ expect(CAP_TOLERANCES.storage.soft).toBe(1.0);
130
+ expect(CAP_TOLERANCES.storage.hard).toBe(1.05);
131
+ });
132
+
133
+ test("at exactly limit (storage soft=1.0) → soft-hit", async () => {
134
+ const ctx = stubCalendarCtx([{ value: 10240, lastSoftWarnedAt: null }]);
135
+ const result = await enforceCap(ctx, {
136
+ capName: "db-storage-mb",
137
+ periodStartIso: PERIOD,
138
+ limit: 10240,
139
+ profile: "storage",
140
+ });
141
+ expect(result.state).toBe("soft-hit");
142
+ });
143
+
144
+ test("at 1.05× limit (storage hard) → throws", async () => {
145
+ const ctx = stubCalendarCtx([{ value: 10752, lastSoftWarnedAt: null }]);
146
+ await expect(
147
+ enforceCap(ctx, {
148
+ capName: "db-storage-mb",
149
+ periodStartIso: PERIOD,
150
+ limit: 10240,
151
+ profile: "storage",
152
+ }),
153
+ ).rejects.toThrow(CapExceededError);
154
+ });
155
+ });
156
+
157
+ describe("enforceCap — hardSlot profile (apps-count)", () => {
158
+ test("hardSlot has zero buffer — hard@100%", async () => {
159
+ expect(CAP_TOLERANCES.hardSlot.hard).toBe(1.0);
160
+ });
161
+
162
+ test("at exactly limit → throws (hardSlot is hard)", async () => {
163
+ const ctx = stubCalendarCtx([{ value: 5, lastSoftWarnedAt: null }]);
164
+ await expect(
165
+ enforceCap(ctx, {
166
+ capName: "apps-count",
167
+ periodStartIso: PERIOD,
168
+ limit: 5,
169
+ profile: "hardSlot",
170
+ }),
171
+ ).rejects.toThrow(CapExceededError);
172
+ });
173
+ });
174
+
175
+ describe("enforceCap — egress profile", () => {
176
+ test("egress has the largest hard-buffer (130%) — bursty traffic legitimate", async () => {
177
+ expect(CAP_TOLERANCES.egress.hard).toBe(1.3);
178
+ });
179
+ });
180
+
181
+ // =============================================================================
182
+ // enforceRollingCap — Sprint 4: window-based read über Increment-Events
183
+ // =============================================================================
184
+
185
+ describe("enforceRollingCap — burstable profile (KI-tokens-7d)", () => {
186
+ // limit=10000 chosen weil 10000 × 1.1 = 11000 und × 1.2 = 12000 als
187
+ // exact integer floating-point bleiben (50000 × 1.1 wäre 55000.00000000001).
188
+ const opts = {
189
+ capName: "ai-tokens-7d",
190
+ windowDays: 7,
191
+ limit: 10000,
192
+ profile: "burstable" as const,
193
+ };
194
+
195
+ test("no events in window → value=0, ok", async () => {
196
+ const ctx = stubRollingCtx([]);
197
+ const result = await enforceRollingCap(ctx, opts);
198
+ expect(result.state).toBe("ok");
199
+ if (result.state === "ok") {
200
+ expect(result.value).toBe(0);
201
+ }
202
+ });
203
+
204
+ test("sums amounts across multiple events", async () => {
205
+ const ctx = stubRollingCtx([{ amount: 1000 }, { amount: 2500 }, { amount: 500 }]);
206
+ const result = await enforceRollingCap(ctx, opts);
207
+ expect(result.state).toBe("ok");
208
+ if (result.state === "ok") {
209
+ expect(result.value).toBe(4000);
210
+ }
211
+ });
212
+
213
+ test("sum at soft-threshold (11000, soft=1.1×10000) → soft-hit, crossed=false", async () => {
214
+ const ctx = stubRollingCtx([{ amount: 6000 }, { amount: 5000 }]);
215
+ const result = await enforceRollingCap(ctx, opts);
216
+ expect(result.state).toBe("soft-hit");
217
+ if (result.state === "soft-hit") {
218
+ expect(result.value).toBe(11000);
219
+ // Rolling-counter trackt kein lastSoftWarnedAt — crossed ist immer false.
220
+ expect(result.crossed).toBe(false);
221
+ }
222
+ });
223
+
224
+ test("sum at hard-threshold (12000, hard=1.2×10000) → throws CapExceededError", async () => {
225
+ const ctx = stubRollingCtx([{ amount: 6000 }, { amount: 6000 }]);
226
+ await expect(enforceRollingCap(ctx, opts)).rejects.toThrow(CapExceededError);
227
+ });
228
+
229
+ test("ignores malformed payloads (no `amount` field) — defensive against schema-drift", async () => {
230
+ // Sums only the well-formed events. If a future event-shape lands
231
+ // with a different field name, we don't blow up — just under-count.
232
+ const ctx = stubRollingCtx([
233
+ { amount: 1000 },
234
+ // @ts-expect-error — testing defensive read
235
+ { other: 9999 },
236
+ { amount: 2000 },
237
+ ]);
238
+ const result = await enforceRollingCap(ctx, opts);
239
+ expect(result.state).toBe("ok");
240
+ if (result.state === "ok") {
241
+ expect(result.value).toBe(3000);
242
+ }
243
+ });
244
+ });
245
+
246
+ describe("enforceRollingCap — input validation", () => {
247
+ test("missing ctx.db → throws clear error", async () => {
248
+ const ctx = { user: { tenantId: "t" } } as unknown as Parameters<typeof enforceRollingCap>[0];
249
+ await expect(
250
+ enforceRollingCap(ctx, {
251
+ capName: "x",
252
+ windowDays: 7,
253
+ limit: 1,
254
+ profile: "burstable",
255
+ }),
256
+ ).rejects.toThrow(/ctx\.db missing/);
257
+ });
258
+
259
+ test("missing ctx.user.tenantId → throws clear error", async () => {
260
+ const ctx = stubRollingCtx([]);
261
+ delete (ctx as { user?: unknown }).user;
262
+ await expect(
263
+ enforceRollingCap(ctx, {
264
+ capName: "x",
265
+ windowDays: 7,
266
+ limit: 1,
267
+ profile: "burstable",
268
+ }),
269
+ ).rejects.toThrow(/tenantId missing/);
270
+ });
271
+ });
272
+
273
+ // =============================================================================
274
+ // enforceCapAndMaybeNotify — Calendar + Notification-Wiring
275
+ // =============================================================================
276
+
277
+ describe("enforceCapAndMaybeNotify — calendar", () => {
278
+ const baseOpts = {
279
+ capName: "mails-per-month",
280
+ periodStartIso: PERIOD,
281
+ limit: 1000,
282
+ profile: "burstable" as const,
283
+ };
284
+
285
+ test("ok → notifier NICHT aufgerufen", async () => {
286
+ const ctx = stubCalendarCtx([{ value: 100, lastSoftWarnedAt: null }]);
287
+ const notify = vi.fn();
288
+ const result = await enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify });
289
+ expect(result.state).toBe("ok");
290
+ expect(notify).not.toHaveBeenCalled();
291
+ });
292
+
293
+ test("soft-hit, crossed=true → notifier mit info-payload + ctx.write markSoftWarned", async () => {
294
+ const ctx = stubCalendarCtx([{ value: 1100, lastSoftWarnedAt: null }]);
295
+ const write = vi.fn(async () => ({ isSuccess: true, data: {} }));
296
+ (ctx as unknown as { write: typeof write }).write = write;
297
+ const notify = vi.fn();
298
+
299
+ const result = await enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify });
300
+ expect(result.state).toBe("soft-hit");
301
+ expect(notify).toHaveBeenCalledExactlyOnceWith({
302
+ capName: "mails-per-month",
303
+ value: 1100,
304
+ limit: 1000,
305
+ tenantId: "tenant-test",
306
+ });
307
+ expect(write).toHaveBeenCalledExactlyOnceWith("cap-counter:write:mark-soft-warned", {
308
+ capName: "mails-per-month",
309
+ periodStartIso: PERIOD,
310
+ });
311
+ });
312
+
313
+ test("soft-hit, crossed=false (already warned) → notifier NICHT erneut aufgerufen", async () => {
314
+ const ctx = stubCalendarCtx([{ value: 1150, lastSoftWarnedAt: "2026-05-15T12:00:00Z" }]);
315
+ const notify = vi.fn();
316
+ const result = await enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify });
317
+ expect(result.state).toBe("soft-hit");
318
+ expect(notify).not.toHaveBeenCalled();
319
+ });
320
+
321
+ test("hard-hit → throws CapExceededError BEVOR notifier feuert", async () => {
322
+ const ctx = stubCalendarCtx([{ value: 1500, lastSoftWarnedAt: null }]);
323
+ const notify = vi.fn();
324
+ await expect(enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify })).rejects.toThrow(
325
+ CapExceededError,
326
+ );
327
+ expect(notify).not.toHaveBeenCalled();
328
+ });
329
+ });
330
+
331
+ // =============================================================================
332
+ // enforceRollingCapAndMaybeNotify — Rolling + Notification (no dedup)
333
+ // =============================================================================
334
+
335
+ describe("enforceRollingCapAndMaybeNotify — rolling", () => {
336
+ const baseOpts = {
337
+ capName: "ai-tokens-7d",
338
+ windowDays: 7,
339
+ limit: 10000,
340
+ profile: "burstable" as const,
341
+ };
342
+
343
+ test("ok → notifier NICHT aufgerufen", async () => {
344
+ const ctx = stubRollingCtx([{ amount: 100 }]);
345
+ const notify = vi.fn();
346
+ const result = await enforceRollingCapAndMaybeNotify(ctx, { ...baseOpts, notify });
347
+ expect(result.state).toBe("ok");
348
+ expect(notify).not.toHaveBeenCalled();
349
+ });
350
+
351
+ test("soft-hit → notifier feuert (ohne dedup, Caller-Verantwortung)", async () => {
352
+ const ctx = stubRollingCtx([{ amount: 6000 }, { amount: 5000 }]);
353
+ const notify = vi.fn();
354
+ const result = await enforceRollingCapAndMaybeNotify(ctx, { ...baseOpts, notify });
355
+ expect(result.state).toBe("soft-hit");
356
+ expect(notify).toHaveBeenCalledExactlyOnceWith({
357
+ capName: "ai-tokens-7d",
358
+ value: 11000,
359
+ limit: 10000,
360
+ tenantId: "tenant-test",
361
+ });
362
+ });
363
+
364
+ test("zwei aufeinanderfolgende soft-hit-Calls → notifier 2× (kein Dedup)", async () => {
365
+ // Drift-Pin: rolling-counter trackt KEIN lastSoftWarnedAt; Caller
366
+ // muss selbst dedup'en (Cache-Eintrag, Hourly-Cron etc.). Wenn
367
+ // ein Refactor heimlich Dedup einbaut ohne projection-row, fällt
368
+ // das hier auf.
369
+ const ctx = stubRollingCtx([{ amount: 11000 }]);
370
+ const notify = vi.fn();
371
+ await enforceRollingCapAndMaybeNotify(ctx, { ...baseOpts, notify });
372
+ await enforceRollingCapAndMaybeNotify(ctx, { ...baseOpts, notify });
373
+ expect(notify).toHaveBeenCalledTimes(2);
374
+ });
375
+ });
376
+
377
+ // =============================================================================
378
+ // Period-Helpers
379
+ // =============================================================================
380
+
381
+ describe("currentCalendarMonthStartIso", () => {
382
+ test("returns 1st of month at 00:00 UTC, ISO format", () => {
383
+ const midMay = Temporal.Instant.from("2026-05-15T14:32:11Z");
384
+ const result = currentCalendarMonthStartIso(midMay);
385
+ expect(result).toBe("2026-05-01T00:00:00Z");
386
+ });
387
+ });
388
+
389
+ // =============================================================================
390
+ // Aggregate-ID drift-pins — Namespace-Wechsel würde tenant-stored
391
+ // Counter-History komplett re-keyen. Pinning der UUIDs.
392
+ // =============================================================================
393
+
394
+ describe("aggregate-id namespaces — drift-pin", () => {
395
+ test("calendar capCounterAggregateId stable für (tenant, capName, period)", async () => {
396
+ const { capCounterAggregateId } = await import("../aggregate-id");
397
+ expect(capCounterAggregateId("tenant-1", "cap-x", "2026-05-01T00:00:00Z")).toBe(
398
+ "2e74a706-7cc1-51ca-a1a7-89e5c5bccb7e",
399
+ );
400
+ });
401
+
402
+ test("rolling rollingCapAggregateId stable für (tenant, capName)", async () => {
403
+ const { rollingCapAggregateId } = await import("../aggregate-id");
404
+ // Pinne den exakten UUID-output. Wenn jemand den Namespace-uuid in
405
+ // aggregate-id.ts ändert, kollabiert die ganze rolling-counter-
406
+ // history des Tenants — Test fängt's vor dem Deploy.
407
+ expect(rollingCapAggregateId("tenant-1", "ai-tokens-7d")).toBe(
408
+ "7d3dc5df-561f-555f-96d7-e9542d0de679",
409
+ );
410
+ });
411
+
412
+ test("calendar und rolling produzieren UNTERSCHIEDLICHE UUIDs für gleiches Tupel", async () => {
413
+ const { capCounterAggregateId, rollingCapAggregateId } = await import("../aggregate-id");
414
+ // Selbst wenn jemand "1970-01-01..." als periodStart in den
415
+ // calendar-Pfad reinpasst, soll die UUID NICHT mit dem rolling-
416
+ // aggregate kollidieren — sonst würden sich die beiden Streams
417
+ // vermischen und Counter wären falsch.
418
+ const calendarId = capCounterAggregateId("tenant-1", "ai-tokens-7d", "1970-01-01T00:00:00Z");
419
+ const rollingId = rollingCapAggregateId("tenant-1", "ai-tokens-7d");
420
+ expect(calendarId).not.toBe(rollingId);
421
+ });
422
+ });
@@ -0,0 +1,265 @@
1
+ // Integration-test for withCapEnforcement / withRollingCapEnforcement.
2
+ // Beweist die Wrapper-Verdrahtung end-to-end:
3
+ // 1. Pre-call: enforceCapAndMaybeNotify dispatched (notifier feuert,
4
+ // mark-soft-warned-handler kippt das DB-Flag)
5
+ // 2. Handler runs — only when below hard-cap
6
+ // 3. Post-success: ctx.write(increment) — counter steigt um `amount`
7
+ // 4. Hard-hit: handler runs NICHT, counter NICHT inkrementiert
8
+ // 5. Failed handler: counter NICHT inkrementiert (cap-quota nicht
9
+ // verbrannt für gescheiterte writes)
10
+
11
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
12
+ import { defineFeature, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import {
15
+ createEntityTable,
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ testTenantId,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { z } from "zod";
23
+ import { CapCounterQueries } from "../constants";
24
+ import type { SoftHitNotifier } from "../enforce-cap";
25
+ import { capCounterEntity } from "../entity";
26
+ import { capCounterFeature } from "../feature";
27
+ import { withCapEnforcement, withRollingCapEnforcement } from "../with-cap-enforcement";
28
+
29
+ // =============================================================================
30
+ // Test-Probe — newsletter-send-Handler with cap-enforcement
31
+ // =============================================================================
32
+ //
33
+ // Module-level state für die Tests:
34
+ // - sendCallCount: wie oft der gewrappte Handler tatsächlich gerufen wurde
35
+ // (Drift-Pin: bei hard-hit darf das NICHT inkrementieren)
36
+ // - recordedNotifications: Notifier-callback firings
37
+ // - failNextSend: simuliert handler-Fehler — Drift-Pin: Counter darf
38
+ // bei failure nicht inkrementieren
39
+ let sendCallCount = 0;
40
+ let failNextSend = false;
41
+ const recordedNotifications: Array<{ capName: string; value: number }> = [];
42
+ const recordingNotifier: SoftHitNotifier = (info) => {
43
+ recordedNotifications.push({ capName: info.capName, value: info.value });
44
+ };
45
+
46
+ const innerSendHandler: WriteHandlerDef = {
47
+ name: "send-newsletter",
48
+ schema: z.object({ to: z.string() }),
49
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
50
+ handler: async (_event, _ctx) => {
51
+ sendCallCount += 1;
52
+ if (failNextSend) {
53
+ failNextSend = false;
54
+ throw new Error("send-failed-on-purpose");
55
+ }
56
+ return { isSuccess: true as const, data: { sent: true } };
57
+ },
58
+ };
59
+
60
+ const PERIOD = "2026-07-01T00:00:00Z";
61
+
62
+ const wrappedCalendar = withCapEnforcement(innerSendHandler, () => ({
63
+ capName: "newsletter-cap",
64
+ periodStartIso: PERIOD,
65
+ limit: 5,
66
+ profile: "burstable",
67
+ notify: recordingNotifier,
68
+ }));
69
+
70
+ const wrappedRolling = withRollingCapEnforcement(
71
+ { ...innerSendHandler, name: "send-rolling" },
72
+ () => ({
73
+ capName: "newsletter-rolling-cap",
74
+ windowDays: 7,
75
+ limit: 5,
76
+ profile: "burstable",
77
+ notify: recordingNotifier,
78
+ }),
79
+ );
80
+
81
+ const NEWSLETTER_QN = "newsletter:write:send-newsletter";
82
+ const NEWSLETTER_ROLLING_QN = "newsletter:write:send-rolling";
83
+ const newsletterFeature = defineFeature("newsletter", (r) => {
84
+ r.writeHandler(wrappedCalendar);
85
+ r.writeHandler(wrappedRolling);
86
+ });
87
+
88
+ // =============================================================================
89
+ // Setup
90
+ // =============================================================================
91
+
92
+ let stack: TestStack;
93
+ let db: DbConnection;
94
+
95
+ beforeAll(async () => {
96
+ stack = await setupTestStack({ features: [capCounterFeature, newsletterFeature] });
97
+ db = stack.db;
98
+ await createEntityTable(db, capCounterEntity);
99
+ await createEventsTable(db);
100
+ });
101
+
102
+ afterAll(async () => {
103
+ await stack.cleanup();
104
+ });
105
+
106
+ function adminFor(tenantNumber: number) {
107
+ return createTestUser({
108
+ id: tenantNumber,
109
+ tenantId: testTenantId(tenantNumber),
110
+ roles: ["TenantAdmin", "SystemAdmin"],
111
+ });
112
+ }
113
+
114
+ async function readCounter(user: ReturnType<typeof adminFor>, capName: string, period: string) {
115
+ return (await stack.http.queryOk(
116
+ CapCounterQueries.getCounter,
117
+ { capName, periodStartIso: period },
118
+ user,
119
+ )) as Record<string, unknown> | null;
120
+ }
121
+
122
+ function resetState() {
123
+ sendCallCount = 0;
124
+ failNextSend = false;
125
+ recordedNotifications.length = 0;
126
+ }
127
+
128
+ // =============================================================================
129
+ // Calendar-wrapper scenarios
130
+ // =============================================================================
131
+
132
+ describe("withCapEnforcement — calendar", () => {
133
+ test("under-cap: handler läuft, counter inkrementiert um 1 pro success", async () => {
134
+ resetState();
135
+ const admin = adminFor(1201);
136
+
137
+ await stack.http.writeOk(NEWSLETTER_QN, { to: "a@x.de" }, admin);
138
+ await stack.http.writeOk(NEWSLETTER_QN, { to: "b@x.de" }, admin);
139
+ await stack.http.writeOk(NEWSLETTER_QN, { to: "c@x.de" }, admin);
140
+
141
+ expect(sendCallCount).toBe(3);
142
+ const row = await readCounter(admin, "newsletter-cap", PERIOD);
143
+ expect(row).not.toBeNull();
144
+ expect(row!["value"]).toBe(3);
145
+ expect(recordedNotifications).toHaveLength(0);
146
+ });
147
+
148
+ test("hard-hit: handler läuft NICHT, counter NICHT weiter inkrementiert", async () => {
149
+ resetState();
150
+ const admin = adminFor(1203);
151
+ // limit=5, soft=1.1×5=5.5, hard=1.2×5=6. Da Counter int ist, springt
152
+ // value(5)→6 direkt in den hard-Bereich (keine intermediate soft-zone
153
+ // bei limit=5). Soft-hit-Verhalten ist im enforce-cap-Integration-Test
154
+ // mit limit=1000 schon gepinnt; hier liegt der Fokus auf hard-block.
155
+ for (let i = 0; i < 6; i++) {
156
+ await stack.http.writeOk(NEWSLETTER_QN, { to: `${i}@x.de` }, admin);
157
+ }
158
+ expect(sendCallCount).toBe(6);
159
+ const beforeBlocked = await readCounter(admin, "newsletter-cap", PERIOD);
160
+ expect(beforeBlocked!["value"]).toBe(6);
161
+
162
+ // 7. send: pre-call sieht value=6 ≥ hard=6 → CapExceededError, der
163
+ // dispatcher wickelt's als internal_error mit causeName=CapExceededError.
164
+ const error = await stack.http.writeErr(NEWSLETTER_QN, { to: "blocked@x.de" }, admin);
165
+ expect(JSON.stringify(error)).toMatch(/CapExceededError/);
166
+
167
+ // Drift-Pin: handler darf NICHT gelaufen sein (sendCallCount unverändert)
168
+ expect(sendCallCount).toBe(6);
169
+ // Drift-Pin: counter NICHT weiter inkrementiert (immer noch 6)
170
+ const afterBlocked = await readCounter(admin, "newsletter-cap", PERIOD);
171
+ expect(afterBlocked!["value"]).toBe(6);
172
+ });
173
+
174
+ test("failed handler: counter NICHT inkrementiert (cap-quota nicht verbrannt)", async () => {
175
+ resetState();
176
+ const admin = adminFor(1204);
177
+
178
+ // Erster send: success, counter → 1
179
+ await stack.http.writeOk(NEWSLETTER_QN, { to: "first@x.de" }, admin);
180
+ expect(sendCallCount).toBe(1);
181
+
182
+ // Zweiter send schlägt fehl im inner-handler (failNextSend=true).
183
+ // Wrapper soll NICHT inkrementieren.
184
+ failNextSend = true;
185
+ await stack.http.writeErr(NEWSLETTER_QN, { to: "fail@x.de" }, admin);
186
+ expect(sendCallCount).toBe(2);
187
+
188
+ // Counter bleibt bei 1 — der gescheiterte send hat keine quota verbrannt.
189
+ const row = await readCounter(admin, "newsletter-cap", PERIOD);
190
+ expect(row!["value"]).toBe(1);
191
+ });
192
+ });
193
+
194
+ // =============================================================================
195
+ // Rolling-wrapper scenarios — kürzer, weil Notification-Wiring + base-flow
196
+ // schon vom calendar-Test abgedeckt sind.
197
+ // =============================================================================
198
+
199
+ describe("withRollingCapEnforcement — rolling", () => {
200
+ test("under-cap: handler läuft, increment-rolling-events accumulieren", async () => {
201
+ resetState();
202
+ const admin = adminFor(1301);
203
+
204
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: "a@x.de" }, admin);
205
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: "b@x.de" }, admin);
206
+ expect(sendCallCount).toBe(2);
207
+ // Read via enforceRollingCap — kein direct-getter, aber wir können
208
+ // einen weiteren write absetzen und das Ergebnis prüfen ist
209
+ // upstream. Wichtig: handler ist aufgerufen.
210
+ });
211
+
212
+ test("hard-hit: rolling-counter blockiert weitere sends", async () => {
213
+ resetState();
214
+ const admin = adminFor(1302);
215
+
216
+ // limit=5, soft=5.5, hard=6. 6 sends bringen value=6 → 7. send blockiert.
217
+ for (let i = 0; i < 6; i++) {
218
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: `${i}@x.de` }, admin);
219
+ }
220
+ expect(sendCallCount).toBe(6);
221
+
222
+ const error = await stack.http.writeErr(NEWSLETTER_ROLLING_QN, { to: "blocked@x.de" }, admin);
223
+ expect(JSON.stringify(error)).toMatch(/CapExceededError/);
224
+ expect(sendCallCount).toBe(6); // handler wurde NICHT erneut aufgerufen
225
+ });
226
+
227
+ test("failed handler: kein increment-rolling-event hinzugefügt (cap-quota nicht verbrannt)", async () => {
228
+ // Symmetrisch zum calendar-Test "failed handler: counter NICHT
229
+ // inkrementiert". Beweist dass der rolling-Wrapper denselben
230
+ // Atomicity-Vertrag erfüllt: nur erfolgreiche handler verbrennen
231
+ // quota.
232
+ resetState();
233
+ const admin = adminFor(1303);
234
+
235
+ // 1. send: success → increment-rolling-event #1
236
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: "first@x.de" }, admin);
237
+ expect(sendCallCount).toBe(1);
238
+
239
+ // 2. send: handler wirft → kein increment-rolling-event
240
+ failNextSend = true;
241
+ await stack.http.writeErr(NEWSLETTER_ROLLING_QN, { to: "fail@x.de" }, admin);
242
+ expect(sendCallCount).toBe(2);
243
+
244
+ // 3. send: success → increment-rolling-event #2 (Drift-Pin: counter
245
+ // steht bei 2, NICHT bei 3 — der gescheiterte send #2 hat keine
246
+ // quota verbrannt). Wir treiben den counter bis genau hard-1, das
247
+ // funktioniert NUR wenn #2 nicht gezählt wurde.
248
+ for (let i = 0; i < 4; i++) {
249
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: `s-${i}@x.de` }, admin);
250
+ }
251
+ expect(sendCallCount).toBe(6);
252
+
253
+ // 7. send (= hard@6): blockiert. counter steht bei 5 (1 + 4),
254
+ // pre-call sieht 5 < hard@6 → handler läuft + increment, counter
255
+ // steigt auf 6. Direkt danach blockiert der nächste send.
256
+ // Wenn der gescheiterte send fälschlich gezählt hätte, wäre der
257
+ // counter schon bei 6 und der jetzt-erlaubte send würde blockieren.
258
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: "last-allowed@x.de" }, admin);
259
+ expect(sendCallCount).toBe(7);
260
+
261
+ const blocked = await stack.http.writeErr(NEWSLETTER_ROLLING_QN, { to: "blocked@x.de" }, admin);
262
+ expect(JSON.stringify(blocked)).toMatch(/CapExceededError/);
263
+ expect(sendCallCount).toBe(7); // wrapper hat den blockierten handler NICHT gerufen
264
+ });
265
+ });