test: 💍 fix a bug w race conditions + extra stuff

This commit is contained in:
amianthus
2026-05-07 17:46:23 +01:00
parent 7dbe1bfcb8
commit 691be40722
7 changed files with 482 additions and 48 deletions

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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.51.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);

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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", () => {