test: add vite unit test action and remove from .gitignore
This commit is contained in:
25
.github/workflows/vite-unit-tests.yml
vendored
Normal file
25
.github/workflows/vite-unit-tests.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Vite Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun test tests/
|
||||
working-directory: vite
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ supabase.sh
|
||||
**/.env*
|
||||
tests/
|
||||
!server/tests
|
||||
!vite/tests
|
||||
.secrets
|
||||
|
||||
# **/.npmrc
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type ProductV2, UsageModel } from "@autumn/shared";
|
||||
import { buildAttachRequestBody } from "@/components/forms/attach-v2/hooks/useAttachRequestBody";
|
||||
|
||||
function makeProduct({ items }: { items: ProductV2["items"] }): ProductV2 {
|
||||
return {
|
||||
id: "prod_test",
|
||||
name: "Test Product",
|
||||
is_add_on: false,
|
||||
is_default: false,
|
||||
version: 1,
|
||||
group: null,
|
||||
env: "sandbox" as any,
|
||||
items,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
customerId: "cus_123",
|
||||
entityId: undefined,
|
||||
items: null,
|
||||
version: undefined,
|
||||
trialLength: null,
|
||||
trialDuration: "day" as const,
|
||||
trialEnabled: false,
|
||||
trialCardRequired: false,
|
||||
planSchedule: null,
|
||||
billingBehavior: null,
|
||||
newBillingSubscription: false,
|
||||
discounts: [],
|
||||
};
|
||||
|
||||
describe("buildAttachRequestBody — billing_units handling", () => {
|
||||
test("should pass display quantities through as-is, not multiply by billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
product,
|
||||
prepaidOptions: { messages: 5000 },
|
||||
});
|
||||
|
||||
expect(result?.options).toEqual([
|
||||
{ feature_id: "messages", quantity: 5000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should not divide display quantities by billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
product,
|
||||
prepaidOptions: { messages: 5000 },
|
||||
});
|
||||
|
||||
// Must NOT be 5 (5000 / 1000)
|
||||
expect(result?.options?.[0]?.quantity).not.toBe(5);
|
||||
// Must NOT be 5,000,000 (5000 * 1000)
|
||||
expect(result?.options?.[0]?.quantity).not.toBe(5000000);
|
||||
expect(result?.options?.[0]?.quantity).toBe(5000);
|
||||
});
|
||||
|
||||
test("should handle multiple prepaid features with different billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
{
|
||||
feature_id: "tokens",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
product,
|
||||
prepaidOptions: { messages: 10000, tokens: 2500 },
|
||||
});
|
||||
|
||||
expect(result?.options).toEqual([
|
||||
{ feature_id: "messages", quantity: 10000 },
|
||||
{ feature_id: "tokens", quantity: 2500 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should omit options when prepaidOptions is empty", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
product,
|
||||
prepaidOptions: {},
|
||||
});
|
||||
|
||||
expect(result?.options).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return null when customerId is missing", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
customerId: undefined,
|
||||
product,
|
||||
prepaidOptions: { messages: 5000 },
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when product is missing", () => {
|
||||
const result = buildAttachRequestBody({
|
||||
...baseParams,
|
||||
product: undefined,
|
||||
prepaidOptions: { messages: 5000 },
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
CouponDurationType,
|
||||
type Reward,
|
||||
type RewardProgram,
|
||||
RewardType,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
buildDiscountOptions,
|
||||
filterDiscountRewards,
|
||||
formatCouponDiscount,
|
||||
rewardToOption,
|
||||
stripeCouponToOption,
|
||||
} from "@/components/forms/attach-v2/utils/discountOptionUtils";
|
||||
|
||||
function makeReward(overrides: Partial<Reward> & { id: string }): Reward {
|
||||
return {
|
||||
name: null,
|
||||
promo_codes: [],
|
||||
type: RewardType.PercentageDiscount,
|
||||
free_product_id: null,
|
||||
discount_config: null,
|
||||
free_product_config: null,
|
||||
internal_id: `rew_${overrides.id}`,
|
||||
org_id: "org_test",
|
||||
env: "sandbox",
|
||||
created_at: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStripeCoupon(
|
||||
overrides: Partial<Stripe.Coupon> & { id: string },
|
||||
): Stripe.Coupon {
|
||||
return {
|
||||
object: "coupon",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
name: null,
|
||||
percent_off: null,
|
||||
amount_off: null,
|
||||
currency: null,
|
||||
duration: "once",
|
||||
duration_in_months: null,
|
||||
valid: true,
|
||||
max_redemptions: null,
|
||||
redeem_by: null,
|
||||
times_redeemed: 0,
|
||||
...overrides,
|
||||
} as Stripe.Coupon;
|
||||
}
|
||||
|
||||
function makeRewardProgram(
|
||||
overrides: Partial<RewardProgram> & {
|
||||
internal_reward_id: string;
|
||||
product_ids: string[];
|
||||
},
|
||||
): RewardProgram {
|
||||
return {
|
||||
internal_id: `rp_${overrides.internal_reward_id}`,
|
||||
org_id: "org_test",
|
||||
env: "sandbox",
|
||||
created_at: Date.now(),
|
||||
...overrides,
|
||||
} as RewardProgram;
|
||||
}
|
||||
|
||||
describe("filterDiscountRewards", () => {
|
||||
test("should keep percentage and fixed discount rewards", () => {
|
||||
const rewards = [
|
||||
makeReward({ id: "pct", type: RewardType.PercentageDiscount }),
|
||||
makeReward({ id: "fix", type: RewardType.FixedDiscount }),
|
||||
];
|
||||
const result = filterDiscountRewards(rewards);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("should filter out free product and invoice credits rewards", () => {
|
||||
const rewards = [
|
||||
makeReward({ id: "pct", type: RewardType.PercentageDiscount }),
|
||||
makeReward({ id: "free", type: RewardType.FreeProduct }),
|
||||
makeReward({ id: "credits", type: RewardType.InvoiceCredits }),
|
||||
];
|
||||
const result = filterDiscountRewards(rewards);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("pct");
|
||||
});
|
||||
|
||||
test("should return empty array when no discount rewards exist", () => {
|
||||
const rewards = [makeReward({ id: "free", type: RewardType.FreeProduct })];
|
||||
expect(filterDiscountRewards(rewards)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewardToOption", () => {
|
||||
test("should use reward name as label", () => {
|
||||
const reward = makeReward({ id: "r1", name: "20% Off" });
|
||||
const option = rewardToOption(reward);
|
||||
expect(option).toEqual({
|
||||
id: "r1",
|
||||
label: "20% Off",
|
||||
sublabel: undefined,
|
||||
source: "autumn",
|
||||
});
|
||||
});
|
||||
|
||||
test("should fall back to id when name is null", () => {
|
||||
const reward = makeReward({ id: "r1", name: null });
|
||||
expect(rewardToOption(reward).label).toBe("r1");
|
||||
});
|
||||
|
||||
test("should include first promo code as sublabel", () => {
|
||||
const reward = makeReward({
|
||||
id: "r1",
|
||||
name: "Discount",
|
||||
promo_codes: [{ code: "SAVE20" }, { code: "SAVE30" }],
|
||||
});
|
||||
expect(rewardToOption(reward).sublabel).toBe("SAVE20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCouponDiscount", () => {
|
||||
test("should format percent_off", () => {
|
||||
const coupon = makeStripeCoupon({ id: "c1", percent_off: 25 });
|
||||
expect(formatCouponDiscount(coupon)).toBe("25% off");
|
||||
});
|
||||
|
||||
test("should format amount_off in cents to currency display", () => {
|
||||
const coupon = makeStripeCoupon({
|
||||
id: "c1",
|
||||
amount_off: 1000,
|
||||
currency: "usd",
|
||||
});
|
||||
expect(formatCouponDiscount(coupon)).toBe("10 USD off");
|
||||
});
|
||||
|
||||
test("should default to USD when no currency", () => {
|
||||
const coupon = makeStripeCoupon({ id: "c1", amount_off: 500 });
|
||||
expect(formatCouponDiscount(coupon)).toBe("5 USD off");
|
||||
});
|
||||
|
||||
test("should return empty string when no discount values set", () => {
|
||||
const coupon = makeStripeCoupon({ id: "c1" });
|
||||
expect(formatCouponDiscount(coupon)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripeCouponToOption", () => {
|
||||
test("should use coupon name as label", () => {
|
||||
const coupon = makeStripeCoupon({
|
||||
id: "c1",
|
||||
name: "Holiday Sale",
|
||||
percent_off: 15,
|
||||
});
|
||||
const option = stripeCouponToOption(coupon);
|
||||
expect(option).toEqual({
|
||||
id: "c1",
|
||||
label: "Holiday Sale",
|
||||
sublabel: "15% off",
|
||||
source: "stripe",
|
||||
});
|
||||
});
|
||||
|
||||
test("should fall back to id when name is null", () => {
|
||||
const coupon = makeStripeCoupon({ id: "c1", name: null });
|
||||
expect(stripeCouponToOption(coupon).label).toBe("c1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDiscountOptions", () => {
|
||||
test("should return autumn rewards mapped to options", () => {
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "r1",
|
||||
name: "10% Off",
|
||||
type: RewardType.PercentageDiscount,
|
||||
discount_config: {
|
||||
discount_value: 10,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms: [],
|
||||
stripeCoupons: [],
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: "r1",
|
||||
label: "10% Off",
|
||||
sublabel: undefined,
|
||||
source: "autumn",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return stripe-only coupons mapped to options", () => {
|
||||
const stripeCoupons = [
|
||||
makeStripeCoupon({ id: "sc1", name: "Stripe Deal", percent_off: 20 }),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards: [],
|
||||
rewardPrograms: [],
|
||||
stripeCoupons,
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: "sc1",
|
||||
label: "Stripe Deal",
|
||||
sublabel: "20% off",
|
||||
source: "stripe",
|
||||
});
|
||||
});
|
||||
|
||||
test("should deduplicate stripe coupons that match autumn reward IDs", () => {
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "shared_id",
|
||||
name: "Autumn Discount",
|
||||
type: RewardType.PercentageDiscount,
|
||||
discount_config: {
|
||||
discount_value: 10,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
const stripeCoupons = [
|
||||
makeStripeCoupon({
|
||||
id: "shared_id",
|
||||
name: "Same Coupon In Stripe",
|
||||
percent_off: 10,
|
||||
}),
|
||||
makeStripeCoupon({
|
||||
id: "stripe_only",
|
||||
name: "Stripe Only",
|
||||
percent_off: 30,
|
||||
}),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms: [],
|
||||
stripeCoupons,
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
// First should be the Autumn reward version
|
||||
expect(result[0].id).toBe("shared_id");
|
||||
expect(result[0].source).toBe("autumn");
|
||||
// Second should be the stripe-only coupon
|
||||
expect(result[1].id).toBe("stripe_only");
|
||||
expect(result[1].source).toBe("stripe");
|
||||
});
|
||||
|
||||
test("should deduplicate against all autumn rewards, not just discount-type ones", () => {
|
||||
// A free product reward that shares an ID with a Stripe coupon should still deduplicate
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "free_reward_id",
|
||||
type: RewardType.FreeProduct,
|
||||
}),
|
||||
];
|
||||
const stripeCoupons = [
|
||||
makeStripeCoupon({
|
||||
id: "free_reward_id",
|
||||
name: "Coupon",
|
||||
percent_off: 5,
|
||||
}),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms: [],
|
||||
stripeCoupons,
|
||||
productId: undefined,
|
||||
});
|
||||
// Free product reward is filtered from autumn options, AND
|
||||
// the stripe coupon with the same ID is deduped away
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should filter non-discount reward types from autumn options", () => {
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "discount",
|
||||
type: RewardType.PercentageDiscount,
|
||||
discount_config: {
|
||||
discount_value: 10,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: true,
|
||||
},
|
||||
}),
|
||||
makeReward({ id: "free", type: RewardType.FreeProduct }),
|
||||
makeReward({ id: "credits", type: RewardType.InvoiceCredits }),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms: [],
|
||||
stripeCoupons: [],
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("discount");
|
||||
});
|
||||
|
||||
test("should filter autumn rewards by product when productId is provided", () => {
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "all_products",
|
||||
name: "All Products",
|
||||
type: RewardType.PercentageDiscount,
|
||||
discount_config: {
|
||||
discount_value: 10,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: true,
|
||||
},
|
||||
}),
|
||||
makeReward({
|
||||
id: "specific",
|
||||
name: "Specific",
|
||||
type: RewardType.FixedDiscount,
|
||||
discount_config: {
|
||||
discount_value: 500,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
const rewardPrograms = [
|
||||
makeRewardProgram({
|
||||
internal_reward_id: "rew_specific",
|
||||
product_ids: ["prod_other"],
|
||||
}),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms,
|
||||
stripeCoupons: [],
|
||||
productId: "prod_target",
|
||||
});
|
||||
// Only the apply_to_all reward should pass; the specific one is linked to prod_other
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("all_products");
|
||||
});
|
||||
|
||||
test("should place autumn options before stripe options", () => {
|
||||
const rewards = [
|
||||
makeReward({
|
||||
id: "autumn1",
|
||||
name: "A",
|
||||
type: RewardType.PercentageDiscount,
|
||||
discount_config: {
|
||||
discount_value: 10,
|
||||
duration_type: CouponDurationType.OneOff,
|
||||
duration_value: 0,
|
||||
apply_to_all: true,
|
||||
},
|
||||
}),
|
||||
];
|
||||
const stripeCoupons = [
|
||||
makeStripeCoupon({ id: "stripe1", name: "B", percent_off: 5 }),
|
||||
];
|
||||
const result = buildDiscountOptions({
|
||||
rewards,
|
||||
rewardPrograms: [],
|
||||
stripeCoupons,
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result[0].source).toBe("autumn");
|
||||
expect(result[1].source).toBe("stripe");
|
||||
});
|
||||
|
||||
test("should return empty array when no rewards or coupons", () => {
|
||||
const result = buildDiscountOptions({
|
||||
rewards: [],
|
||||
rewardPrograms: [],
|
||||
stripeCoupons: [],
|
||||
productId: undefined,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { UsageModel } from "@autumn/shared";
|
||||
import { buildUpdateSubscriptionOptions } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody";
|
||||
|
||||
describe("buildUpdateSubscriptionOptions — billing_units handling", () => {
|
||||
test("should pass display quantities through, not multiply by billing_units", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 0 }],
|
||||
prepaidOptions: { messages: 5000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should not multiply or divide quantity by billing_units", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 0 }],
|
||||
prepaidOptions: { messages: 5000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
});
|
||||
|
||||
// Must NOT be 5,000,000 (5000 * 1000) or 5 (5000 / 1000)
|
||||
expect(result[0]?.quantity).toBe(5000);
|
||||
});
|
||||
|
||||
test("should add included_usage to quantity", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 200 }],
|
||||
prepaidOptions: { messages: 5000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "messages", quantity: 5200 }]);
|
||||
});
|
||||
|
||||
test("should skip items where quantity has not changed", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 0 }],
|
||||
prepaidOptions: { messages: 1000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle multiple features with different billing_units", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [
|
||||
{ feature_id: "messages", included_usage: 0 },
|
||||
{ feature_id: "tokens", included_usage: 100 },
|
||||
],
|
||||
prepaidOptions: { messages: 10000, tokens: 2500 },
|
||||
initialPrepaidOptions: { messages: 5000, tokens: 1000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ feature_id: "messages", quantity: 10000 },
|
||||
{ feature_id: "tokens", quantity: 2600 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include new prepaid items from items array that are not in prepaidItems", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 0 }],
|
||||
prepaidOptions: { messages: 5000, tokens: 3000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
items: [
|
||||
{
|
||||
feature_id: "tokens",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
included_usage: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ feature_id: "messages", quantity: 5000 },
|
||||
{ feature_id: "tokens", quantity: 3050 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should not duplicate items already in prepaidItems when also in items array", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: 0 }],
|
||||
prepaidOptions: { messages: 5000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
items: [
|
||||
{
|
||||
feature_id: "messages",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
included_usage: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should skip non-prepaid items from items array", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [],
|
||||
prepaidOptions: { storage: 100 },
|
||||
initialPrepaidOptions: {},
|
||||
items: [
|
||||
{
|
||||
feature_id: "storage",
|
||||
usage_model: UsageModel.PayPerUse,
|
||||
included_usage: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array when no quantities changed", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [
|
||||
{ feature_id: "messages", included_usage: 0 },
|
||||
{ feature_id: "tokens", included_usage: 0 },
|
||||
],
|
||||
prepaidOptions: { messages: 1000, tokens: 500 },
|
||||
initialPrepaidOptions: { messages: 1000, tokens: 500 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle included_usage as 'inf' by treating as 0", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [{ feature_id: "messages", included_usage: "inf" }],
|
||||
prepaidOptions: { messages: 5000 },
|
||||
initialPrepaidOptions: { messages: 1000 },
|
||||
});
|
||||
|
||||
// typeof "inf" !== "number", so includedUsage defaults to 0
|
||||
expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should use feature.internal_id as fallback when feature_id is null", () => {
|
||||
const result = buildUpdateSubscriptionOptions({
|
||||
prepaidItems: [
|
||||
{
|
||||
feature_id: null,
|
||||
feature: { internal_id: "int_messages" },
|
||||
included_usage: 0,
|
||||
},
|
||||
],
|
||||
prepaidOptions: { int_messages: 3000 },
|
||||
initialPrepaidOptions: { int_messages: 1000 },
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "int_messages", quantity: 3000 }]);
|
||||
});
|
||||
});
|
||||
59
vite/tests/components/general/time-picker-utils.test.ts
Normal file
59
vite/tests/components/general/time-picker-utils.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getArrowByType,
|
||||
getDateByType,
|
||||
setDateByType,
|
||||
} from "@/components/general/timePickerUtils";
|
||||
|
||||
describe("timePickerUtils seconds support", () => {
|
||||
test("setDateByType sets seconds when type is seconds", () => {
|
||||
const date = new Date("2026-01-01T10:15:20.000Z");
|
||||
|
||||
const updatedDate = setDateByType({
|
||||
date,
|
||||
value: "45",
|
||||
type: "seconds",
|
||||
});
|
||||
|
||||
expect(updatedDate.getSeconds()).toBe(45);
|
||||
});
|
||||
|
||||
test("setDateByType clamps invalid seconds values", () => {
|
||||
const date = new Date("2026-01-01T10:15:20.000Z");
|
||||
|
||||
const updatedDate = setDateByType({
|
||||
date,
|
||||
value: "99",
|
||||
type: "seconds",
|
||||
});
|
||||
|
||||
expect(updatedDate.getSeconds()).toBe(59);
|
||||
});
|
||||
|
||||
test("getDateByType returns zero-padded seconds", () => {
|
||||
const date = new Date("2026-01-01T10:15:07.000Z");
|
||||
|
||||
const value = getDateByType({
|
||||
date,
|
||||
type: "seconds",
|
||||
});
|
||||
|
||||
expect(value).toBe("07");
|
||||
});
|
||||
|
||||
test("getArrowByType wraps forward and backward for seconds", () => {
|
||||
const incremented = getArrowByType({
|
||||
value: "59",
|
||||
step: 1,
|
||||
type: "seconds",
|
||||
});
|
||||
const decremented = getArrowByType({
|
||||
value: "00",
|
||||
step: -1,
|
||||
type: "seconds",
|
||||
});
|
||||
|
||||
expect(incremented).toBe("00");
|
||||
expect(decremented).toBe("59");
|
||||
});
|
||||
});
|
||||
296
vite/tests/utils/billing/prepaid-quantity-utils.test.ts
Normal file
296
vite/tests/utils/billing/prepaid-quantity-utils.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getPrepaidDisplayQuantity,
|
||||
type ProductV2,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
backendToDisplayQuantity,
|
||||
convertPrepaidOptionsToFeatureOptions,
|
||||
} from "@/utils/billing/prepaidQuantityUtils";
|
||||
|
||||
function makeProduct({ items }: { items: ProductV2["items"] }): ProductV2 {
|
||||
return {
|
||||
id: "prod_test",
|
||||
name: "Test Product",
|
||||
is_add_on: false,
|
||||
is_default: false,
|
||||
version: 1,
|
||||
group: null,
|
||||
env: "sandbox" as any,
|
||||
items,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("backendToDisplayQuantity", () => {
|
||||
test("should multiply backend quantity by billing_units", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [{ feature_id: "messages", quantity: 1 }],
|
||||
prepaidItems: [{ feature_id: "messages", billing_units: 1000 }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages: 1000 });
|
||||
});
|
||||
|
||||
test("should handle multiple features with different billing_units", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [
|
||||
{ feature_id: "messages", quantity: 10 },
|
||||
{ feature_id: "tokens", quantity: 5 },
|
||||
],
|
||||
prepaidItems: [
|
||||
{ feature_id: "messages", billing_units: 1000 },
|
||||
{ feature_id: "tokens", billing_units: 500 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages: 10000, tokens: 2500 });
|
||||
});
|
||||
|
||||
test("should default to billing_units=1 when nullish", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [{ feature_id: "messages", quantity: 5 }],
|
||||
prepaidItems: [{ feature_id: "messages", billing_units: null }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages: 5 });
|
||||
});
|
||||
|
||||
test("should default to 0 when feature has no backend option", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [],
|
||||
prepaidItems: [{ feature_id: "messages", billing_units: 1000 }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages: 0 });
|
||||
});
|
||||
|
||||
test("should ignore backend options not in prepaidItems", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [
|
||||
{ feature_id: "messages", quantity: 10 },
|
||||
{ feature_id: "unknown", quantity: 99 },
|
||||
],
|
||||
prepaidItems: [{ feature_id: "messages", billing_units: 1000 }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ messages: 10000 });
|
||||
});
|
||||
|
||||
test("should return empty record when no prepaid items", () => {
|
||||
const result = backendToDisplayQuantity({
|
||||
backendOptions: [{ feature_id: "messages", quantity: 10 }],
|
||||
prepaidItems: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertPrepaidOptionsToFeatureOptions", () => {
|
||||
test("should pass quantity through as-is for prepaid items (inclusive of billing units)", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_credits: 10000 },
|
||||
product,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "feat_credits", quantity: 10000 }]);
|
||||
});
|
||||
|
||||
test("should not multiply quantity by billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_credits: 5000 },
|
||||
product,
|
||||
});
|
||||
|
||||
// Must NOT be 5,000,000 (5000 * 1000)
|
||||
expect(result).toEqual([{ feature_id: "feat_credits", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should not divide quantity by billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_credits: 5000 },
|
||||
product,
|
||||
});
|
||||
|
||||
// Must NOT be 5 (5000 / 1000)
|
||||
expect(result).toEqual([{ feature_id: "feat_credits", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should pass quantity through for non-prepaid items", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_storage",
|
||||
usage_model: UsageModel.PayPerUse,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_storage: 5000 },
|
||||
product,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "feat_storage", quantity: 5000 }]);
|
||||
});
|
||||
|
||||
test("should handle multiple features with different billing_units", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
{
|
||||
feature_id: "feat_tokens",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: {
|
||||
feat_credits: 10000,
|
||||
feat_tokens: 2500,
|
||||
},
|
||||
product,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ feature_id: "feat_credits", quantity: 10000 },
|
||||
{ feature_id: "feat_tokens", quantity: 2500 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("should return undefined when product is undefined", () => {
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_credits: 10000 },
|
||||
product: undefined,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return undefined when prepaidOptions is empty", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: {},
|
||||
product,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle zero quantity", () => {
|
||||
const product = makeProduct({
|
||||
items: [
|
||||
{
|
||||
feature_id: "feat_credits",
|
||||
usage_model: UsageModel.Prepaid,
|
||||
billing_units: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions: { feat_credits: 0 },
|
||||
product,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ feature_id: "feat_credits", quantity: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrepaidDisplayQuantity", () => {
|
||||
test("should multiply quantity by billingUnits", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 1,
|
||||
billingUnits: 1000,
|
||||
});
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
|
||||
test("should handle larger pack counts", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 5,
|
||||
billingUnits: 1000,
|
||||
});
|
||||
expect(result).toBe(5000);
|
||||
});
|
||||
|
||||
test("should default to billingUnits=1 when null", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 7,
|
||||
billingUnits: null,
|
||||
});
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
test("should default to billingUnits=1 when undefined", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 7,
|
||||
billingUnits: undefined,
|
||||
});
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
test("should return 0 when quantity is 0", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 0,
|
||||
billingUnits: 1000,
|
||||
});
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
test("should be identity when billingUnits is 1", () => {
|
||||
const result = getPrepaidDisplayQuantity({
|
||||
quantity: 42,
|
||||
billingUnits: 1,
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
});
|
||||
62
vite/tests/utils/link-utils.test.ts
Normal file
62
vite/tests/utils/link-utils.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
|
||||
describe("getStripeInvoiceLink", () => {
|
||||
const baseArgs = {
|
||||
env: AppEnv.Sandbox,
|
||||
accountId: "acct_123",
|
||||
};
|
||||
|
||||
test("works when stripeInvoice is an object with id", () => {
|
||||
const result = getStripeInvoiceLink({
|
||||
stripeInvoice: { id: "in_abc123" },
|
||||
...baseArgs,
|
||||
});
|
||||
expect(result).toBe(
|
||||
"https://dashboard.stripe.com/acct_123/test/invoices/in_abc123",
|
||||
);
|
||||
expect(result).not.toContain("undefined");
|
||||
});
|
||||
|
||||
test("works when stripeInvoice is an object with stripe_id", () => {
|
||||
const result = getStripeInvoiceLink({
|
||||
stripeInvoice: { stripe_id: "in_abc123" },
|
||||
...baseArgs,
|
||||
});
|
||||
expect(result).toBe(
|
||||
"https://dashboard.stripe.com/acct_123/test/invoices/in_abc123",
|
||||
);
|
||||
expect(result).not.toContain("undefined");
|
||||
});
|
||||
|
||||
test("works when stripeInvoice is a string (attach v2 flow)", () => {
|
||||
const result = getStripeInvoiceLink({
|
||||
stripeInvoice: "in_abc123",
|
||||
...baseArgs,
|
||||
});
|
||||
expect(result).toBe(
|
||||
"https://dashboard.stripe.com/acct_123/test/invoices/in_abc123",
|
||||
);
|
||||
expect(result).not.toContain("undefined");
|
||||
});
|
||||
|
||||
test("works with live env (no /test prefix)", () => {
|
||||
const result = getStripeInvoiceLink({
|
||||
stripeInvoice: "in_abc123",
|
||||
env: AppEnv.Live,
|
||||
accountId: "acct_123",
|
||||
});
|
||||
expect(result).toBe(
|
||||
"https://dashboard.stripe.com/acct_123/invoices/in_abc123",
|
||||
);
|
||||
});
|
||||
|
||||
test("works without accountId", () => {
|
||||
const result = getStripeInvoiceLink({
|
||||
stripeInvoice: "in_abc123",
|
||||
env: AppEnv.Sandbox,
|
||||
});
|
||||
expect(result).toBe("https://dashboard.stripe.com/test/invoices/in_abc123");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user