test: 💍 fix a bug w race conditions + extra stuff
This commit is contained in:
20
server/src/external/svix/svixHelpers.ts
vendored
20
server/src/external/svix/svixHelpers.ts
vendored
@@ -43,11 +43,13 @@ export const sendSvixEvent = async ({
|
||||
eventType,
|
||||
data,
|
||||
payloadFields,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
eventType: string;
|
||||
data: unknown;
|
||||
payloadFields?: { id?: string; occurred_at?: number };
|
||||
idempotencyKey?: string;
|
||||
}) => {
|
||||
if (!process.env.SVIX_API_KEY) return;
|
||||
|
||||
@@ -60,14 +62,18 @@ export const sendSvixEvent = async ({
|
||||
const appId = getSvixAppId({ org, env });
|
||||
if (!appId) return null;
|
||||
|
||||
return await svix.message.create(appId, {
|
||||
eventType,
|
||||
payload: {
|
||||
type: eventType,
|
||||
...payloadFields,
|
||||
data,
|
||||
return await svix.message.create(
|
||||
appId,
|
||||
{
|
||||
eventType,
|
||||
payload: {
|
||||
type: eventType,
|
||||
...payloadFields,
|
||||
data,
|
||||
},
|
||||
},
|
||||
});
|
||||
idempotencyKey ? { idempotencyKey } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.logger.error(`[svix] Failed to send ${eventType}: ${error}`);
|
||||
Sentry.captureException(error, {
|
||||
|
||||
@@ -139,9 +139,22 @@ const processAlerts = async ({
|
||||
|
||||
const customerId = newFullCus.id || newFullCus.internal_id;
|
||||
|
||||
const minuteBucket = Math.floor(Date.now() / 60_000);
|
||||
const idempotencyKey = [
|
||||
ctx.org.id,
|
||||
ctx.env,
|
||||
customerId,
|
||||
entityId ?? "_",
|
||||
feature.id,
|
||||
alert.threshold_type,
|
||||
alert.threshold,
|
||||
minuteBucket,
|
||||
].join(":");
|
||||
|
||||
await sendSvixEvent({
|
||||
ctx,
|
||||
eventType: WebhookEventType.BalancesUsageAlertTriggered,
|
||||
idempotencyKey,
|
||||
data: {
|
||||
customer_id: customerId,
|
||||
feature_id: feature.id,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Replication test for duplicate balances.usage_alert_triggered webhooks
|
||||
* fired by concurrent /v1/track requests crossing the same threshold.
|
||||
*
|
||||
* Expectation: within 2 minutes, exactly 2 matching webhook events arrive.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import type { LimitedItem } from "@autumn/shared";
|
||||
import { setCustomerUsageAlerts } from "@tests/integration/balances/utils/usage-alert-utils/customerUsageAlertUtils.js";
|
||||
import {
|
||||
getPlayHistory,
|
||||
getTestSvixAppId,
|
||||
parseEventBody,
|
||||
setupWebhookTest,
|
||||
type WebhookTestSetup,
|
||||
} from "@tests/integration/utils/svixWebhookTestUtils.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
|
||||
type BalancesUsageAlertTriggeredPayload = {
|
||||
type: string;
|
||||
data: {
|
||||
customer_id: string;
|
||||
feature_id: string;
|
||||
usage_alert: {
|
||||
threshold: number;
|
||||
threshold_type: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
let webhook: WebhookTestSetup;
|
||||
let playToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
|
||||
webhook = await setupWebhookTest({
|
||||
appId,
|
||||
filterTypes: ["balances.usage_alert_triggered"],
|
||||
});
|
||||
playToken = webhook.playToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await webhook?.cleanup();
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("usage-alert race: concurrent tracks fire two webhooks crossing the same threshold")}`, async () => {
|
||||
const creditsItem = constructFeatureItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
const product = constructProduct({
|
||||
type: "pro",
|
||||
isDefault: false,
|
||||
items: [creditsItem],
|
||||
id: "ua-race-condition-pro",
|
||||
});
|
||||
|
||||
const threshold = 20;
|
||||
|
||||
const { customerId, autumnV2_1 } = await initScenario({
|
||||
customerId: "usage-alert-race-condition",
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
],
|
||||
actions: [s.attach({ productId: product.id })],
|
||||
});
|
||||
|
||||
await setCustomerUsageAlerts({
|
||||
autumn: autumnV2_1,
|
||||
customerId,
|
||||
usageAlerts: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
threshold,
|
||||
threshold_type: "remaining",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Stage balance just above the threshold so two concurrent small deductions
|
||||
// can both observe the pre-crossing snapshot and both fire the alert.
|
||||
// action1 credit_cost = 0.2 → 395 units = 79 credits used → remaining = 21.
|
||||
await autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Action1,
|
||||
value: 395,
|
||||
});
|
||||
|
||||
// Worst-case stress: many concurrent tracks crossing the SAME threshold.
|
||||
// Each value is tiny (0.5–1.0 credits) so every track on its own would cross
|
||||
// remaining=21 → below 20, meaning every handler would naively fire the alert.
|
||||
// Going negative on remaining is fine for this test — only the FIRST crossing
|
||||
// of the threshold should fire; subsequent reads see oldRemaining<20 already.
|
||||
const BURST_SIZE = 500;
|
||||
await Promise.all(
|
||||
Array.from({ length: BURST_SIZE }, (_, i) =>
|
||||
autumnV2_1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
value: 0.5 + (i % 50) * 0.01,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait long enough for any duplicate webhooks to surface, then assert exactly one.
|
||||
const SETTLE_MS = 30_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||
|
||||
const history = await getPlayHistory({ token: playToken });
|
||||
const matching = history.data
|
||||
.map((event) => parseEventBody<BalancesUsageAlertTriggeredPayload>(event))
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.type === "balances.usage_alert_triggered" &&
|
||||
payload.data?.customer_id === customerId &&
|
||||
payload.data?.feature_id === TestFeature.Credits &&
|
||||
payload.data?.usage_alert?.threshold === threshold,
|
||||
);
|
||||
|
||||
expect(matching.length).toBe(1);
|
||||
}, 150_000);
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"created_at": 1777974355227,
|
||||
"created_at": 1778167866112,
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"stripe_id": "cus_UTQkXDkzlY2hGh",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
@@ -15,12 +15,12 @@
|
||||
"group": "get-cus-multi-ent-v2",
|
||||
"status": "active",
|
||||
"canceled_at": null,
|
||||
"started_at": 1777974354000,
|
||||
"started_at": 1778167865000,
|
||||
"is_default": false,
|
||||
"is_add_on": false,
|
||||
"version": 1,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"current_period_start": 1778167865000,
|
||||
"current_period_end": 1780846265000,
|
||||
"items": [
|
||||
{
|
||||
"type": "price",
|
||||
@@ -83,7 +83,7 @@
|
||||
"group": "get-cus-multi-ent-v2",
|
||||
"status": "active",
|
||||
"canceled_at": null,
|
||||
"started_at": 1777974379436,
|
||||
"started_at": 1778167888784,
|
||||
"is_default": false,
|
||||
"is_add_on": false,
|
||||
"version": 1,
|
||||
@@ -104,7 +104,7 @@
|
||||
"balance": 90,
|
||||
"usage": 10,
|
||||
"included_usage": 100,
|
||||
"next_reset_at": 1780652754000,
|
||||
"next_reset_at": 1780846265000,
|
||||
"overage_allowed": false,
|
||||
"breakdown": [
|
||||
{
|
||||
@@ -113,7 +113,7 @@
|
||||
"balance": 90,
|
||||
"usage": 10,
|
||||
"included_usage": 100,
|
||||
"next_reset_at": 1780652754000,
|
||||
"next_reset_at": 1780846265000,
|
||||
"expires_at": null,
|
||||
"overage_allowed": false
|
||||
}
|
||||
@@ -173,12 +173,12 @@
|
||||
"product_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"stripe_id": "in_1TUTtJIidku0FvdnavizBNAV",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
"created_at": 1778167865000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"created_at": 1777974355227,
|
||||
"created_at": 1778167866112,
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"stripe_id": "cus_UTQkXDkzlY2hGh",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
"billing_controls": {},
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P",
|
||||
"id": "cus_prod_3DOzO3SlpNyeQx2sJ8ggOp0BnwJ",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -20,13 +20,13 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1777974354000,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"started_at": 1778167865000,
|
||||
"current_period_start": 1778167865000,
|
||||
"current_period_end": 1780846265000,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp",
|
||||
"id": "cus_prod_3DOzQdCIlhWL0ZqgYFizxtzScAy",
|
||||
"plan_id": "ent-prod_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -35,7 +35,7 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1777974379436,
|
||||
"started_at": 1778167888784,
|
||||
"current_period_start": null,
|
||||
"current_period_end": null,
|
||||
"quantity": 1
|
||||
@@ -52,11 +52,11 @@
|
||||
"unlimited": false,
|
||||
"overage_allowed": false,
|
||||
"max_purchase": null,
|
||||
"next_reset_at": 1780652754000,
|
||||
"next_reset_at": 1780846265000,
|
||||
"breakdown": [
|
||||
{
|
||||
"object": "balance_breakdown",
|
||||
"id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue",
|
||||
"id": "cus_ent_3DOzO7SC9PenACcAtXDG1wBJhch",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"included_grant": 100,
|
||||
"prepaid_grant": 0,
|
||||
@@ -65,7 +65,7 @@
|
||||
"unlimited": false,
|
||||
"reset": {
|
||||
"interval": "month",
|
||||
"resets_at": 1780652754000
|
||||
"resets_at": 1780846265000
|
||||
},
|
||||
"price": null,
|
||||
"expires_at": null,
|
||||
@@ -89,24 +89,30 @@
|
||||
"flags": {
|
||||
"dashboard": {
|
||||
"object": "flag",
|
||||
"id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag",
|
||||
"id": "cus_ent_3DOzO6uL1W9AZ7kC07wdNPOUKxc",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"expires_at": null,
|
||||
"feature_id": "dashboard"
|
||||
}
|
||||
},
|
||||
"config": {},
|
||||
"processors": {
|
||||
"stripe": {
|
||||
"id": "cus_UTQkXDkzlY2hGh"
|
||||
}
|
||||
},
|
||||
"invoices": [
|
||||
{
|
||||
"plan_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"stripe_id": "in_1TUTtJIidku0FvdnavizBNAV",
|
||||
"processor_type": "stripe",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
"created_at": 1778167865000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,16 +2,16 @@
|
||||
"id": "get-cus-multi-ent-v2",
|
||||
"name": "get-cus-multi-ent-v2",
|
||||
"email": "get-cus-multi-ent-v2@example.com",
|
||||
"created_at": 1777974355227,
|
||||
"created_at": 1778167866112,
|
||||
"fingerprint": null,
|
||||
"stripe_id": "cus_USajNwvM0pax2M",
|
||||
"stripe_id": "cus_UTQkXDkzlY2hGh",
|
||||
"env": "sandbox",
|
||||
"metadata": {},
|
||||
"send_email_receipts": false,
|
||||
"billing_controls": {},
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P",
|
||||
"id": "cus_prod_3DOzO3SlpNyeQx2sJ8ggOp0BnwJ",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -20,13 +20,13 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1777974354000,
|
||||
"current_period_start": 1777974354000,
|
||||
"current_period_end": 1780652754000,
|
||||
"started_at": 1778167865000,
|
||||
"current_period_start": 1778167865000,
|
||||
"current_period_end": 1780846265000,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp",
|
||||
"id": "cus_prod_3DOzQdCIlhWL0ZqgYFizxtzScAy",
|
||||
"plan_id": "ent-prod_get-cus-multi-ent-v2",
|
||||
"auto_enable": false,
|
||||
"add_on": false,
|
||||
@@ -35,7 +35,7 @@
|
||||
"canceled_at": null,
|
||||
"expires_at": null,
|
||||
"trial_ends_at": null,
|
||||
"started_at": 1777974379436,
|
||||
"started_at": 1778167888784,
|
||||
"current_period_start": null,
|
||||
"current_period_end": null,
|
||||
"quantity": 1
|
||||
@@ -52,11 +52,11 @@
|
||||
"unlimited": false,
|
||||
"overage_allowed": false,
|
||||
"max_purchase": null,
|
||||
"next_reset_at": 1780652754000,
|
||||
"next_reset_at": 1780846265000,
|
||||
"breakdown": [
|
||||
{
|
||||
"object": "balance_breakdown",
|
||||
"id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue",
|
||||
"id": "cus_ent_3DOzO7SC9PenACcAtXDG1wBJhch",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"included_grant": 100,
|
||||
"prepaid_grant": 0,
|
||||
@@ -65,7 +65,7 @@
|
||||
"unlimited": false,
|
||||
"reset": {
|
||||
"interval": "month",
|
||||
"resets_at": 1780652754000
|
||||
"resets_at": 1780846265000
|
||||
},
|
||||
"price": null,
|
||||
"expires_at": null,
|
||||
@@ -89,24 +89,30 @@
|
||||
"flags": {
|
||||
"dashboard": {
|
||||
"object": "flag",
|
||||
"id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag",
|
||||
"id": "cus_ent_3DOzO6uL1W9AZ7kC07wdNPOUKxc",
|
||||
"plan_id": "cus-lvl_get-cus-multi-ent-v2",
|
||||
"expires_at": null,
|
||||
"feature_id": "dashboard"
|
||||
}
|
||||
},
|
||||
"config": {},
|
||||
"processors": {
|
||||
"stripe": {
|
||||
"id": "cus_UTQkXDkzlY2hGh"
|
||||
}
|
||||
},
|
||||
"invoices": [
|
||||
{
|
||||
"plan_ids": [
|
||||
"cus-lvl_get-cus-multi-ent-v2"
|
||||
],
|
||||
"stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K",
|
||||
"stripe_id": "in_1TUTtJIidku0FvdnavizBNAV",
|
||||
"processor_type": "stripe",
|
||||
"status": "paid",
|
||||
"total": 20,
|
||||
"currency": "usd",
|
||||
"created_at": 1777974354000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy"
|
||||
"created_at": 1778167865000,
|
||||
"hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DOzOeMdn7GMFP4N5ZN2KoTqDi8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AggregatedFeatureBalanceSchema,
|
||||
AppEnv,
|
||||
type FullCustomer,
|
||||
FullCustomerSchema,
|
||||
ProcessorType,
|
||||
ProductSchema,
|
||||
type SubjectBalance,
|
||||
@@ -648,6 +650,276 @@ describe("sanitizeCachedFullSubject — processor_type (Option A walker behavior
|
||||
});
|
||||
expect(wire.processor_type).toBe(ProcessorType.Stripe);
|
||||
});
|
||||
|
||||
test("end-to-end pre-update rollover: cache JSON missing field → JSON.parse → walker → processInvoice → wire stripe", async () => {
|
||||
// Simulates the deploy-boundary rollover scenario:
|
||||
// 1. An invoice was cached BEFORE this Phase shipped (no
|
||||
// `processor_type` key in the JSON).
|
||||
// 2. After deploy, `getCachedFullSubject` reads that JSON, parses
|
||||
// it, and sanitizes via the walker.
|
||||
// 3. `processInvoice` then maps the sanitized row to the V5 wire
|
||||
// shape.
|
||||
// The wire MUST emit `processor_type === "stripe"` for the row to
|
||||
// remain valid in V5 consumers. Any regression in the walker's
|
||||
// ZodDefault handling OR the consumer-side `??` mask trips this test.
|
||||
const preUpdateInvoice = buildInvoice();
|
||||
delete (preUpdateInvoice as Record<string, unknown>).processor_type;
|
||||
|
||||
const cachedJson = JSON.stringify({
|
||||
...(buildCachedFullSubject() as Record<string, unknown>),
|
||||
invoices: [preUpdateInvoice],
|
||||
});
|
||||
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
expect(
|
||||
(deserialized.invoices as Record<string, unknown>[])[0]
|
||||
.processor_type,
|
||||
).toBeUndefined();
|
||||
|
||||
const sanitized = sanitizeCachedFullSubject({
|
||||
cachedFullSubject: deserialized as unknown as CachedFullSubject,
|
||||
});
|
||||
expect(sanitized.invoices[0].processor_type).toBe(ProcessorType.Stripe);
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({ invoice: sanitized.invoices[0] });
|
||||
expect(wire.processor_type).toBe(ProcessorType.Stripe);
|
||||
expect(wire.stripe_id).toBe("in_proc");
|
||||
});
|
||||
|
||||
test("end-to-end post-update revenuecat row: cache JSON with revenuecat → roundtrip → wire revenuecat", async () => {
|
||||
// Mirror of the rollover test for the post-deploy path: an RC invoice
|
||||
// is written with `processor_type: "revenuecat"`, cached as JSON,
|
||||
// then read back. The wire MUST surface "revenuecat" intact (no
|
||||
// over-correction to "stripe" by the walker, the consumer mask, or
|
||||
// processInvoice).
|
||||
const postUpdateInvoice = buildInvoice({
|
||||
processor_type: ProcessorType.RevenueCat,
|
||||
stripe_id: "rc_txn_42",
|
||||
});
|
||||
|
||||
const cachedJson = JSON.stringify({
|
||||
...(buildCachedFullSubject() as Record<string, unknown>),
|
||||
invoices: [postUpdateInvoice],
|
||||
});
|
||||
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
expect(
|
||||
(deserialized.invoices as Record<string, unknown>[])[0]
|
||||
.processor_type,
|
||||
).toBe(ProcessorType.RevenueCat);
|
||||
|
||||
const sanitized = sanitizeCachedFullSubject({
|
||||
cachedFullSubject: deserialized as unknown as CachedFullSubject,
|
||||
});
|
||||
expect(sanitized.invoices[0].processor_type).toBe(
|
||||
ProcessorType.RevenueCat,
|
||||
);
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({ invoice: sanitized.invoices[0] });
|
||||
expect(wire.processor_type).toBe(ProcessorType.RevenueCat);
|
||||
expect(wire.stripe_id).toBe("rc_txn_42");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// processor_type — FullCustomer (v1) cache rollover
|
||||
//
|
||||
// The FullCustomer cache (`getCachedFullCustomer`) uses the cacheUtils walker
|
||||
// at `server/src/utils/cacheUtils/normalizeFromSchema.ts`, NOT the FullSubject
|
||||
// sanitize walker. The cacheUtils walker has a known asymmetry: it does not
|
||||
// apply leaf-level ZodDefault for primitives (documented limitation; the
|
||||
// "ZodDefault does NOT fire on primitive leaves in cacheUtils walker" test
|
||||
// in the core walker block proves this). For pre-update cached invoices
|
||||
// missing `processor_type`, the walker leaves the field undefined and the
|
||||
// consumer-side `?? ProcessorType.Stripe` mask in `processInvoice` is what
|
||||
// makes the rollover safe.
|
||||
//
|
||||
// These tests exercise the v1 cache path end-to-end through JSON roundtrip
|
||||
// + cacheUtils walker + processInvoice, mirroring the FullSubject (v2)
|
||||
// rollover tests above.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("FullCustomer v1 cache — processor_type rollover", () => {
|
||||
const buildFullCustomer = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
internal_id: "cus_int_v1",
|
||||
org_id: "org_v1",
|
||||
env: AppEnv.Live,
|
||||
created_at: 1,
|
||||
id: "cus_v1",
|
||||
name: null,
|
||||
email: null,
|
||||
fingerprint: null,
|
||||
processor: null,
|
||||
processors: {},
|
||||
metadata: {},
|
||||
send_email_receipts: false,
|
||||
auto_topups: null,
|
||||
spend_limits: null,
|
||||
usage_alerts: null,
|
||||
overage_allowed: null,
|
||||
config: null,
|
||||
customer_products: [],
|
||||
entities: [],
|
||||
extra_customer_entitlements: [],
|
||||
invoices: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildInvoice = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
id: "inv_v1",
|
||||
created_at: 1,
|
||||
internal_customer_id: "cus_int_v1",
|
||||
internal_entity_id: null,
|
||||
product_ids: [],
|
||||
internal_product_ids: [],
|
||||
stripe_id: "in_v1",
|
||||
status: "paid",
|
||||
hosted_invoice_url: null,
|
||||
total: 100,
|
||||
amount_paid: 100,
|
||||
refunded_amount: 0,
|
||||
currency: "usd",
|
||||
discounts: [],
|
||||
items: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("pre-update rollover: cache JSON missing processor_type → cacheUtils walker → processInvoice → wire stripe", async () => {
|
||||
// Pre-deploy FullCustomer cache entry: invoice has no `processor_type`
|
||||
// key. The cacheUtils walker does NOT fill the default (orthogonal
|
||||
// bug); it leaves the field undefined. The consumer mask in
|
||||
// processInvoice converts undefined → ProcessorType.Stripe on the
|
||||
// wire. End-to-end safety relies on this two-layer behavior.
|
||||
const preUpdateInvoice = buildInvoice();
|
||||
delete (preUpdateInvoice as Record<string, unknown>).processor_type;
|
||||
|
||||
const cachedJson = JSON.stringify(
|
||||
buildFullCustomer({ invoices: [preUpdateInvoice] }),
|
||||
);
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
expect(
|
||||
(deserialized.invoices as Record<string, unknown>[])[0]
|
||||
.processor_type,
|
||||
).toBeUndefined();
|
||||
|
||||
const fullCustomer = normalizeFromSchemaCacheUtils<FullCustomer>({
|
||||
schema: FullCustomerSchema as unknown as z.ZodTypeAny,
|
||||
data: deserialized,
|
||||
});
|
||||
|
||||
expect(
|
||||
(fullCustomer.invoices ?? [])[0]?.processor_type,
|
||||
).toBeUndefined();
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({
|
||||
invoice: (fullCustomer.invoices ?? [])[0]!,
|
||||
});
|
||||
expect(wire.processor_type).toBe(ProcessorType.Stripe);
|
||||
expect(wire.stripe_id).toBe("in_v1");
|
||||
});
|
||||
|
||||
test("explicit null processor_type → cacheUtils walker passthrough → processInvoice → wire stripe", async () => {
|
||||
// Post-deploy DB row with NULL processor_type cached as JSON. The
|
||||
// cacheUtils walker passes null through unchanged. Consumer mask
|
||||
// converts null → ProcessorType.Stripe.
|
||||
const cachedJson = JSON.stringify(
|
||||
buildFullCustomer({
|
||||
invoices: [buildInvoice({ processor_type: null })],
|
||||
}),
|
||||
);
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
|
||||
const fullCustomer = normalizeFromSchemaCacheUtils<FullCustomer>({
|
||||
schema: FullCustomerSchema as unknown as z.ZodTypeAny,
|
||||
data: deserialized,
|
||||
});
|
||||
expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBeNull();
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({
|
||||
invoice: (fullCustomer.invoices ?? [])[0]!,
|
||||
});
|
||||
expect(wire.processor_type).toBe(ProcessorType.Stripe);
|
||||
});
|
||||
|
||||
test("post-update revenuecat row → cacheUtils walker preserves → wire revenuecat", async () => {
|
||||
// RC invoice cached after Phase 3 deploys. Walker passes the value
|
||||
// through unchanged; consumer mask is a no-op for defined values.
|
||||
const cachedJson = JSON.stringify(
|
||||
buildFullCustomer({
|
||||
invoices: [
|
||||
buildInvoice({
|
||||
processor_type: ProcessorType.RevenueCat,
|
||||
stripe_id: "rc_txn_v1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
|
||||
const fullCustomer = normalizeFromSchemaCacheUtils<FullCustomer>({
|
||||
schema: FullCustomerSchema as unknown as z.ZodTypeAny,
|
||||
data: deserialized,
|
||||
});
|
||||
expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBe(
|
||||
ProcessorType.RevenueCat,
|
||||
);
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({
|
||||
invoice: (fullCustomer.invoices ?? [])[0]!,
|
||||
});
|
||||
expect(wire.processor_type).toBe(ProcessorType.RevenueCat);
|
||||
expect(wire.stripe_id).toBe("rc_txn_v1");
|
||||
});
|
||||
|
||||
test("post-update stripe row with explicit processor_type → cacheUtils walker preserves → wire stripe", async () => {
|
||||
// Post-Phase-2 stripe writes set processor_type explicitly via
|
||||
// initInvoiceFromStripe. New cached entries have the field present.
|
||||
// Walker preserves; consumer mask is a no-op. Sanity check that
|
||||
// nothing over-corrects.
|
||||
const cachedJson = JSON.stringify(
|
||||
buildFullCustomer({
|
||||
invoices: [
|
||||
buildInvoice({ processor_type: ProcessorType.Stripe }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const deserialized = JSON.parse(cachedJson) as Record<string, unknown>;
|
||||
|
||||
const fullCustomer = normalizeFromSchemaCacheUtils<FullCustomer>({
|
||||
schema: FullCustomerSchema as unknown as z.ZodTypeAny,
|
||||
data: deserialized,
|
||||
});
|
||||
expect((fullCustomer.invoices ?? [])[0]?.processor_type).toBe(
|
||||
ProcessorType.Stripe,
|
||||
);
|
||||
|
||||
const { processInvoice } = await import(
|
||||
"@/internal/invoices/InvoiceService.js"
|
||||
);
|
||||
const wire = processInvoice({
|
||||
invoice: (fullCustomer.invoices ?? [])[0]!,
|
||||
});
|
||||
expect(wire.processor_type).toBe(ProcessorType.Stripe);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCachedAggregatedFeatureBalance", () => {
|
||||
|
||||
Reference in New Issue
Block a user