@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 +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
- package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +1 -1
- package/src/billing-foundation/index.ts +2 -4
- package/src/billing-foundation/subscription-tier-sync.ts +110 -83
- package/src/billing-foundation/webhook-handler.ts +117 -187
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/connect-routes.ts +218 -197
- package/src/inbound-mail-foundation/index.ts +1 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "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-
|
|
12
|
-
//
|
|
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
|
|
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 {
|
|
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-
|
|
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
|
|
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
|
|
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()
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, tierAssignmentEntity);
|
|
117
77
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
78
|
+
return stack;
|
|
79
|
+
}
|
|
118
80
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
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",
|