@cosmicdrift/kumiko-bundled-features 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,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
|
+
});
|