chore: merged from main

This commit is contained in:
John Yeo
2025-11-18 13:51:40 +00:00
11 changed files with 671 additions and 4 deletions

View File

@@ -122,7 +122,7 @@ export const runRedisDeduction = async ({
overageBehavior,
eventInfo,
}: RunRedisDeductionParams): Promise<RunRedisDeductionResult> => {
const { org, env } = ctx;
const { org, env, skipCache } = ctx;
const hasContUseFeature = featureDeductions.some((deduction) =>
isContUseFeature({ feature: deduction.feature }),
@@ -144,7 +144,7 @@ export const runRedisDeduction = async ({
};
}
if (query.skip_cache) {
if (query.skip_cache || skipCache) {
return {
fallback: true,
code: "skip_cache",

View File

@@ -31,6 +31,7 @@ export const runTrack = async ({
statusCode: 400,
});
}
// ctx.skipCache = true;
const { fallback, balances } = await runRedisDeduction({
ctx,

View File

@@ -103,7 +103,7 @@ export const deductFromCusEnts = async ({
const isPaidAllocated = deductions.some((d) =>
isPaidContinuousUse({
feature: d.feature,
fullCus,
fullCus: fullCus!,
}),
);
@@ -374,6 +374,10 @@ export const runDeductionTx = async (
const result = await db.transaction(
async (tx) => {
// Set statement timeout to cancel query at database level if it takes too long
// This will automatically rollback the transaction and free the connection
await tx.execute(sql`SET LOCAL statement_timeout = '1000ms'`);
// Pass tx as the db connection
const txParams = {
...params,

View File

@@ -115,6 +115,5 @@ export const getCustomerAndProducts = async ({
}
}
}
return { customer, products };
};

0
server/src/test.ts Normal file
View File

View File

@@ -0,0 +1,62 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// const creditsFeature = constructFeatureItem({
// featureId: TestFeature.Credits,
// includedUsage: 1000,
// });
const actionFeature = constructArrearItem({
featureId: TestFeature.Action1,
includedUsage: 0,
});
const proProd = constructProduct({
type: "pro",
isDefault: false,
items: [actionFeature],
});
const testCase = "credit-systems5";
const customerId = "credit-systems5";
describe(`${chalk.yellowBright("credit-systems5: test check works with main feature in credit system (usage allowed true)")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [proProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: proProd.id,
});
});
test("should allow check when feature has usage allowed true and part of a credit system", async () => {
const checkResult = await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Action1,
required_balance: 1,
});
expect(checkResult.allowed).toBe(true);
});
});

View File

@@ -0,0 +1,277 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { timeout } from "@tests/utils/genUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "concurrent-track7";
// Product with both lifetime and monthly Messages features
const lifetimeMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 20000,
interval: null, // Lifetime
}) as LimitedItem;
const monthlyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 10000,
interval: "month" as any,
intervalCount: 1,
}) as LimitedItem;
const pro = constructProduct({
type: "free",
isDefault: false,
items: [lifetimeMessagesItem, monthlyMessagesItem],
});
const NUM_REQUESTS = 500; // Reduced from 10000 to avoid DB parameter limits
const NUM_CUSTOMERS = 3;
// Calculate total included usage dynamically
const TOTAL_INCLUDED_USAGE =
(lifetimeMessagesItem.included_usage ?? 0) +
(monthlyMessagesItem.included_usage ?? 0);
// Helper to generate random decimal between min and max using Decimal.js
const randomDecimal = (min: number, max: number): Decimal => {
const value = Math.random() * (max - min) + min;
return new Decimal(value).toDecimalPlaces(2);
};
describe(`${chalk.yellowBright(`${testCase}: Stress test with 10k concurrent requests per customer through check (send_event)`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const customerIds = Array.from(
{ length: NUM_CUSTOMERS },
(_, i) => `${testCase}_customer${i + 1}`,
);
// Store expected total usage per customer using Decimal for precision
const customerExpectedUsage: Record<string, Decimal> = {};
beforeAll(async () => {
// Initialize all customers
for (const customerId of customerIds) {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
}
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
for (const customerId of customerIds) {
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
// Initialize expected usage to 0
customerExpectedUsage[customerId] = new Decimal(0);
}
});
test("should have initial balances for all customers", async () => {
for (const customerId of customerIds) {
const customer = await autumnV1.customers.get(customerId);
console.log(`\n🔍 Initial state for ${customerId}:`);
console.log(
` Balance: ${customer.features[TestFeature.Messages].balance}`,
);
console.log(` Usage: ${customer.features[TestFeature.Messages].usage}`);
// Total balance should be lifetime + monthly
expect(customer.features[TestFeature.Messages].balance).toBe(
TOTAL_INCLUDED_USAGE,
);
expect(customer.features[TestFeature.Messages].usage).toBe(0);
expect(customer.features[TestFeature.Messages].breakdown?.length).toBe(2);
}
});
test(`should handle ${NUM_REQUESTS * NUM_CUSTOMERS} concurrent requests across ${NUM_CUSTOMERS} customers`, async () => {
console.log(
`\n🚀 Starting ${NUM_REQUESTS * NUM_CUSTOMERS} concurrent track requests...`,
);
console.log(
` ${NUM_REQUESTS} requests per customer × ${NUM_CUSTOMERS} customers`,
);
const allPromises: Promise<number>[] = [];
// Generate requests for each customer
for (const customerId of customerIds) {
const customerPromises: Promise<number>[] = [];
for (let i = 0; i < NUM_REQUESTS; i++) {
// Generate random value between 0.01 and 2.00 using Decimal
const decimalValue = randomDecimal(0.01, 2.0);
const value = decimalValue.toDecimalPlaces(5).toNumber();
// Accumulate expected usage using Decimal for precision
customerExpectedUsage[customerId] =
customerExpectedUsage[customerId].plus(decimalValue);
// Create track request for Messages feature with timing
const requestStart = Date.now();
const promise = autumnV1
.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
send_event: true,
required_balance: value,
skip_event: true, // Skip event insertion for stress test
})
.then(() => Date.now() - requestStart);
customerPromises.push(promise);
}
allPromises.push(...customerPromises);
}
// Execute all requests concurrently
const startTime = Date.now();
const durations = await Promise.all(allPromises);
const endTime = Date.now();
// Calculate P99
const sortedDurations = durations.sort((a, b) => a - b);
const p99Index = Math.floor(sortedDurations.length * 0.99);
const p99 = sortedDurations[p99Index];
console.log(
`\n✅ Completed ${NUM_REQUESTS * NUM_CUSTOMERS} requests in ${endTime - startTime}ms`,
);
console.log(
` Average: ${((endTime - startTime) / (NUM_REQUESTS * NUM_CUSTOMERS)).toFixed(2)}ms per request`,
);
console.log(` P99: ${p99.toFixed(2)}ms`);
// Log expected totals per customer
for (const customerId of customerIds) {
console.log(`\n📊 ${customerId}:`);
console.log(
` Total usage: ${customerExpectedUsage[customerId].toFixed(2)} units`,
);
}
});
test("should have correct cached balances for all customers", async () => {
for (const customerId of customerIds) {
const customer = await autumnV1.customers.get(customerId);
const totalUsage = customerExpectedUsage[customerId];
// Balance should be capped at 0 (no negative balances without overage_allowed)
const expectedBalance = Decimal.max(
0,
new Decimal(TOTAL_INCLUDED_USAGE).minus(totalUsage),
)
.toDP(5)
.toNumber();
const actualBalance = new Decimal(
customer.features[TestFeature.Messages].balance ?? 0,
)
.toDP(5)
.toNumber();
// Usage should be capped at included_usage without overage_allowed
const expectedUsage = Decimal.min(totalUsage, TOTAL_INCLUDED_USAGE)
.toDP(5)
.toNumber();
const actualUsage = new Decimal(
customer.features[TestFeature.Messages].usage ?? 0,
)
.toDP(5)
.toNumber();
// Verify balance and usage match expectations
expect(actualBalance).toEqual(expectedBalance);
expect(actualUsage).toEqual(expectedUsage);
// Verify breakdown balances sum to top-level balance
const breakdown = customer.features[TestFeature.Messages].breakdown;
if (breakdown && breakdown.length > 0) {
const breakdownBalance = breakdown.reduce(
(sum, b) => new Decimal(sum).plus(b.balance || 0).toNumber(),
0,
);
expect(new Decimal(breakdownBalance).toDP(5).toNumber()).toEqual(
actualBalance!,
);
}
}
});
test("should have correct non-cached balances for all customers after 2s", async () => {
console.log("\n⏳ Waiting 2s for DB sync...");
await timeout(5000);
for (const customerId of customerIds) {
const customer = await autumnV1.customers.get(customerId, {
skip_cache: "true",
});
const totalUsage = customerExpectedUsage[customerId];
// Balance should be capped at 0 (no negative balances without overage_allowed)
const expectedBalance = Decimal.max(
0,
new Decimal(TOTAL_INCLUDED_USAGE).minus(totalUsage),
)
.toDP(5)
.toNumber();
const actualBalance = new Decimal(
customer.features[TestFeature.Messages].balance ?? 0,
)
.toDP(5)
.toNumber();
// Usage should be capped at included_usage without overage_allowed
const expectedUsage = Decimal.min(totalUsage, TOTAL_INCLUDED_USAGE)
.toDP(5)
.toNumber();
const actualUsage = new Decimal(
customer.features[TestFeature.Messages].usage ?? 0,
)
.toDP(5)
.toNumber();
// Use Decimal for precise comparisons - expect exact match
expect(actualBalance).toEqual(expectedBalance);
// Verify usage matches - expect exact match
expect(actualUsage).toEqual(expectedUsage);
// Verify breakdown balances match top-level (lifetime + monthly)
const breakdown = customer.features[TestFeature.Messages].breakdown;
if (breakdown && breakdown.length > 0) {
const breakdownBalance = breakdown.reduce(
(sum, b) => new Decimal(sum).plus(b.balance || 0).toNumber(),
0,
);
expect(new Decimal(breakdownBalance).toDP(5).toNumber()).toEqual(
actualBalance!,
);
}
}
console.log("\n✅ All balances verified successfully!");
});
});

View File

@@ -0,0 +1,63 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type TrackResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-misc4";
describe(`${chalk.yellowBright("track-misc4: track version 1.2 response")}`, () => {
const customerId = "track-misc4";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should track version 1.2 response", async () => {
const trackRes: TrackResponseV2 = await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 20,
});
expect(trackRes).toMatchObject({
id: "placeholder",
code: "event_received",
customer_id: customerId,
feature_id: TestFeature.Messages,
// value: 20,
});
});
});

View File

@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type TrackResponseV2 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "track-negative1";
describe(`${chalk.yellowBright("track-negative1: track negative on meterd feature")}`, () => {
const customerId = "track-negative1";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should have initial balance of 100", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
expect(balance).toBe(100);
});
test("should deduct from messages with negative value", async () => {
const deductValue = -37.89;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
expect(trackRes.balance).toBeDefined();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Messages);
expect(trackRes.balance?.current_balance).toBe(100 - deductValue);
expect(trackRes.balance?.usage).toBe(deductValue);
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
expect(balance).toBe(100 - deductValue);
expect(usage).toBe(deductValue);
});
});

View File

@@ -0,0 +1,122 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomer,
ApiVersion,
type TrackResponseV2,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { timeout } from "../../../utils/genUtils";
const userItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [userItem],
});
const testCase = "track-negative2";
describe(`${chalk.yellowBright("track-negative2: track negative on free allocated feature")}`, () => {
const customerId = "track-negative2";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should have initial balance of 5", async () => {
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Users].balance;
expect(balance).toBe(5);
});
test("should track positive into 'overage' ", async () => {
const trackValue = 8;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: trackValue,
});
expect(trackRes.balance).toBeDefined();
expect(trackRes.balance).toMatchObject({
current_balance: 0,
purchased_balance: 3,
usage: trackValue,
});
});
test("should track negative and reduce purchased balance first", async () => {
const trackValue = -2;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: trackValue,
});
expect(trackRes.balance).toMatchObject({
current_balance: 0,
purchased_balance: 1,
usage: 6,
});
});
test("should track negative and reduce granted balance second", async () => {
const trackValue = -2;
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: trackValue,
});
expect(trackRes.balance).toMatchObject({
current_balance: 1,
purchased_balance: 0,
usage: 4,
});
});
test("non-cached customer should reflect changes", async () => {
await timeout(2000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(customer.balances[TestFeature.Users]).toMatchObject({
current_balance: 1,
purchased_balance: 0,
usage: 4,
});
});
});

View File

@@ -0,0 +1,62 @@
import { beforeAll, describe, test } from "bun:test";
import { type ApiProduct, ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const usageFeature = constructArrearItem({
featureId: TestFeature.Words,
includedUsage: 10,
billingUnits: 1,
price: 0.5,
});
const prepaidFeature = constructPrepaidItem({
featureId: TestFeature.Credits,
billingUnits: 150,
price: 10,
});
const pro = constructProduct({
type: "pro",
isDefault: false,
items: [messagesFeature, usageFeature, prepaidFeature],
});
const testCase = "get-plan1";
describe(`${chalk.yellowBright("get-plan1: get plan response v1.2")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
});
test("should track version 1.2 response", async () => {
const plan = (await autumnV1.products.get(pro.id)) as ApiProduct;
// 1. Check messages product item
const msgesResponseItem = plan.items.find(
(item) => item.feature_id === TestFeature.Messages,
);
console.log(msgesResponseItem);
});
});