@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.296.0",
3
+ "version": "0.299.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.296.0",
137
- "@cosmicdrift/kumiko-framework": "0.296.0",
138
- "@cosmicdrift/kumiko-headless": "0.296.0",
139
- "@cosmicdrift/kumiko-renderer": "0.296.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.296.0",
141
- "@cosmicdrift/kumiko-types": "0.296.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.299.0",
137
+ "@cosmicdrift/kumiko-framework": "0.299.0",
138
+ "@cosmicdrift/kumiko-headless": "0.299.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.299.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.299.0",
141
+ "@cosmicdrift/kumiko-types": "0.299.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.296.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.296.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.299.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.299.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -8,8 +8,8 @@
8
8
  // beweist die Foundation-eigene Verdrahtung (atomic insert + upsert,
9
9
  // Idempotency via deterministic aggregate-ids, tenant-isolation).
10
10
  //
11
- // Webhook-Handler-Factory (createSubscriptionWebhookHandler) wird in
12
- // einem separaten Test mit Hono-mock geprüft.
11
+ // Webhook-route (createSubscriptionWebhookRoute) gets its own dedicated
12
+ // coverage in webhook-handler.integration.test.ts.
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
15
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -22,7 +22,7 @@ import {
22
22
  PII_ERASED_SENTINEL,
23
23
  } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
- import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
27
27
  import {
28
28
  createTestUser,
@@ -32,7 +32,6 @@ import {
32
32
  unsafeCreateEntityTable,
33
33
  } from "@cosmicdrift/kumiko-framework/stack";
34
34
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
35
- import { Hono } from "hono";
36
35
  import {
37
36
  ComplianceProfileHandlers,
38
37
  createComplianceProfilesFeature,
@@ -53,7 +52,7 @@ import { billingFoundationFeature } from "../feature";
53
52
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
54
53
  import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
55
54
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
56
- import { createSubscriptionWebhookHandler } from "../webhook-handler";
55
+ import { createSubscriptionWebhookRoute } from "../webhook-handler";
57
56
 
58
57
  // =============================================================================
59
58
  // Mock-plugin für create-checkout-session + create-portal-session-Tests.
@@ -126,7 +125,6 @@ const mockPaymentProviderFeature = defineFeature("test-mock-payment-provider", (
126
125
 
127
126
  let stack: TestStack;
128
127
  let db: DbConnection;
129
- let paymentWebhookApp: Hono;
130
128
 
131
129
  beforeAll(async () => {
132
130
  stack = await setupTestStack({
@@ -139,6 +137,10 @@ beforeAll(async () => {
139
137
  mockProviderFeature,
140
138
  mockPaymentProviderFeature,
141
139
  ],
140
+ // Scenario 11 exercises the real payment-branch of the webhook route
141
+ // (webhook-handler.ts) mounted through the same stack — no hand-rolled
142
+ // Hono app, real HTTP via stack.app.
143
+ extraRoutes: [createSubscriptionWebhookRoute()],
142
144
  });
143
145
  db = stack.db;
144
146
  // subscriptionsProjectionTable wird von setupTestStack automatisch
@@ -150,34 +152,6 @@ beforeAll(async () => {
150
152
  // see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
151
153
  // directly and needs one configured, same as run{Prod,Dev}App do at boot.
152
154
  configurePiiSubjectKms(new InMemoryKmsAdapter());
153
-
154
- // Webhook-app for scenario 11 — same mountWebhook shape as
155
- // stripe-foundation.integration.test.ts, exercising the real
156
- // createSubscriptionWebhookHandler payment-branch (webhook-handler.ts).
157
- paymentWebhookApp = new Hono();
158
- paymentWebhookApp.post(
159
- "/api/subscription/webhook/:providerName",
160
- createSubscriptionWebhookHandler({
161
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
162
- const systemUser = createTestUser({
163
- id: 1,
164
- tenantId: tenantId as TenantId,
165
- roles: ["SystemAdmin"],
166
- });
167
- const res = await stack.http.write(handlerQn, payload, systemUser);
168
- const body = await res.json();
169
- return body.isSuccess
170
- ? { isSuccess: true, data: body.data }
171
- : { isSuccess: false, error: body.error };
172
- },
173
- resolveProvider: (providerName) => {
174
- const usage = stack.registry
175
- .getExtensionUsages("subscriptionProvider")
176
- .find((u) => u.entityName === providerName);
177
- return usage?.options as SubscriptionProviderPlugin | undefined;
178
- },
179
- }),
180
- );
181
155
  });
182
156
 
183
157
  afterAll(async () => {
@@ -993,7 +967,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
993
967
  expect(rowsB).toHaveLength(1);
994
968
  });
995
969
 
996
- test("webhook-handler payment-branch (createSubscriptionWebhookHandler): POST → row, retry POST → duplicate:true, still one row", async () => {
970
+ test("webhook-route payment-branch (createSubscriptionWebhookRoute): POST → row, retry POST → duplicate:true, still one row", async () => {
997
971
  const tenantId = testTenantId(4004);
998
972
  const body = JSON.stringify({
999
973
  providerEventId: "evt_webhook_payment_001",
@@ -1002,7 +976,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
1002
976
  priceId: "price_webhook_test",
1003
977
  });
1004
978
 
1005
- const first = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
979
+ const first = await stack.app.request("/api/subscription/webhook/mock-payment", {
1006
980
  method: "POST",
1007
981
  body,
1008
982
  });
@@ -1011,7 +985,7 @@ describe("scenario 11: one-off payment — own aggregate, own read_payments-row"
1011
985
  expect(firstBody["processed"]).toBe(true);
1012
986
  expect(firstBody["duplicate"]).toBe(false);
1013
987
 
1014
- const second = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
988
+ const second = await stack.app.request("/api/subscription/webhook/mock-payment", {
1015
989
  method: "POST",
1016
990
  body,
1017
991
  });
@@ -1,28 +1,28 @@
1
- // Integration test for createSubscriptionTierSync()'s onSyncError option
2
- // (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
3
- // only the tier-engine handlers (the primary billing-foundation writes run
4
- // for real against the test DB) — proving that "log" (default) still
5
- // answers the webhook successfully, while "fail-webhook" surfaces the sync
6
- // error all the way into the HTTP response.
1
+ // Integration test for createSubscriptionTierSync() (kumiko-framework
2
+ // #3050 rewrite). No stubbed dispatchSystemWrite anymore — the sync reads
3
+ // and writes through the real dispatcher via the extraRoute's
4
+ // dispatchSystemQuery/dispatchSystemWrite, exercised with real HTTP.
5
+ //
6
+ // `createWebhookRoute()` has a fixed path (SUBSCRIPTION_WEBHOOK_PATH), so
7
+ // the two onSyncError modes ("log" vs "fail-webhook") each need their own
8
+ // stack — you can't mount the same path twice on one Hono app.
9
+ //
10
+ // The forced sync failure is a REAL one: a stack that never mounts
11
+ // tier-engine, so `TierEngineQueries.list` resolves to no handler and
12
+ // dispatchSystemQuery throws — exactly what a consumer forgetting to mount
13
+ // tier-engine would hit in production.
7
14
 
8
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
16
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
10
- import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
11
- import {
12
- defineFeature,
13
- type EntityDefinition,
14
- type TenantId,
15
- } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
16
18
  import {
17
19
  createTestUser,
18
20
  setupTestStack,
19
21
  type TestStack,
20
22
  testTenantId,
21
23
  unsafeCreateEntityTable,
22
- unsafePushTables,
23
24
  } from "@cosmicdrift/kumiko-framework/stack";
24
25
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
25
- import { Hono } from "hono";
26
26
  import {
27
27
  createComplianceProfilesFeature,
28
28
  tenantComplianceProfileEntity,
@@ -32,16 +32,12 @@ import { createTenantFeature } from "../../tenant/feature";
32
32
  import { tenantEntity } from "../../tenant/schema/tenant";
33
33
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
34
  import type { TierMap } from "../../tier-engine/compose-app";
35
- import { TierEngineHandlers } from "../../tier-engine/constants";
35
+ import { TierEngineQueries } from "../../tier-engine/constants";
36
36
  import { tierAssignmentEntity } from "../../tier-engine/entity";
37
37
  import { createTierEngineFeature } from "../../tier-engine/feature";
38
38
  import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
39
39
  import { billingFoundationFeature } from "../feature";
40
- import {
41
- createSubscriptionTierSync,
42
- SUBSCRIPTION_WEBHOOK_PATH,
43
- type SystemWriteResult,
44
- } from "../subscription-tier-sync";
40
+ import { createSubscriptionTierSync } from "../subscription-tier-sync";
45
41
  import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
46
42
 
47
43
  type TestTier = "free" | "pro";
@@ -51,98 +47,42 @@ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
51
47
  pro: { features: [], caps: { maxItems: 100 } },
52
48
  };
53
49
 
54
- const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
55
- r.requires("billing-foundation");
56
- const plugin: SubscriptionProviderPlugin = {
57
- verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
58
- };
59
- r.useExtension("subscriptionProvider", "mock", plugin);
60
- });
61
-
62
- // Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
63
- // concrete field types to EntityDefinition (generic factory), which TS
64
- // can't structurally widen to on its own.
65
- const tierAssignmentTable = buildEntityTable(
66
- "tier-assignment",
67
- tierAssignmentEntity,
68
- ) as unknown as EntityTable<EntityDefinition>;
69
-
70
- let stack: TestStack;
71
- let forceTierSyncFailure = false;
72
-
73
- async function dispatchSystemWrite(args: {
74
- readonly handlerQn: string;
75
- readonly payload: unknown;
76
- readonly tenantId: TenantId;
77
- }): Promise<SystemWriteResult> {
78
- if (
79
- forceTierSyncFailure &&
80
- (args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
81
- ) {
82
- return {
83
- isSuccess: false,
84
- error: { code: "test_forced_failure", message: "forced tier-sync failure" },
50
+ function mockProviderFeature() {
51
+ return defineFeature("test-mock-tier-sync-provider", (r) => {
52
+ r.requires("billing-foundation");
53
+ const plugin: SubscriptionProviderPlugin = {
54
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
85
55
  };
86
- }
87
- const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
88
- const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
89
- const body = (await res.json()) as {
90
- isSuccess: boolean;
91
- data?: unknown;
92
- error?: { readonly code?: string; readonly message?: string };
93
- };
94
- return body.isSuccess
95
- ? { isSuccess: true, data: body.data }
96
- : { isSuccess: false, error: body.error };
56
+ r.useExtension("subscriptionProvider", "mock", plugin);
57
+ });
97
58
  }
98
59
 
99
- let logApp: Hono;
100
- let failApp: Hono;
101
-
102
- beforeAll(async () => {
103
- stack = await setupTestStack({
104
- features: [
105
- createConfigFeature(),
106
- createTenantFeature(),
107
- createComplianceProfilesFeature(),
108
- createTenantLifecycleFeature(),
109
- billingFoundationFeature,
110
- createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
111
- mockProviderFeature,
112
- ],
113
- });
60
+ const sharedBaseFeatures = () => [
61
+ createConfigFeature(),
62
+ createTenantFeature(),
63
+ createComplianceProfilesFeature(),
64
+ createTenantLifecycleFeature(),
65
+ billingFoundationFeature,
66
+ mockProviderFeature(),
67
+ ];
68
+
69
+ async function bootStack(
70
+ features: ReturnType<typeof sharedBaseFeatures>,
71
+ extraRoutes: NonNullable<Parameters<typeof setupTestStack>[0]["extraRoutes"]>,
72
+ ): Promise<TestStack> {
73
+ const stack = await setupTestStack({ features, extraRoutes });
114
74
  await unsafeCreateEntityTable(stack.db, tenantEntity);
115
75
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
116
- await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
76
+ await unsafeCreateEntityTable(stack.db, tierAssignmentEntity);
117
77
  configurePiiSubjectKms(new InMemoryKmsAdapter());
78
+ return stack;
79
+ }
118
80
 
119
- const sharedDeps = {
120
- db: stack.db,
121
- registry: stack.registry,
122
- dispatchSystemWrite,
123
- tierAssignmentTable,
124
- isTierName,
125
- defaultTier: "free" as const,
126
- };
127
-
128
- logApp = new Hono();
129
- createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
130
-
131
- failApp = new Hono();
132
- createSubscriptionTierSync<TestTier>({
133
- ...sharedDeps,
134
- onSyncError: "fail-webhook",
135
- }).wireSubscriptionWebhookRoute(failApp);
136
- });
137
-
138
- afterAll(async () => {
139
- await stack.cleanup();
140
- resetPiiSubjectKmsForTests();
141
- });
142
-
143
- const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
144
-
145
- function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
81
+ function buildEvent(
82
+ tenantId: string,
83
+ providerEventId: string,
84
+ overrides: Partial<SubscriptionEvent> = {},
85
+ ): SubscriptionEvent {
146
86
  return {
147
87
  providerEventId,
148
88
  providerName: "mock",
@@ -154,33 +94,133 @@ function buildEvent(tenantId: string, providerEventId: string): SubscriptionEven
154
94
  tier: "pro",
155
95
  currentPeriodEnd: "2026-06-01T00:00:00Z",
156
96
  rawPayload: "{}",
97
+ ...overrides,
157
98
  };
158
99
  }
159
100
 
101
+ async function postWebhook(stack: TestStack, path: string, event: SubscriptionEvent) {
102
+ return stack.app.request(path.replace(":providerName", "mock"), {
103
+ method: "POST",
104
+ body: JSON.stringify(event),
105
+ });
106
+ }
107
+
108
+ // =============================================================================
109
+ // onSyncError — tier-engine deliberately NOT mounted, so the sync's
110
+ // dispatchSystemQuery(TierEngineQueries.list) throws a real NotFoundError.
111
+ // =============================================================================
112
+
160
113
  describe("createSubscriptionTierSync onSyncError", () => {
161
- test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
162
- forceTierSyncFailure = true;
163
- const tenantId = testTenantId(9001);
164
- const res = await logApp.request(webhookPath, {
165
- method: "POST",
166
- body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
114
+ let logStack: TestStack;
115
+ let failStack: TestStack;
116
+ let logPath: string;
117
+ let failPath: string;
118
+
119
+ beforeAll(async () => {
120
+ const logSync = createSubscriptionTierSync<TestTier>({ isTierName, defaultTier: "free" });
121
+ const logRoute = logSync.createWebhookRoute();
122
+ logStack = await bootStack(sharedBaseFeatures(), [logRoute]);
123
+ logPath = logRoute.path;
124
+
125
+ const failSync = createSubscriptionTierSync<TestTier>({
126
+ isTierName,
127
+ defaultTier: "free",
128
+ onSyncError: "fail-webhook",
167
129
  });
168
- forceTierSyncFailure = false;
130
+ const failRoute = failSync.createWebhookRoute();
131
+ failStack = await bootStack(sharedBaseFeatures(), [failRoute]);
132
+ failPath = failRoute.path;
133
+ });
134
+
135
+ afterAll(async () => {
136
+ await logStack.cleanup();
137
+ await failStack.cleanup();
138
+ resetPiiSubjectKmsForTests();
139
+ });
169
140
 
141
+ test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
142
+ const tenantId = testTenantId(9001);
143
+ const res = await postWebhook(logStack, logPath, buildEvent(tenantId, "evt_log_1"));
170
144
  expect(res.status).toBe(200);
171
145
  });
172
146
 
173
147
  test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
174
- forceTierSyncFailure = true;
175
148
  const tenantId = testTenantId(9002);
176
- const res = await failApp.request(webhookPath, {
177
- method: "POST",
178
- body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
179
- });
180
- forceTierSyncFailure = false;
181
-
149
+ const res = await postWebhook(failStack, failPath, buildEvent(tenantId, "evt_fail_1"));
182
150
  expect(res.status).toBe(500);
183
151
  const body = (await res.json()) as { error: { code: string } };
184
152
  expect(body.error.code).toBe("subscription_webhook_processing_failed");
185
153
  });
186
154
  });
155
+
156
+ // =============================================================================
157
+ // Happy path — tier-engine mounted, tier-sync effect visible through
158
+ // TierEngineQueries.list.
159
+ // =============================================================================
160
+
161
+ describe("createSubscriptionTierSync — tier-sync effect", () => {
162
+ let stack: TestStack;
163
+ let webhookPath: string;
164
+
165
+ beforeAll(async () => {
166
+ const sync = createSubscriptionTierSync<TestTier>({ isTierName, defaultTier: "free" });
167
+ const route = sync.createWebhookRoute();
168
+ webhookPath = route.path;
169
+ stack = await bootStack(
170
+ [
171
+ ...sharedBaseFeatures(),
172
+ createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
173
+ ],
174
+ [route],
175
+ );
176
+ });
177
+
178
+ afterAll(async () => {
179
+ await stack.cleanup();
180
+ resetPiiSubjectKmsForTests();
181
+ });
182
+
183
+ test("active subscription → tier-assignment created with the subscription's tier", async () => {
184
+ const tenantId = testTenantId(9101);
185
+ const res = await postWebhook(
186
+ stack,
187
+ webhookPath,
188
+ buildEvent(tenantId, "evt_create_1", { status: SubscriptionStatuses.active, tier: "pro" }),
189
+ );
190
+ expect(res.status).toBe(200);
191
+
192
+ const admin = createTestUser({ id: 9101, tenantId, roles: ["SystemAdmin"] });
193
+ const listed = (await stack.http.queryOk(TierEngineQueries.list, {}, admin)) as {
194
+ rows: Array<Record<string, unknown>>;
195
+ };
196
+ expect(listed.rows).toHaveLength(1);
197
+ expect(listed.rows[0]?.["tier"]).toBe("pro");
198
+ });
199
+
200
+ test("subsequent canceled event → tier-assignment updated to the default tier", async () => {
201
+ const tenantId = testTenantId(9102);
202
+ await postWebhook(
203
+ stack,
204
+ webhookPath,
205
+ buildEvent(tenantId, "evt_create_2", { status: SubscriptionStatuses.active, tier: "pro" }),
206
+ );
207
+
208
+ const res = await postWebhook(
209
+ stack,
210
+ webhookPath,
211
+ buildEvent(tenantId, "evt_cancel_2", {
212
+ type: SubscriptionEventTypes.canceled,
213
+ status: SubscriptionStatuses.canceled,
214
+ tier: "pro",
215
+ }),
216
+ );
217
+ expect(res.status).toBe(200);
218
+
219
+ const admin = createTestUser({ id: 9102, tenantId, roles: ["SystemAdmin"] });
220
+ const listed = (await stack.http.queryOk(TierEngineQueries.list, {}, admin)) as {
221
+ rows: Array<Record<string, unknown>>;
222
+ };
223
+ expect(listed.rows).toHaveLength(1);
224
+ expect(listed.rows[0]?.["tier"]).toBe("free");
225
+ });
226
+ });
@@ -0,0 +1,174 @@
1
+ // Integration-tests for createSubscriptionWebhookRoute (kumiko-framework
2
+ // #3050). No injected dispatchWrite/resolveProvider stubs anymore — the
3
+ // route is a real `entry:"signature"` extraRoute mounted through
4
+ // setupTestStack, driven with real HTTP against the real dispatcher +
5
+ // registry (never createTestDispatcher, per project rule).
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ setupTestStack,
11
+ type TestStack,
12
+ testTenantId,
13
+ unsafeCreateEntityTable,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import {
16
+ createComplianceProfilesFeature,
17
+ tenantComplianceProfileEntity,
18
+ } from "../../compliance-profiles";
19
+ import { createConfigFeature } from "../../config";
20
+ import { createTenantFeature } from "../../tenant/feature";
21
+ import { tenantEntity } from "../../tenant/schema/tenant";
22
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
23
+ import {
24
+ SubscriptionEventTypes,
25
+ type SubscriptionStatus,
26
+ SubscriptionStatuses,
27
+ } from "../constants";
28
+ import { billingFoundationFeature } from "../feature";
29
+ import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
30
+ import { createSubscriptionWebhookRoute } from "../webhook-handler";
31
+
32
+ const stripeLikePlugin: SubscriptionProviderPlugin = {
33
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent | null,
34
+ };
35
+ const paypalLikePlugin: SubscriptionProviderPlugin = {
36
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent | null,
37
+ };
38
+
39
+ // Test-mock-provider whose `verifyAndParseWebhook` is swapped per test via
40
+ // `resolvedPlugin` — lets each scenario control verify()'s throw/return
41
+ // behavior without touching the wired extraRoute.
42
+ const mockProviderFeature = defineFeature("test-mock-webhook-provider", (r) => {
43
+ r.requires("billing-foundation");
44
+ r.useExtension("subscriptionProvider", "stripe", stripeLikePlugin);
45
+ r.useExtension("subscriptionProvider", "paypal", paypalLikePlugin);
46
+ });
47
+
48
+ let stack: TestStack;
49
+
50
+ beforeAll(async () => {
51
+ stack = await setupTestStack({
52
+ features: [
53
+ createConfigFeature(),
54
+ createTenantFeature(),
55
+ createComplianceProfilesFeature(),
56
+ createTenantLifecycleFeature(),
57
+ billingFoundationFeature,
58
+ mockProviderFeature,
59
+ ],
60
+ extraRoutes: [createSubscriptionWebhookRoute()],
61
+ });
62
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
64
+ });
65
+
66
+ afterAll(async () => {
67
+ await stack.cleanup();
68
+ });
69
+
70
+ function buildEvent(overrides: Partial<SubscriptionEvent> = {}): SubscriptionEvent {
71
+ return {
72
+ providerEventId: "evt_test_001",
73
+ providerName: "stripe",
74
+ type: SubscriptionEventTypes.created,
75
+ tenantId: testTenantId(5001),
76
+ providerCustomerId: "cus_test",
77
+ providerSubscriptionId: "sub_test",
78
+ status: SubscriptionStatuses.active,
79
+ tier: "pro",
80
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
81
+ rawPayload: '{"raw":"payload"}',
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ async function postWebhook(providerName: string, body: unknown) {
87
+ return stack.app.request(`/api/subscription/webhook/${providerName}`, {
88
+ method: "POST",
89
+ body: JSON.stringify(body),
90
+ headers: { "stripe-signature": "test_sig" },
91
+ });
92
+ }
93
+
94
+ describe("webhook-handler — happy path", () => {
95
+ test("verifyAndParseWebhook → SubscriptionEvent → process-event write → 200 processed", async () => {
96
+ const res = await postWebhook("stripe", buildEvent({ tenantId: testTenantId(5002) }));
97
+ expect(res.status).toBe(200);
98
+ const body = (await res.json()) as { processed: boolean; duplicate: boolean };
99
+ expect(body.processed).toBe(true);
100
+ expect(body.duplicate).toBe(false);
101
+ });
102
+
103
+ test("plugin returns null (unknown event-type) → 200 ignored", async () => {
104
+ const res = await stack.app.request("/api/subscription/webhook/stripe", {
105
+ method: "POST",
106
+ body: "null",
107
+ headers: { "stripe-signature": "test_sig" },
108
+ });
109
+ expect(res.status).toBe(200);
110
+ const body = (await res.json()) as { ignored: boolean };
111
+ expect(body.ignored).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("webhook-handler — error paths", () => {
116
+ test("provider not registered → 404 subscription_provider_not_registered", async () => {
117
+ const res = await postWebhook("ghost-provider", buildEvent());
118
+ expect(res.status).toBe(404);
119
+ const body = (await res.json()) as { error: { code: string; message: string } };
120
+ expect(body.error.code).toBe("subscription_provider_not_registered");
121
+ expect(body.error.message).toMatch(/ghost-provider/);
122
+ });
123
+
124
+ test("verify() throws (sig mismatch) → 401 extra_route_signature_invalid", async () => {
125
+ const res = await stack.app.request("/api/subscription/webhook/stripe", {
126
+ method: "POST",
127
+ body: "not valid json — forces JSON.parse to throw inside verify()",
128
+ headers: { "stripe-signature": "test_sig" },
129
+ });
130
+ expect(res.status).toBe(401);
131
+ const body = (await res.json()) as { error: { code: string } };
132
+ expect(body.error.code).toBe("extra_route_signature_invalid");
133
+ });
134
+
135
+ test("process-event write rejected by schema → 500 subscription_webhook_processing_failed", async () => {
136
+ // status outside the enum trips processEventSchema validation inside
137
+ // process-event.write.ts, so dispatchSystemWrite returns isSuccess:false
138
+ // — a real dispatch failure, not a stubbed one.
139
+ const res = await postWebhook(
140
+ "stripe",
141
+ // @cast-boundary test-only invalid input — deliberately outside the
142
+ // SubscriptionStatus enum to trip processEventSchema validation.
143
+ buildEvent({
144
+ tenantId: testTenantId(5003),
145
+ status: "not-a-real-status" as SubscriptionStatus,
146
+ }),
147
+ );
148
+ expect(res.status).toBe(500);
149
+ const body = (await res.json()) as {
150
+ error: { code: string; message: string; details: unknown };
151
+ };
152
+ expect(body.error.code).toBe("subscription_webhook_processing_failed");
153
+ });
154
+ });
155
+
156
+ describe("webhook-handler — multi-provider mounting", () => {
157
+ test("two different path-segments resolve to two different plugins", async () => {
158
+ const stripeRes = await postWebhook(
159
+ "stripe",
160
+ buildEvent({ tenantId: testTenantId(5004), providerEventId: "evt_multi_stripe" }),
161
+ );
162
+ const paypalRes = await postWebhook(
163
+ "paypal",
164
+ buildEvent({
165
+ tenantId: testTenantId(5005),
166
+ providerEventId: "evt_multi_paypal",
167
+ providerName: "paypal",
168
+ }),
169
+ );
170
+
171
+ expect(stripeRes.status).toBe(200);
172
+ expect(paypalRes.status).toBe(200);
173
+ });
174
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.298.0",
4
+ "type": "breaking",
5
+ "title": "Billing webhook wiring moves from a raw handler to createSubscriptionWebhookRoute()/createWebhookRoute()",
6
+ "migration": "createSubscriptionWebhookHandler(...) is replaced by createSubscriptionWebhookRoute({ path?, afterDispatch? }), which returns an ExtraRouteDefinition for the extraRoutes array instead of a manually-mounted handler; the route path still defaults to /webhooks/subscription/:providerName. createSubscriptionTierSync(...) deps drop db, registry, dispatchSystemWrite and tierAssignmentTable - it now exposes .createWebhookRoute(), which produces the signature-verified ExtraRouteDefinition to add to extraRoutes. A webhook previously wired as extraRoutes: (app, deps) => { app.post(\"/webhooks/subscription/:providerName\", createSubscriptionWebhookHandler(...)) } becomes extraRoutes: [createSubscriptionTierSync({ ... }).createWebhookRoute()] (or createSubscriptionWebhookRoute({...}) for the lower-level handler), with db/registry/dispatchSystemWrite no longer passed in - the route's signature-entry deps (systemQuery, dispatchSystemWrite, dispatchSystemQuery) cover the read/write access the old handler needed."
7
+ },
2
8
  {
3
9
  "version": "0.288.0",
4
10
  "type": "improvement",