From 4da64eebc90579096b8d095be7fbd94259f278c9 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Wed, 25 Feb 2026 10:21:27 +0000 Subject: [PATCH] test: add vite unit test action and remove from .gitignore --- .github/workflows/vite-unit-tests.yml | 25 ++ .gitignore | 1 + .../hooks/build-attach-request-body.test.ts | 159 +++++++ .../utils/discount-option-utils.test.ts | 391 ++++++++++++++++++ .../build-update-subscription-options.test.ts | 156 +++++++ .../general/time-picker-utils.test.ts | 59 +++ .../billing/prepaid-quantity-utils.test.ts | 296 +++++++++++++ vite/tests/utils/link-utils.test.ts | 62 +++ 8 files changed, 1149 insertions(+) create mode 100644 .github/workflows/vite-unit-tests.yml create mode 100644 vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts create mode 100644 vite/tests/components/forms/attach-v2/utils/discount-option-utils.test.ts create mode 100644 vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts create mode 100644 vite/tests/components/general/time-picker-utils.test.ts create mode 100644 vite/tests/utils/billing/prepaid-quantity-utils.test.ts create mode 100644 vite/tests/utils/link-utils.test.ts diff --git a/.github/workflows/vite-unit-tests.yml b/.github/workflows/vite-unit-tests.yml new file mode 100644 index 000000000..480d87c6a --- /dev/null +++ b/.github/workflows/vite-unit-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index c0a20503d..44b3a3b06 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ supabase.sh **/.env* tests/ !server/tests +!vite/tests .secrets # **/.npmrc diff --git a/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts new file mode 100644 index 000000000..755d842f9 --- /dev/null +++ b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts @@ -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(); + }); +}); diff --git a/vite/tests/components/forms/attach-v2/utils/discount-option-utils.test.ts b/vite/tests/components/forms/attach-v2/utils/discount-option-utils.test.ts new file mode 100644 index 000000000..314f4ca87 --- /dev/null +++ b/vite/tests/components/forms/attach-v2/utils/discount-option-utils.test.ts @@ -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 & { 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 & { 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 & { + 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); + }); +}); diff --git a/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts b/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts new file mode 100644 index 000000000..faf3f6300 --- /dev/null +++ b/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts @@ -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 }]); + }); +}); diff --git a/vite/tests/components/general/time-picker-utils.test.ts b/vite/tests/components/general/time-picker-utils.test.ts new file mode 100644 index 000000000..657ca8597 --- /dev/null +++ b/vite/tests/components/general/time-picker-utils.test.ts @@ -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"); + }); +}); diff --git a/vite/tests/utils/billing/prepaid-quantity-utils.test.ts b/vite/tests/utils/billing/prepaid-quantity-utils.test.ts new file mode 100644 index 000000000..cfdec9159 --- /dev/null +++ b/vite/tests/utils/billing/prepaid-quantity-utils.test.ts @@ -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); + }); +}); diff --git a/vite/tests/utils/link-utils.test.ts b/vite/tests/utils/link-utils.test.ts new file mode 100644 index 000000000..935d85676 --- /dev/null +++ b/vite/tests/utils/link-utils.test.ts @@ -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"); + }); +});