@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,566 @@
1
+ // Full-stack integration test for cap-counter. Drives the increment
2
+ // + get + enforceCap path through the dispatcher + real DB.
3
+ //
4
+ // **Test-Probe-Pattern:** a tiny one-off feature with a write-handler
5
+ // that calls enforceCap → returns the result-state so the test can
6
+ // assert. Mirrors the mail-foundation / file-foundation integration
7
+ // test pattern.
8
+
9
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
+ import { defineFeature, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
12
+ import {
13
+ createEntityTable,
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ TestUsers,
18
+ testTenantId,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { z } from "zod";
22
+ import { CapCounterHandlers, CapCounterQueries } from "../constants";
23
+ import {
24
+ CapExceededError,
25
+ currentCalendarMonthStartIso,
26
+ enforceCap,
27
+ enforceCapAndMaybeNotify,
28
+ enforceRollingCap,
29
+ enforceRollingCapAndMaybeNotify,
30
+ type SoftHitNotifier,
31
+ } from "../enforce-cap";
32
+ import { capCounterEntity } from "../entity";
33
+ import { capCounterFeature } from "../feature";
34
+
35
+ // --- Test-Probe-Feature: drives enforceCap from inside a real handler ---
36
+
37
+ const ENFORCE_PROBE_QN = "cap-test:write:enforce";
38
+
39
+ // Direct WriteHandlerDef — bypasses the defineWriteHandler factory whose
40
+ // type-parameter inference clashes with the cross-package HandlerContext
41
+ // generic in this test file. Same runtime contract.
42
+ const enforceHandler: WriteHandlerDef = {
43
+ name: "enforce",
44
+ schema: z.object({
45
+ capName: z.string(),
46
+ periodStartIso: z.string(),
47
+ limit: z.number(),
48
+ profile: z.enum(["burstable", "storage", "hardSlot", "egress"]),
49
+ }),
50
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
51
+ handler: async (event, ctx) => {
52
+ try {
53
+ const result = await enforceCap(ctx, event.payload as Parameters<typeof enforceCap>[1]);
54
+ return { isSuccess: true as const, data: { ok: true, ...result } };
55
+ } catch (e) {
56
+ if (e instanceof CapExceededError) {
57
+ return {
58
+ isSuccess: true as const,
59
+ data: { ok: false, code: e.code, currentValue: e.currentValue, limit: e.limit },
60
+ };
61
+ }
62
+ throw e;
63
+ }
64
+ },
65
+ };
66
+
67
+ // Sister probe for the rolling-window flavour. Same pattern as
68
+ // `enforceHandler` above — drives `enforceRollingCap` through the
69
+ // dispatcher so the test sees a real ctx with real db + real
70
+ // tenant-scope.
71
+ const ENFORCE_ROLLING_PROBE_QN = "cap-test:write:enforce-rolling";
72
+ const enforceRollingHandler: WriteHandlerDef = {
73
+ name: "enforce-rolling",
74
+ schema: z.object({
75
+ capName: z.string(),
76
+ windowDays: z.number(),
77
+ limit: z.number(),
78
+ profile: z.enum(["burstable", "storage", "hardSlot", "egress"]),
79
+ }),
80
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
81
+ handler: async (event, ctx) => {
82
+ try {
83
+ const result = await enforceRollingCap(
84
+ ctx,
85
+ event.payload as Parameters<typeof enforceRollingCap>[1],
86
+ );
87
+ return { isSuccess: true as const, data: { ok: true, ...result } };
88
+ } catch (e) {
89
+ if (e instanceof CapExceededError) {
90
+ return {
91
+ isSuccess: true as const,
92
+ data: { ok: false, code: e.code, currentValue: e.currentValue, limit: e.limit },
93
+ };
94
+ }
95
+ throw e;
96
+ }
97
+ },
98
+ };
99
+
100
+ // Notification-recorder — module-level state that the probe-handlers
101
+ // push into. Tests reset between scenarios via `recordedNotifications.length = 0`.
102
+ // Captures real notifier-callback firings against a real dispatched
103
+ // mark-soft-warned-write — this is the full-stack proof that
104
+ // enforceCapAndMaybeNotify actually wires soft-hit → notify + DB-flag.
105
+ const recordedNotifications: Array<{
106
+ capName: string;
107
+ value: number;
108
+ limit: number;
109
+ tenantId: string;
110
+ }> = [];
111
+ const recordingNotifier: SoftHitNotifier = (info) => {
112
+ recordedNotifications.push({
113
+ capName: info.capName,
114
+ value: info.value,
115
+ limit: info.limit,
116
+ tenantId: info.tenantId,
117
+ });
118
+ };
119
+
120
+ const ENFORCE_NOTIFY_PROBE_QN = "cap-test:write:enforce-and-notify";
121
+ const enforceAndNotifyHandler: WriteHandlerDef = {
122
+ name: "enforce-and-notify",
123
+ schema: z.object({
124
+ capName: z.string(),
125
+ periodStartIso: z.string(),
126
+ limit: z.number(),
127
+ profile: z.enum(["burstable", "storage", "hardSlot", "egress"]),
128
+ }),
129
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
130
+ handler: async (event, ctx) => {
131
+ try {
132
+ const result = await enforceCapAndMaybeNotify(ctx, {
133
+ ...(event.payload as Omit<Parameters<typeof enforceCapAndMaybeNotify>[1], "notify">),
134
+ notify: recordingNotifier,
135
+ });
136
+ return { isSuccess: true as const, data: { ok: true, ...result } };
137
+ } catch (e) {
138
+ if (e instanceof CapExceededError) {
139
+ return { isSuccess: true as const, data: { ok: false, code: e.code } };
140
+ }
141
+ throw e;
142
+ }
143
+ },
144
+ };
145
+
146
+ const ENFORCE_ROLLING_NOTIFY_PROBE_QN = "cap-test:write:enforce-rolling-and-notify";
147
+ const enforceRollingAndNotifyHandler: WriteHandlerDef = {
148
+ name: "enforce-rolling-and-notify",
149
+ schema: z.object({
150
+ capName: z.string(),
151
+ windowDays: z.number(),
152
+ limit: z.number(),
153
+ profile: z.enum(["burstable", "storage", "hardSlot", "egress"]),
154
+ }),
155
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
156
+ handler: async (event, ctx) => {
157
+ const result = await enforceRollingCapAndMaybeNotify(ctx, {
158
+ ...(event.payload as Omit<Parameters<typeof enforceRollingCapAndMaybeNotify>[1], "notify">),
159
+ notify: recordingNotifier,
160
+ });
161
+ return { isSuccess: true as const, data: { ok: true, ...result } };
162
+ },
163
+ };
164
+
165
+ const enforceProbeFeature = defineFeature("cap-test", (r) => {
166
+ r.writeHandler(enforceHandler);
167
+ r.writeHandler(enforceRollingHandler);
168
+ r.writeHandler(enforceAndNotifyHandler);
169
+ r.writeHandler(enforceRollingAndNotifyHandler);
170
+ });
171
+
172
+ // --- Setup ---
173
+
174
+ let stack: TestStack;
175
+ let db: DbConnection;
176
+
177
+ beforeAll(async () => {
178
+ stack = await setupTestStack({
179
+ features: [capCounterFeature, enforceProbeFeature],
180
+ });
181
+ db = stack.db;
182
+
183
+ await createEntityTable(db, capCounterEntity);
184
+ await createEventsTable(db);
185
+ });
186
+
187
+ afterAll(async () => {
188
+ await stack.cleanup();
189
+ });
190
+
191
+ // Fixed period used for calendar-counter scenarios. Tests don't care
192
+ // which month — just need a stable iso-string so `increment` +
193
+ // `readCounter` hit the same aggregate.
194
+ const PERIOD = "2026-05-01T00:00:00Z";
195
+ const sysadmin = TestUsers.systemAdmin;
196
+
197
+ function adminFor(tenantNumber: number) {
198
+ return createTestUser({
199
+ id: tenantNumber,
200
+ tenantId: testTenantId(tenantNumber),
201
+ roles: ["TenantAdmin", "SystemAdmin"],
202
+ });
203
+ }
204
+
205
+ async function increment(
206
+ user: ReturnType<typeof adminFor>,
207
+ capName: string,
208
+ amount: number,
209
+ periodStartIso: string = PERIOD,
210
+ ) {
211
+ await stack.http.writeOk(CapCounterHandlers.increment, { capName, amount, periodStartIso }, user);
212
+ }
213
+
214
+ async function incrementRolling(
215
+ user: ReturnType<typeof adminFor>,
216
+ capName: string,
217
+ amount: number,
218
+ ) {
219
+ await stack.http.writeOk(CapCounterHandlers.incrementRolling, { capName, amount }, user);
220
+ }
221
+
222
+ async function readCounter(
223
+ user: ReturnType<typeof adminFor>,
224
+ capName: string,
225
+ periodStartIso: string = PERIOD,
226
+ ) {
227
+ return (await stack.http.queryOk(
228
+ CapCounterQueries.getCounter,
229
+ { capName, periodStartIso },
230
+ user,
231
+ )) as Record<string, unknown> | null;
232
+ }
233
+
234
+ // --- Scenario 1: increment + read ---
235
+
236
+ describe("scenario 1: increment + read", () => {
237
+ test("first increment creates the counter row with value=amount", async () => {
238
+ await increment(sysadmin, "cap-1-mails", 1);
239
+
240
+ const row = await readCounter(sysadmin, "cap-1-mails");
241
+ expect(row).not.toBeNull();
242
+ expect(row!["value"]).toBe(1);
243
+ expect(row!["capName"]).toBe("cap-1-mails");
244
+ });
245
+
246
+ test("subsequent increments add to the existing value", async () => {
247
+ await increment(sysadmin, "cap-1-tokens", 100);
248
+ await increment(sysadmin, "cap-1-tokens", 50);
249
+ await increment(sysadmin, "cap-1-tokens", 25);
250
+
251
+ const row = await readCounter(sysadmin, "cap-1-tokens");
252
+ expect(row!["value"]).toBe(175);
253
+ });
254
+
255
+ test("get-counter returns null when no increment happened in this period", async () => {
256
+ const row = await readCounter(sysadmin, "cap-1-never-touched");
257
+ expect(row).toBeNull();
258
+ });
259
+ });
260
+
261
+ // --- Scenario 2: enforceCap end-to-end ---
262
+
263
+ describe("scenario 2: enforceCap through dispatcher", () => {
264
+ test("under-soft → ok", async () => {
265
+ const admin = adminFor(601);
266
+ await increment(admin, "cap-2-mails", 100);
267
+
268
+ const result = (await stack.http.writeOk(
269
+ ENFORCE_PROBE_QN,
270
+ { capName: "cap-2-mails", periodStartIso: PERIOD, limit: 1000, profile: "burstable" },
271
+ admin,
272
+ )) as Record<string, unknown>;
273
+
274
+ expect(result["ok"]).toBe(true);
275
+ expect(result["state"]).toBe("ok");
276
+ expect(result["value"]).toBe(100);
277
+ });
278
+
279
+ test("at soft-threshold (1100, soft=1.1) → soft-hit, crossed=true", async () => {
280
+ const admin = adminFor(602);
281
+ await increment(admin, "cap-2-mails", 1100);
282
+
283
+ const result = (await stack.http.writeOk(
284
+ ENFORCE_PROBE_QN,
285
+ { capName: "cap-2-mails", periodStartIso: PERIOD, limit: 1000, profile: "burstable" },
286
+ admin,
287
+ )) as Record<string, unknown>;
288
+
289
+ expect(result["state"]).toBe("soft-hit");
290
+ expect(result["crossed"]).toBe(true);
291
+ });
292
+
293
+ test("at hard-threshold (1200) → cap_exceeded code returned", async () => {
294
+ const admin = adminFor(603);
295
+ await increment(admin, "cap-2-mails", 1200);
296
+
297
+ const result = (await stack.http.writeOk(
298
+ ENFORCE_PROBE_QN,
299
+ { capName: "cap-2-mails", periodStartIso: PERIOD, limit: 1000, profile: "burstable" },
300
+ admin,
301
+ )) as Record<string, unknown>;
302
+
303
+ expect(result["ok"]).toBe(false);
304
+ expect(result["code"]).toBe("cap_exceeded");
305
+ expect(result["currentValue"]).toBe(1200);
306
+ expect(result["limit"]).toBe(1000);
307
+ });
308
+ });
309
+
310
+ // --- Scenario 3: tenant isolation ---
311
+
312
+ describe("scenario 3: tenant isolation", () => {
313
+ test("tenant A's counter doesn't bleed into tenant B's read", async () => {
314
+ const adminA = adminFor(701);
315
+ const adminB = adminFor(702);
316
+
317
+ await increment(adminA, "iso-test", 500);
318
+ await increment(adminB, "iso-test", 50);
319
+
320
+ const rowA = await readCounter(adminA, "iso-test");
321
+ const rowB = await readCounter(adminB, "iso-test");
322
+
323
+ expect(rowA!["value"]).toBe(500);
324
+ expect(rowB!["value"]).toBe(50);
325
+ });
326
+ });
327
+
328
+ // --- Scenario 4: calendar-month period switch ---
329
+
330
+ describe("scenario 4: period transition", () => {
331
+ test("new periodStart creates a separate counter aggregate", async () => {
332
+ const admin = adminFor(801);
333
+ const monthA = "2026-04-01T00:00:00Z";
334
+ const monthB = "2026-05-01T00:00:00Z";
335
+
336
+ await increment(admin, "monthly-mails", 800, monthA);
337
+ await increment(admin, "monthly-mails", 200, monthB);
338
+
339
+ const rowA = await readCounter(admin, "monthly-mails", monthA);
340
+ const rowB = await readCounter(admin, "monthly-mails", monthB);
341
+
342
+ expect(rowA!["value"]).toBe(800);
343
+ expect(rowB!["value"]).toBe(200);
344
+ });
345
+
346
+ test("currentCalendarMonthStartIso returns a usable period-key", () => {
347
+ // Real-time call — just confirm it returns valid ISO + 1st-of-month
348
+ const iso = currentCalendarMonthStartIso();
349
+ expect(iso).toMatch(/^\d{4}-\d{2}-01T00:00:00/);
350
+ });
351
+ });
352
+
353
+ // --- Scenario 5: Rolling-Window Counter (Sprint 4) ---
354
+ //
355
+ // Echte Verdrahtung beweisen: incrementRollingCap appendet ein
356
+ // rolling-incremented-Event in den event-store, enforceRollingCap
357
+ // liest das Event aus dem Window und summiert. Beide gehen über den
358
+ // Dispatcher mit dem realen ctx.
359
+
360
+ describe("scenario 5: rolling-window through dispatcher", () => {
361
+ test("incrementRolling appends a rolling-incremented-event without creating a projection-row", async () => {
362
+ const admin = adminFor(901);
363
+ await incrementRolling(admin, "ai-tokens-7d", 1500);
364
+
365
+ // Drift-Pin: Rolling-Counter MUSS den Calendar-Counter NICHT
366
+ // berühren. Wenn ein Refactor die Pfade vermischt, taucht hier
367
+ // plötzlich eine Row auf, die nichts mit dem rolling-stream zu
368
+ // tun hat.
369
+ const calendarRow = await readCounter(admin, "ai-tokens-7d");
370
+ expect(calendarRow).toBeNull();
371
+ });
372
+
373
+ test("enforceRollingCap summiert mehrere increment-events innerhalb des Windows", async () => {
374
+ const admin = adminFor(902);
375
+ await incrementRolling(admin, "ai-tokens-7d", 1000);
376
+ await incrementRolling(admin, "ai-tokens-7d", 2500);
377
+ await incrementRolling(admin, "ai-tokens-7d", 500);
378
+
379
+ const result = (await stack.http.writeOk(
380
+ ENFORCE_ROLLING_PROBE_QN,
381
+ { capName: "ai-tokens-7d", windowDays: 7, limit: 10000, profile: "burstable" },
382
+ admin,
383
+ )) as Record<string, unknown>;
384
+
385
+ expect(result["ok"]).toBe(true);
386
+ expect(result["state"]).toBe("ok");
387
+ expect(result["value"]).toBe(4000);
388
+ });
389
+
390
+ test("at soft-threshold (11000, soft=1.1×10000) → soft-hit, crossed=false", async () => {
391
+ const admin = adminFor(903);
392
+ await incrementRolling(admin, "ai-tokens-7d", 6000);
393
+ await incrementRolling(admin, "ai-tokens-7d", 5000);
394
+
395
+ const result = (await stack.http.writeOk(
396
+ ENFORCE_ROLLING_PROBE_QN,
397
+ { capName: "ai-tokens-7d", windowDays: 7, limit: 10000, profile: "burstable" },
398
+ admin,
399
+ )) as Record<string, unknown>;
400
+
401
+ expect(result["state"]).toBe("soft-hit");
402
+ expect(result["value"]).toBe(11000);
403
+ // Rolling-Counter hat keine projection-row → crossed ist konstant false.
404
+ expect(result["crossed"]).toBe(false);
405
+ });
406
+
407
+ test("at hard-threshold (12000) → cap_exceeded code returned", async () => {
408
+ const admin = adminFor(904);
409
+ await incrementRolling(admin, "ai-tokens-7d", 6000);
410
+ await incrementRolling(admin, "ai-tokens-7d", 6000);
411
+
412
+ const result = (await stack.http.writeOk(
413
+ ENFORCE_ROLLING_PROBE_QN,
414
+ { capName: "ai-tokens-7d", windowDays: 7, limit: 10000, profile: "burstable" },
415
+ admin,
416
+ )) as Record<string, unknown>;
417
+
418
+ expect(result["ok"]).toBe(false);
419
+ expect(result["code"]).toBe("cap_exceeded");
420
+ expect(result["currentValue"]).toBe(12000);
421
+ });
422
+
423
+ test("rolling-counter-Stream isoliert pro (tenant, capName) — fremder cap zählt nicht", async () => {
424
+ const admin = adminFor(905);
425
+ await incrementRolling(admin, "ai-tokens-7d", 9999);
426
+ await incrementRolling(admin, "egress-bytes-24h", 100);
427
+
428
+ const result = (await stack.http.writeOk(
429
+ ENFORCE_ROLLING_PROBE_QN,
430
+ { capName: "egress-bytes-24h", windowDays: 1, limit: 1000, profile: "egress" },
431
+ admin,
432
+ )) as Record<string, unknown>;
433
+
434
+ // Egress-window sieht NUR die 100 vom egress-cap — die 9999 vom
435
+ // ai-tokens-cap sind ein anderer Aggregate-Stream.
436
+ expect(result["state"]).toBe("ok");
437
+ expect(result["value"]).toBe(100);
438
+ });
439
+
440
+ test("rolling-counter ist tenant-isoliert — tenant A's increments leaken nicht zu tenant B", async () => {
441
+ const adminA = adminFor(906);
442
+ const adminB = adminFor(907);
443
+ await incrementRolling(adminA, "rolling-iso", 5000);
444
+ await incrementRolling(adminB, "rolling-iso", 100);
445
+
446
+ const resultB = (await stack.http.writeOk(
447
+ ENFORCE_ROLLING_PROBE_QN,
448
+ { capName: "rolling-iso", windowDays: 7, limit: 10000, profile: "burstable" },
449
+ adminB,
450
+ )) as Record<string, unknown>;
451
+
452
+ expect(resultB["state"]).toBe("ok");
453
+ expect(resultB["value"]).toBe(100);
454
+ });
455
+ });
456
+
457
+ // --- Scenario 6: Notification-Wiring through dispatcher (Sprint 4) ---
458
+ //
459
+ // Beweist: enforceCapAndMaybeNotify ruft den Notifier UND dispatched
460
+ // tatsächlich `cap-counter:write:mark-soft-warned`, das den
461
+ // `lastSoftWarnedAt`-flag in der DB setzt. Beim zweiten Aufruf in
462
+ // derselben Period feuert der Notifier NICHT erneut (crossed=false,
463
+ // weil flag jetzt nicht mehr null).
464
+
465
+ describe("scenario 6: notification-wiring (calendar)", () => {
466
+ test("soft-hit-Crossing → notifier feuert UND mark-soft-warned-handler kippt das DB-Flag", async () => {
467
+ recordedNotifications.length = 0;
468
+ const admin = adminFor(1001);
469
+ const NOTIFY_PERIOD = "2026-06-01T00:00:00Z";
470
+
471
+ // 1100 = soft-threshold bei limit=1000 / burstable.
472
+ await increment(admin, "cap-notify-mails", 1100, NOTIFY_PERIOD);
473
+
474
+ const first = (await stack.http.writeOk(
475
+ ENFORCE_NOTIFY_PROBE_QN,
476
+ {
477
+ capName: "cap-notify-mails",
478
+ periodStartIso: NOTIFY_PERIOD,
479
+ limit: 1000,
480
+ profile: "burstable",
481
+ },
482
+ admin,
483
+ )) as Record<string, unknown>;
484
+ expect(first["state"]).toBe("soft-hit");
485
+ expect(first["crossed"]).toBe(true);
486
+ expect(recordedNotifications).toHaveLength(1);
487
+ expect(recordedNotifications[0]).toMatchObject({
488
+ capName: "cap-notify-mails",
489
+ value: 1100,
490
+ limit: 1000,
491
+ });
492
+
493
+ // Zweiter Aufruf in derselben Period — counter ist immer noch im
494
+ // soft-Bereich, aber lastSoftWarnedAt ist jetzt gesetzt (durch
495
+ // den dispatched mark-soft-warned-Handler). enforceCap returnt
496
+ // crossed=false, der Notifier feuert NICHT erneut.
497
+ const second = (await stack.http.writeOk(
498
+ ENFORCE_NOTIFY_PROBE_QN,
499
+ {
500
+ capName: "cap-notify-mails",
501
+ periodStartIso: NOTIFY_PERIOD,
502
+ limit: 1000,
503
+ profile: "burstable",
504
+ },
505
+ admin,
506
+ )) as Record<string, unknown>;
507
+ expect(second["state"]).toBe("soft-hit");
508
+ expect(second["crossed"]).toBe(false);
509
+ expect(recordedNotifications).toHaveLength(1); // unverändert
510
+ });
511
+
512
+ test("ok-Bereich → notifier feuert NICHT", async () => {
513
+ recordedNotifications.length = 0;
514
+ const admin = adminFor(1002);
515
+ await increment(admin, "cap-notify-quiet", 100);
516
+
517
+ const result = (await stack.http.writeOk(
518
+ ENFORCE_NOTIFY_PROBE_QN,
519
+ { capName: "cap-notify-quiet", periodStartIso: PERIOD, limit: 1000, profile: "burstable" },
520
+ admin,
521
+ )) as Record<string, unknown>;
522
+ expect(result["state"]).toBe("ok");
523
+ expect(recordedNotifications).toHaveLength(0);
524
+ });
525
+
526
+ test("hard-hit → CapExceededError, notifier feuert NICHT (throw kommt vor notify)", async () => {
527
+ recordedNotifications.length = 0;
528
+ const admin = adminFor(1003);
529
+ await increment(admin, "cap-notify-hard", 1200);
530
+
531
+ const result = (await stack.http.writeOk(
532
+ ENFORCE_NOTIFY_PROBE_QN,
533
+ { capName: "cap-notify-hard", periodStartIso: PERIOD, limit: 1000, profile: "burstable" },
534
+ admin,
535
+ )) as Record<string, unknown>;
536
+ expect(result["ok"]).toBe(false);
537
+ expect(result["code"]).toBe("cap_exceeded");
538
+ expect(recordedNotifications).toHaveLength(0);
539
+ });
540
+ });
541
+
542
+ describe("scenario 7: notification-wiring (rolling, no dedup)", () => {
543
+ test("rolling-soft-hit feuert notifier bei JEDEM Aufruf — kein lastSoftWarnedAt-tracking", async () => {
544
+ recordedNotifications.length = 0;
545
+ const admin = adminFor(1101);
546
+ await incrementRolling(admin, "cap-notify-rolling", 6000);
547
+ await incrementRolling(admin, "cap-notify-rolling", 5000);
548
+ // sum=11000, limit=10000, soft=1.1×10000=11000 → soft-hit
549
+
550
+ await stack.http.writeOk(
551
+ ENFORCE_ROLLING_NOTIFY_PROBE_QN,
552
+ { capName: "cap-notify-rolling", windowDays: 7, limit: 10000, profile: "burstable" },
553
+ admin,
554
+ );
555
+ await stack.http.writeOk(
556
+ ENFORCE_ROLLING_NOTIFY_PROBE_QN,
557
+ { capName: "cap-notify-rolling", windowDays: 7, limit: 10000, profile: "burstable" },
558
+ admin,
559
+ );
560
+
561
+ // Drift-Pin: Rolling-Counter HAT KEINEN lastSoftWarnedAt-Flag
562
+ // (kein projection-row). Wenn jemand heimlich Dedup einbaut ohne
563
+ // erst die Speicher-Story zu lösen, fällt das hier auf.
564
+ expect(recordedNotifications).toHaveLength(2);
565
+ });
566
+ });