From 80e6312807f4a2c5bf35c511078901f6cf4f7297 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:05:06 +0800 Subject: [PATCH] test: add comprehensive unit and integration tests for billing components (#32227) Co-authored-by: CodingOnStar --- .../billing/billing-integration.test.tsx | 991 ++++++++++++++++++ .../billing/cloud-plan-payment-flow.test.tsx | 296 ++++++ .../education-verification-flow.test.tsx | 318 ++++++ .../billing/partner-stack-flow.test.tsx | 326 ++++++ .../billing/pricing-modal-flow.test.tsx | 327 ++++++ .../billing/self-hosted-plan-flow.test.tsx | 225 ++++ .../billing/__tests__/config.spec.ts | 141 +++ .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/modal.spec.tsx | 15 +- .../{ => __tests__}/usage.spec.tsx | 18 +- .../{ => __tests__}/index.spec.tsx | 23 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 42 +- .../{ => __tests__}/index.spec.tsx | 23 +- .../{ => __tests__}/use-ps-info.spec.tsx | 105 +- .../{ => __tests__}/index.spec.tsx | 15 +- .../plan/{ => __tests__}/index.spec.tsx | 72 +- .../{ => __tests__}/enterprise.spec.tsx | 2 +- .../assets/{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/professional.spec.tsx | 2 +- .../assets/{ => __tests__}/sandbox.spec.tsx | 2 +- .../plan/assets/{ => __tests__}/team.spec.tsx | 2 +- .../pricing/{ => __tests__}/footer.spec.tsx | 13 +- .../pricing/{ => __tests__}/header.spec.tsx | 39 +- .../pricing/{ => __tests__}/index.spec.tsx | 57 +- .../assets/__tests__/components.spec.tsx | 81 ++ .../assets/{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/index.spec.tsx | 48 +- .../plan-range-switcher.spec.tsx | 48 +- .../{ => __tests__}/tab.spec.tsx | 14 +- .../plans/{ => __tests__}/index.spec.tsx | 16 +- .../{ => __tests__}/button.spec.tsx | 9 +- .../{ => __tests__}/index.spec.tsx | 177 +++- .../list/{ => __tests__}/index.spec.tsx | 4 +- .../list/item/{ => __tests__}/index.spec.tsx | 11 +- .../item/{ => __tests__}/tooltip.spec.tsx | 9 +- .../{ => __tests__}/button.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 52 +- .../list/__tests__/index.spec.tsx | 20 + .../list/__tests__/item.spec.tsx | 35 + .../self-hosted-plan-item/list/index.spec.tsx | 26 - .../self-hosted-plan-item/list/item.spec.tsx | 12 - .../{ => __tests__}/index.spec.tsx | 39 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 54 +- .../{ => __tests__}/index.spec.tsx | 142 +-- .../usage-info/__tests__/apps-info.spec.tsx | 67 ++ .../usage-info/{ => __tests__}/index.spec.tsx | 4 +- .../vector-space-info.spec.tsx | 6 +- .../billing/usage-info/apps-info.spec.tsx | 35 - .../utils/{ => __tests__}/index.spec.ts | 6 +- .../{ => __tests__}/index.spec.tsx | 28 +- web/eslint-suppressions.json | 15 - 53 files changed, 3431 insertions(+), 625 deletions(-) create mode 100644 web/__tests__/billing/billing-integration.test.tsx create mode 100644 web/__tests__/billing/cloud-plan-payment-flow.test.tsx create mode 100644 web/__tests__/billing/education-verification-flow.test.tsx create mode 100644 web/__tests__/billing/partner-stack-flow.test.tsx create mode 100644 web/__tests__/billing/pricing-modal-flow.test.tsx create mode 100644 web/__tests__/billing/self-hosted-plan-flow.test.tsx create mode 100644 web/app/components/billing/__tests__/config.spec.ts rename web/app/components/billing/annotation-full/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/billing/annotation-full/{ => __tests__}/modal.spec.tsx (92%) rename web/app/components/billing/annotation-full/{ => __tests__}/usage.spec.tsx (70%) rename web/app/components/billing/apps-full-in-dialog/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/billing/billing-page/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/billing/header-billing-btn/{ => __tests__}/index.spec.tsx (65%) rename web/app/components/billing/partner-stack/{ => __tests__}/index.spec.tsx (57%) rename web/app/components/billing/partner-stack/{ => __tests__}/use-ps-info.spec.tsx (60%) rename web/app/components/billing/plan-upgrade-modal/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/billing/plan/{ => __tests__}/index.spec.tsx (69%) rename web/app/components/billing/plan/assets/{ => __tests__}/enterprise.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/billing/plan/assets/{ => __tests__}/professional.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/sandbox.spec.tsx (99%) rename web/app/components/billing/plan/assets/{ => __tests__}/team.spec.tsx (99%) rename web/app/components/billing/pricing/{ => __tests__}/footer.spec.tsx (87%) rename web/app/components/billing/pricing/{ => __tests__}/header.spec.tsx (55%) rename web/app/components/billing/pricing/{ => __tests__}/index.spec.tsx (66%) create mode 100644 web/app/components/billing/pricing/assets/__tests__/components.spec.tsx rename web/app/components/billing/pricing/assets/{ => __tests__}/index.spec.tsx (91%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/index.spec.tsx (65%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/plan-range-switcher.spec.tsx (50%) rename web/app/components/billing/pricing/plan-switcher/{ => __tests__}/tab.spec.tsx (88%) rename web/app/components/billing/pricing/plans/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/billing/pricing/plans/cloud-plan-item/{ => __tests__}/button.spec.tsx (89%) rename web/app/components/billing/pricing/plans/cloud-plan-item/{ => __tests__}/index.spec.tsx (52%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/item/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/billing/pricing/plans/cloud-plan-item/list/item/{ => __tests__}/tooltip.spec.tsx (88%) rename web/app/components/billing/pricing/plans/self-hosted-plan-item/{ => __tests__}/button.spec.tsx (94%) rename web/app/components/billing/pricing/plans/self-hosted-plan-item/{ => __tests__}/index.spec.tsx (75%) create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/index.spec.tsx create mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/__tests__/item.spec.tsx delete mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/index.spec.tsx delete mode 100644 web/app/components/billing/pricing/plans/self-hosted-plan-item/list/item.spec.tsx rename web/app/components/billing/priority-label/{ => __tests__}/index.spec.tsx (87%) rename web/app/components/billing/progress-bar/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/billing/trigger-events-limit-modal/{ => __tests__}/index.spec.tsx (55%) rename web/app/components/billing/upgrade-btn/{ => __tests__}/index.spec.tsx (79%) create mode 100644 web/app/components/billing/usage-info/__tests__/apps-info.spec.tsx rename web/app/components/billing/usage-info/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/billing/usage-info/{ => __tests__}/vector-space-info.spec.tsx (98%) delete mode 100644 web/app/components/billing/usage-info/apps-info.spec.tsx rename web/app/components/billing/utils/{ => __tests__}/index.spec.ts (98%) rename web/app/components/billing/vector-space-full/{ => __tests__}/index.spec.tsx (69%) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx new file mode 100644 index 0000000000..4891760df4 --- /dev/null +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -0,0 +1,991 @@ +import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import AnnotationFull from '@/app/components/billing/annotation-full' +import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' +import AppsFull from '@/app/components/billing/apps-full-in-dialog' +import Billing from '@/app/components/billing/billing-page' +import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' +import HeaderBillingBtn from '@/app/components/billing/header-billing-btn' +import PlanComp from '@/app/components/billing/plan' +import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import PriorityLabel from '@/app/components/billing/priority-label' +import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal' +import { Plan } from '@/app/components/billing/type' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import VectorSpaceFull from '@/app/components/billing/vector-space-full' + +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), + useModalContextSelector: (selector: (s: Record) => unknown) => + selector({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', + useGetPricingPageLanguage: () => 'en', +})) + +// ─── Service mocks ────────────────────────────────────────────────────────── +const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' }) +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: 'https://billing.example.com', + isFetching: false, + refetch: mockRefetch, + }), + useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }), + isPending: false, + }), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +const mockRouterPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── External component mocks ─────────────────────────────────────────────── +vi.mock('@/app/education-apply/verify-state-modal', () => ({ + default: ({ isShow }: { isShow: boolean }) => + isShow ?
: null, +})) + +vi.mock('@/app/components/header/utils/util', () => ({ + mailToSupport: () => 'mailto:support@test.com', +})) + +// ─── Test data factories ──────────────────────────────────────────────────── +type PlanOverrides = { + type?: string + usage?: Partial + total?: Partial + reset?: Partial +} + +const createPlanData = (overrides: PlanOverrides = {}) => ({ + ...defaultPlan, + ...overrides, + type: overrides.type ?? defaultPlan.type, + usage: { ...defaultPlan.usage, ...overrides.usage }, + total: { ...defaultPlan.total, ...overrides.total }, + reset: { ...defaultPlan.reset, ...overrides.reset }, +}) + +const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record = {}) => { + mockProviderCtx = { + plan: createPlanData(planOverrides), + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + ...extra, + } +} + +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'test@example.com' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...overrides, + } +} + +// Vitest hoists vi.mock() calls, so imports above will use mocked modules + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. Billing Page + Plan Component Integration +// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar +// ═══════════════════════════════════════════════════════════════════════════ +describe('Billing Page + Plan Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // Verify that the billing page renders PlanComp with all 7 usage items + describe('Rendering complete plan information', () => { + it('should display all 7 usage metrics for sandbox plan', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { + buildApps: 3, + teamMembers: 1, + documentsUploadQuota: 10, + vectorSpace: 20, + annotatedResponse: 5, + triggerEvents: 1000, + apiRateLimit: 2000, + }, + total: { + buildApps: 5, + teamMembers: 1, + documentsUploadQuota: 50, + vectorSpace: 50, + annotatedResponse: 10, + triggerEvents: 3000, + apiRateLimit: 5000, + }, + }) + + render() + + // Plan name + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + + // All 7 usage items should be visible + expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument() + expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument() + expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument() + }) + + it('should display usage values as "usage / total" format', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 3, teamMembers: 1 }, + total: { buildApps: 5, teamMembers: 1 }, + }) + + render() + + // Check that the buildApps usage fraction "3 / 5" is rendered + const usageContainers = screen.getAllByText('3') + expect(usageContainers.length).toBeGreaterThan(0) + const totalContainers = screen.getAllByText('5') + expect(totalContainers.length).toBeGreaterThan(0) + }) + + it('should show "unlimited" for infinite quotas (professional API rate limit)', () => { + setupProviderContext({ + type: Plan.professional, + total: { apiRateLimit: NUM_INFINITE }, + }) + + render() + + expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument() + }) + + it('should display reset days for trigger events when applicable', () => { + setupProviderContext({ + type: Plan.professional, + total: { triggerEvents: 20000 }, + reset: { triggerEvents: 7 }, + }) + + render() + + // Reset text should be visible + expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument() + }) + }) + + // Verify billing URL button visibility and behavior + describe('Billing URL button', () => { + it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => { + setupProviderContext({ type: Plan.sandbox }) + setupAppContext({ isCurrentWorkspaceManager: true }) + + render() + + expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument() + expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument() + }) + + it('should hide billing button when user is not workspace manager', () => { + setupProviderContext({ type: Plan.sandbox }) + setupAppContext({ isCurrentWorkspaceManager: false }) + + render() + + expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() + }) + + it('should hide billing button when billing is disabled', () => { + setupProviderContext({ type: Plan.sandbox }, { enableBilling: false }) + + render() + + expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. Plan Type Display Integration +// Tests that different plan types render correct visual elements +// ═══════════════════════════════════════════════════════════════════════════ +describe('Plan Type Display Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should render sandbox plan with upgrade button (premium badge)', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument() + // Sandbox shows premium badge upgrade button (not plain) + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render professional plan with plain upgrade button', () => { + setupProviderContext({ type: Plan.professional }) + + render() + + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + // Professional shows plain button because it's not team + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render team plan with plain-style upgrade button', () => { + setupProviderContext({ type: Plan.team }) + + render() + + expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument() + // Team plan has isPlain=true, so shows "upgradeBtn.plain" text + expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument() + }) + + it('should not render upgrade button for enterprise plan', () => { + setupProviderContext({ type: Plan.enterprise }) + + render() + + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument() + }) + + it('should show education verify button when enableEducationPlan is true and not yet verified', () => { + setupProviderContext({ type: Plan.sandbox }, { + enableEducationPlan: true, + isEducationAccount: false, + }) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Upgrade Flow Integration +// Tests the flow: UpgradeBtn click → setShowPricingModal +// and PlanUpgradeModal → close + trigger pricing +// ═══════════════════════════════════════════════════════════════════════════ +describe('Upgrade Flow Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + setupProviderContext({ type: Plan.sandbox }) + }) + + // UpgradeBtn triggers pricing modal + describe('UpgradeBtn triggers pricing modal', () => { + it('should call setShowPricingModal when clicking premium badge upgrade button', async () => { + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call setShowPricingModal when clicking plain upgrade button', async () => { + const user = userEvent.setup() + + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should use custom onClick when provided instead of setShowPricingModal', async () => { + const customOnClick = vi.fn() + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(customOnClick).toHaveBeenCalledTimes(1) + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should fire gtag event with loc parameter when clicked', async () => { + const mockGtag = vi.fn() + ;(window as unknown as Record).gtag = mockGtag + const user = userEvent.setup() + + render() + + const badgeText = screen.getByText(/upgradeBtn\.encourage/i) + await user.click(badgeText) + + expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' }) + delete (window as unknown as Record).gtag + }) + }) + + // PlanUpgradeModal integration: close modal and trigger pricing + describe('PlanUpgradeModal upgrade flow', () => { + it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + , + ) + + // The modal should show title and description + expect(screen.getByText('Upgrade Required')).toBeInTheDocument() + expect(screen.getByText('You need a better plan')).toBeInTheDocument() + + // Click the upgrade button inside the modal + const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeText) + + // Should close the current modal first + expect(onClose).toHaveBeenCalledTimes(1) + // Then open pricing modal + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should call onClose and custom onUpgrade when provided', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onUpgrade = vi.fn() + + render( + , + ) + + const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeText) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onUpgrade).toHaveBeenCalledTimes(1) + // Custom onUpgrade replaces default setShowPricingModal + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should call onClose when clicking dismiss button', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + , + ) + + const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i) + await user.click(dismissBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + }) + + // Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing + describe('PlanComp upgrade button triggers pricing', () => { + it('should open pricing modal when clicking upgrade in sandbox plan', async () => { + const user = userEvent.setup() + setupProviderContext({ type: Plan.sandbox }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. Capacity Full Components Integration +// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal +// with real child components (UsageInfo, ProgressBar, UpgradeBtn) +// ═══════════════════════════════════════════════════════════════════════════ +describe('Capacity Full Components Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // AppsFull renders with correct messaging and components + describe('AppsFull integration', () => { + it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + // Should show "full" tip + expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument() + // Should show upgrade button + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + // Should show usage/total fraction "5/5" + expect(screen.getByText(/5\/5/)).toBeInTheDocument() + // Should have a progress bar rendered + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + }) + + it('should display upgrade tip and upgrade button for professional plan', () => { + setupProviderContext({ + type: Plan.professional, + usage: { buildApps: 48 }, + total: { buildApps: 50 }, + }) + + render() + + expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should display contact tip and contact button for team plan', () => { + setupProviderContext({ + type: Plan.team, + usage: { buildApps: 200 }, + total: { buildApps: 200 }, + }) + + render() + + // Team plan shows different tip + expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument() + // Team plan shows "Contact Us" instead of upgrade + expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + }) + + it('should render progress bar with correct color based on usage percentage', () => { + // 100% usage should show error color + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + const progressBar = screen.getByTestId('billing-progress-bar') + expect(progressBar).toHaveClass('bg-components-progress-error-progress') + }) + }) + + // VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn + describe('VectorSpaceFull integration', () => { + it('should display full tip, upgrade button, and vector space usage info', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 50 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Should show full tip + expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument() + expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument() + // Should show upgrade button + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + // Should show vector space usage info + expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument() + }) + }) + + // AnnotationFull renders with Usage component and UpgradeBtn + describe('AnnotationFull integration', () => { + it('should display annotation full tip, upgrade button, and usage info', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument() + expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument() + // UpgradeBtn rendered + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + // Usage component should show annotation quota + expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument() + }) + }) + + // AnnotationFullModal shows modal with usage and upgrade button + describe('AnnotationFullModal integration', () => { + it('should render modal with annotation info and upgrade button when show is true', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument() + expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument() + }) + + it('should not render content when show is false', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument() + }) + }) + + // TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo + describe('TriggerEventsLimitModal integration', () => { + it('should display trigger limit title, usage info, and upgrade button', () => { + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + // Modal title and description + expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument() + // Embedded UsageInfo with trigger events data + expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument() + expect(screen.getByText('18000')).toBeInTheDocument() + expect(screen.getByText('20000')).toBeInTheDocument() + // Reset info + expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument() + // Upgrade and dismiss buttons + expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument() + expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument() + }) + + it('should call onClose and onUpgrade when clicking upgrade', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const onUpgrade = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onUpgrade).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. Header Billing Button Integration +// Tests HeaderBillingBtn behavior for different plan states +// ═══════════════════════════════════════════════════════════════════════════ +describe('Header Billing Button Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should render UpgradeBtn (premium badge) for sandbox plan', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should render "pro" badge for professional plan', () => { + setupProviderContext({ type: Plan.professional }) + + render() + + expect(screen.getByText('pro')).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument() + }) + + it('should render "team" badge for team plan', () => { + setupProviderContext({ type: Plan.team }) + + render() + + expect(screen.getByText('team')).toBeInTheDocument() + }) + + it('should return null when billing is disabled', () => { + setupProviderContext({ type: Plan.sandbox }, { enableBilling: false }) + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('should return null when plan is not fetched yet', () => { + setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false }) + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('should call onClick when clicking pro/team badge in non-display-only mode', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render() + + await user.click(screen.getByText('pro')) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not call onClick when isDisplayOnly is true', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render() + + await user.click(screen.getByText('pro')) + + expect(onClick).not.toHaveBeenCalled() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. PriorityLabel Integration +// Tests priority badge display for different plan types +// ═══════════════════════════════════════════════════════════════════════════ +describe('PriorityLabel Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should display "standard" priority for sandbox plan', () => { + setupProviderContext({ type: Plan.sandbox }) + + render() + + expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument() + }) + + it('should display "priority" for professional plan with icon', () => { + setupProviderContext({ type: Plan.professional }) + + const { container } = render() + + expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument() + // Professional plan should show the priority icon + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should display "top-priority" for team plan with icon', () => { + setupProviderContext({ type: Plan.team }) + + const { container } = render() + + expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should display "top-priority" for enterprise plan', () => { + setupProviderContext({ type: Plan.enterprise }) + + render() + + expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument() + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Usage Display Edge Cases +// Tests storage mode, threshold logic, and progress bar color integration +// ═══════════════════════════════════════════════════════════════════════════ +describe('Usage Display Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + // Vector space storage mode behavior + describe('VectorSpace storage mode in PlanComp', () => { + it('should show "< 50" for sandbox plan with low vector space usage', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 10 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Storage mode: usage below threshold shows "< 50" + expect(screen.getByText(/ { + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 10 }, + total: { vectorSpace: 50 }, + }) + + render() + + // Should have an indeterminate progress bar + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() + }) + + it('should show actual usage for pro plan above threshold', () => { + setupProviderContext({ + type: Plan.professional, + usage: { vectorSpace: 1024 }, + total: { vectorSpace: 5120 }, + }) + + render() + + // Pro plan above threshold shows actual value + expect(screen.getByText('1024')).toBeInTheDocument() + }) + }) + + // Progress bar color logic through real components + describe('Progress bar color reflects usage severity', () => { + it('should show normal color for low usage percentage', () => { + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 1 }, + total: { buildApps: 5 }, + }) + + render() + + // 20% usage - normal color + const progressBars = screen.getAllByTestId('billing-progress-bar') + // At least one should have the normal progress color + const hasNormalColor = progressBars.some(bar => + bar.classList.contains('bg-components-progress-bar-progress-solid'), + ) + expect(hasNormalColor).toBe(true) + }) + }) + + // Reset days calculation in PlanComp + describe('Reset days integration', () => { + it('should not show reset for sandbox trigger events (no reset_date)', () => { + setupProviderContext({ + type: Plan.sandbox, + total: { triggerEvents: 3000 }, + reset: { triggerEvents: null }, + }) + + render() + + // Find the trigger events section - should not have reset text + const triggerSection = screen.getByText(/usagePage\.triggerEvents/i) + const parent = triggerSection.closest('[class*="flex flex-col"]') + // No reset text should appear (sandbox doesn't show reset for triggerEvents) + expect(parent?.textContent).not.toContain('usagePage.resetsIn') + }) + + it('should show reset for professional trigger events with reset date', () => { + setupProviderContext({ + type: Plan.professional, + total: { triggerEvents: 20000 }, + reset: { triggerEvents: 14 }, + }) + + render() + + // Professional plan with finite triggerEvents should show reset + const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i) + expect(resetTexts.length).toBeGreaterThan(0) + }) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 8. Cross-Component Upgrade Flow (End-to-End) +// Tests the complete chain: capacity alert → upgrade button → pricing +// ═══════════════════════════════════════════════════════════════════════════ +describe('Cross-Component Upgrade Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + setupAppContext() + }) + + it('should trigger pricing from AppsFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { buildApps: 5 }, + total: { buildApps: 5 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from VectorSpaceFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { vectorSpace: 50 }, + total: { vectorSpace: 50 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from AnnotationFull upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + setupProviderContext({ type: Plan.professional }) + + render( + , + ) + + // TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal + // PlanUpgradeModal's upgrade button calls onClose then onUpgrade + const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i) + await user.click(upgradeBtn) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should trigger pricing from AnnotationFullModal upgrade button', async () => { + const user = userEvent.setup() + setupProviderContext({ + type: Plan.sandbox, + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }) + + render() + + const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i) + await user.click(upgradeText) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx new file mode 100644 index 0000000000..e01d9250fd --- /dev/null +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -0,0 +1,296 @@ +/** + * Integration test: Cloud Plan Payment Flow + * + * Tests the payment flow for cloud plan items: + * CloudPlanItem → Button click → permission check → fetch URL → redirect + * + * Covers plan comparison, downgrade prevention, monthly/yearly pricing, + * and workspace manager permission enforcement. + */ +import type { BasicPlan } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher' +import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockAppCtx: Record = {} +const mockFetchSubscriptionUrls = vi.fn() +const mockInvoices = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockToastNotify = vi.fn() + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: () => mockInvoices(), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +type RenderCloudPlanItemOptions = { + currentPlan?: BasicPlan + plan?: BasicPlan + planRange?: PlanRange + canPay?: boolean +} + +const renderCloudPlanItem = ({ + currentPlan = Plan.sandbox, + plan = Plan.professional, + planRange = PlanRange.monthly, + canPay = true, +}: RenderCloudPlanItemOptions = {}) => { + return render( + , + ) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Cloud Plan Payment Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' }) + mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' }) + }) + + // ─── 1. Plan Display ──────────────────────────────────────────────────── + describe('Plan display', () => { + it('should render plan name and description', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument() + }) + + it('should show "Free" price for sandbox plan', () => { + renderCloudPlanItem({ plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show monthly price for paid plans', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly }) + + expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument() + }) + + it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => { + renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly }) + + const yearlyPrice = ALL_PLANS.professional.price * 10 + const originalPrice = ALL_PLANS.professional.price * 12 + + expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument() + expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument() + }) + + it('should show "most popular" badge for professional plan', () => { + renderCloudPlanItem({ plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + + it('should not show "most popular" badge for sandbox or team plans', () => { + const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + unmount() + + renderCloudPlanItem({ plan: Plan.team }) + expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument() + }) + }) + + // ─── 2. Button Text Logic ─────────────────────────────────────────────── + describe('Button text logic', () => { + it('should show "Current Plan" when plan matches current plan', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show "Start for Free" for sandbox plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument() + }) + + it('should show "Start Building" for professional plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + }) + + it('should show "Get Started" for team plan when not current', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Downgrade Prevention ──────────────────────────────────────────── + describe('Downgrade prevention', () => { + it('should disable sandbox button when user is on professional plan (downgrade)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('should disable sandbox and professional buttons when user is on team plan', () => { + const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox }) + expect(screen.getByRole('button')).toBeDisabled() + unmount() + + renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional }) + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not disable current paid plan button (for invoice management)', () => { + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + + it('should enable higher-tier plan buttons for upgrade', () => { + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team }) + + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + }) + }) + + // ─── 4. Payment URL Flow ──────────────────────────────────────────────── + describe('Payment URL flow', () => { + it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => { + const user = userEvent.setup() + // Simulate clicking on a professional plan button (user is on sandbox) + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.professional, + planRange: PlanRange.monthly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month') + }) + }) + + it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ + currentPlan: Plan.sandbox, + plan: Plan.team, + planRange: PlanRange.yearly, + }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year') + }) + }) + + it('should open invoice management for current paid plan', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalled() + }) + // Should NOT call fetchSubscriptionUrls (invoice, not subscription) + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + + it('should not do anything when clicking on sandbox free plan button', async () => { + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox }) + + const button = screen.getByRole('button') + await user.click(button) + + // Wait a tick and verify no actions were taken + await waitFor(() => { + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + }) + }) + }) + + // ─── 5. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks upgrade button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + // Should not proceed with payment + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx new file mode 100644 index 0000000000..8c35cd9a8c --- /dev/null +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -0,0 +1,318 @@ +/** + * Integration test: Education Verification Flow + * + * Tests the education plan verification flow in PlanComp: + * PlanComp → handleVerify → useEducationVerify → router.push → education-apply + * PlanComp → handleVerify → error → show VerifyStateModal + * + * Also covers education button visibility based on context flags. + */ +import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { defaultPlan } from '@/app/components/billing/config' +import PlanComp from '@/app/components/billing/plan' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = vi.fn() +const mockRouterPush = vi.fn() +const mockMutateAsync = vi.fn() + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), + useModalContextSelector: (selector: (s: Record) => unknown) => + selector({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/use-education', () => ({ + useEducationVerify: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})) + +vi.mock('@/service/use-billing', () => ({ + useBillingUrl: () => ({ + data: 'https://billing.example.com', + isFetching: false, + refetch: vi.fn(), + }), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── External component mocks ─────────────────────────────────────────────── +vi.mock('@/app/education-apply/verify-state-modal', () => ({ + default: ({ isShow, title, content, email, showLink }: { + isShow: boolean + title?: string + content?: string + email?: string + showLink?: boolean + }) => + isShow + ? ( +
+ {title && {title}} + {content && {content}} + {email && {email}} + {showLink && link} +
+ ) + : null, +})) + +// ─── Test data factories ──────────────────────────────────────────────────── +type PlanOverrides = { + type?: string + usage?: Partial + total?: Partial + reset?: Partial +} + +const createPlanData = (overrides: PlanOverrides = {}) => ({ + ...defaultPlan, + ...overrides, + type: overrides.type ?? defaultPlan.type, + usage: { ...defaultPlan.usage, ...overrides.usage }, + total: { ...defaultPlan.total, ...overrides.total }, + reset: { ...defaultPlan.reset, ...overrides.reset }, +}) + +const setupContexts = ( + planOverrides: PlanOverrides = {}, + providerOverrides: Record = {}, + appOverrides: Record = {}, +) => { + mockProviderCtx = { + plan: createPlanData(planOverrides), + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + ...providerOverrides, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'student@university.edu' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Education Verification Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Education Button Visibility ───────────────────────────────────── + describe('Education button visibility', () => { + it('should not show verify button when enableEducationPlan is false', () => { + setupContexts({}, { enableEducationPlan: false }) + + render() + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when enableEducationPlan is true and not yet verified', () => { + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + + it('should not show verify button when already verified and not about to expire', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: false, + }) + + render() + + expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument() + }) + + it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => { + setupContexts({}, { + enableEducationPlan: true, + isEducationAccount: true, + allowRefreshEducationVerify: true, + }) + + render() + + // Shown because isAboutToExpire = allowRefreshEducationVerify = true + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + }) + }) + + // ─── 2. Successful Verification Flow ──────────────────────────────────── + describe('Successful verification flow', () => { + it('should navigate to education-apply with token on successful verification', async () => { + mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123') + }) + }) + + it('should remove education verifying flag from localStorage on success', async () => { + mockMutateAsync.mockResolvedValue({ token: 'token-xyz' }) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying') + }) + }) + }) + + // ─── 3. Failed Verification Flow ──────────────────────────────────────── + describe('Failed verification flow', () => { + it('should show VerifyStateModal with rejection info on error', async () => { + mockMutateAsync.mockRejectedValue(new Error('Verification failed')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + // Modal should not be visible initially + expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument() + + const verifyButton = screen.getByText(/toVerified/i) + await user.click(verifyButton) + + // Modal should appear after verification failure + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Modal should display rejection title and content + expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i) + expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i) + }) + + it('should show email and link in VerifyStateModal', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu') + expect(screen.getByTestId('modal-show-link')).toBeInTheDocument() + }) + }) + + it('should not redirect on verification failure', async () => { + mockMutateAsync.mockRejectedValue(new Error('fail')) + setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) + const user = userEvent.setup() + + render() + + await user.click(screen.getByText(/toVerified/i)) + + await waitFor(() => { + expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument() + }) + + // Should NOT navigate + expect(mockRouterPush).not.toHaveBeenCalled() + }) + }) + + // ─── 4. Education + Upgrade Coexistence ───────────────────────────────── + describe('Education and upgrade button coexistence', () => { + it('should show both education verify and upgrade buttons for sandbox user', () => { + setupContexts( + { type: Plan.sandbox }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument() + }) + + it('should not show upgrade button for enterprise plan', () => { + setupContexts( + { type: Plan.enterprise }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument() + expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument() + }) + + it('should show team plan with plain upgrade button and education button', () => { + setupContexts( + { type: Plan.team }, + { enableEducationPlan: true, isEducationAccount: false }, + ) + + render() + + expect(screen.getByText(/toVerified/i)).toBeInTheDocument() + expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx new file mode 100644 index 0000000000..4f265478cd --- /dev/null +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -0,0 +1,326 @@ +/** + * Integration test: Partner Stack Flow + * + * Tests the PartnerStack integration: + * PartnerStack component → usePSInfo hook → cookie management → bind API call + * + * Covers URL param reading, cookie persistence, API bind on mount, + * cookie cleanup after successful bind, and error handling for 400 status. + */ +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react' +import Cookies from 'js-cookie' +import * as React from 'react' +import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info' +import { PARTNER_STACK_CONFIG } from '@/config' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockSearchParams = new URLSearchParams() +const mockMutateAsync = vi.fn() + +// ─── Module mocks ──────────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useSearchParams: () => mockSearchParams, + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/', +})) + +vi.mock('@/service/use-billing', () => ({ + useBindPartnerStackInfo: () => ({ + mutateAsync: mockMutateAsync, + }), + useBillingUrl: () => ({ + data: '', + isFetching: false, + refetch: vi.fn(), + }), +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + IS_CLOUD_EDITION: true, + PARTNER_STACK_CONFIG: { + cookieName: 'partner_stack_info', + saveCookieDays: 90, + }, + } +}) + +// ─── Cookie helpers ────────────────────────────────────────────────────────── +const getCookieData = () => { + const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName) + if (!raw) + return null + try { + return JSON.parse(raw) + } + catch { + return null + } +} + +const setCookieData = (data: Record) => { + Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data)) +} + +const clearCookie = () => { + Cookies.remove(PARTNER_STACK_CONFIG.cookieName) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Partner Stack Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + clearCookie() + mockSearchParams = new URLSearchParams() + mockMutateAsync.mockResolvedValue({}) + }) + + // ─── 1. URL Param Reading ─────────────────────────────────────────────── + describe('URL param reading', () => { + it('should read ps_partner_key and ps_xid from URL search params', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-123', + ps_xid: 'click-456', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('partner-123') + expect(result.current.psClickId).toBe('click-456') + }) + + it('should fall back to cookie when URL params are not present', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('cookie-partner') + expect(result.current.psClickId).toBe('cookie-click') + }) + + it('should prefer URL params over cookie values', () => { + setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'url-partner', + ps_xid: 'url-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('url-partner') + expect(result.current.psClickId).toBe('url-click') + }) + + it('should return null for both values when no params and no cookie', () => { + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBeUndefined() + expect(result.current.psClickId).toBeUndefined() + }) + }) + + // ─── 2. Cookie Persistence (saveOrUpdate) ─────────────────────────────── + describe('Cookie persistence via saveOrUpdate', () => { + it('should save PS info to cookie when URL params provide new values', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'new-partner', + ps_xid: 'new-click', + }) + + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + const cookieData = getCookieData() + expect(cookieData).toEqual({ + partnerKey: 'new-partner', + clickId: 'new-click', + }) + }) + + it('should not update cookie when values have not changed', () => { + setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'same-partner', + ps_xid: 'same-click', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + // Should not call set because values haven't changed + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when partner key is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + + it('should not save to cookie when click ID is missing', () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-only', + }) + + const cookieSetSpy = vi.spyOn(Cookies, 'set') + const { result } = renderHook(() => usePSInfo()) + act(() => result.current.saveOrUpdate()) + + expect(cookieSetSpy).not.toHaveBeenCalled() + cookieSetSpy.mockRestore() + }) + }) + + // ─── 3. Bind API Flow ────────────────────────────────────────────────── + describe('Bind API flow', () => { + it('should call mutateAsync with partnerKey and clickId on bind', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'bind-partner', + clickId: 'bind-click', + }) + }) + + it('should remove cookie after successful bind', async () => { + setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'rm-partner', + ps_xid: 'rm-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed after successful bind + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should remove cookie on 400 error (already bound)', async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }) + setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'err-partner', + ps_xid: 'err-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should be removed even on 400 + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should not remove cookie on non-400 errors', async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }) + setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' }) + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'keep-partner', + ps_xid: 'keep-click', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + // Cookie should still exist for non-400 errors + const cookieData = getCookieData() + expect(cookieData).toBeTruthy() + }) + + it('should not call bind when partner key is missing', async () => { + mockSearchParams = new URLSearchParams({ + ps_xid: 'click-only', + }) + + const { result } = renderHook(() => usePSInfo()) + await act(async () => { + await result.current.bind() + }) + + expect(mockMutateAsync).not.toHaveBeenCalled() + }) + + it('should not call bind a second time (idempotency)', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'partner-once', + ps_xid: 'click-once', + }) + + const { result } = renderHook(() => usePSInfo()) + + // First bind + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + + // Second bind should be skipped (hasBind = true) + await act(async () => { + await result.current.bind() + }) + expect(mockMutateAsync).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 4. PartnerStack Component Mount ──────────────────────────────────── + describe('PartnerStack component mount behavior', () => { + it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => { + mockSearchParams = new URLSearchParams({ + ps_partner_key: 'mount-partner', + ps_xid: 'mount-click', + }) + + // Use lazy import so the mocks are applied + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + render() + + // The component calls saveOrUpdate and bind in useEffect + await waitFor(() => { + // Bind should have been called + expect(mockMutateAsync).toHaveBeenCalledWith({ + partnerKey: 'mount-partner', + clickId: 'mount-click', + }) + }) + + // Cookie should have been saved (saveOrUpdate was called before bind) + // After bind succeeds, cookie is removed + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() + }) + + it('should render nothing (return null)', async () => { + const { default: PartnerStack } = await import('@/app/components/billing/partner-stack') + + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + }) +}) diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx new file mode 100644 index 0000000000..6b8fb57f83 --- /dev/null +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -0,0 +1,327 @@ +/** + * Integration test: Pricing Modal Flow + * + * Tests the full Pricing modal lifecycle: + * Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted) + * → CloudPlanItem / SelfHostedPlanItem → Footer + * + * Validates cross-component state propagation when the user switches between + * cloud / self-hosted categories and monthly / yearly plan ranges. + */ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { ALL_PLANS } from '@/app/components/billing/config' +import Pricing from '@/app/components/billing/pricing' +import { Plan } from '@/app/components/billing/type' + +// ─── Mock state ────────────────────────────────────────────────────────────── +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} + +// ─── Context mocks ─────────────────────────────────────────────────────────── +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderCtx, +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', + useGetPricingPageLanguage: () => 'en', +})) + +// ─── Service mocks ─────────────────────────────────────────────────────────── +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + billing: { + invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }), + }, + }, +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => vi.fn(), +})) + +// ─── Navigation mocks ─────────────────────────────────────────────────────── +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/billing', + useSearchParams: () => new URLSearchParams(), +})) + +// ─── External component mocks (lightweight) ───────────────────────────────── +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => , + GoogleCloud: () => , + AwsMarketplaceLight: () => , + AwsMarketplaceDark: () => , +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +// Self-hosted List uses t() with returnObjects which returns string in mock; +// mock it to avoid deep i18n dependency (unit tests cover this component) +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( +
Features
+ ), +})) + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const defaultPlanData = { + type: Plan.sandbox, + usage: { + buildApps: 1, + teamMembers: 1, + documentsUploadQuota: 0, + vectorSpace: 10, + annotatedResponse: 1, + triggerEvents: 0, + apiRateLimit: 0, + }, + total: { + buildApps: 5, + teamMembers: 1, + documentsUploadQuota: 50, + vectorSpace: 50, + annotatedResponse: 10, + triggerEvents: 3000, + apiRateLimit: 5000, + }, +} + +const setupContexts = (planOverrides: Record = {}, appOverrides: Record = {}) => { + mockProviderCtx = { + plan: { ...defaultPlanData, ...planOverrides }, + enableBilling: true, + isFetchedPlan: true, + enableEducationPlan: false, + isEducationAccount: false, + allowRefreshEducationVerify: false, + } + mockAppCtx = { + isCurrentWorkspaceManager: true, + userProfile: { email: 'test@example.com' }, + langGeniusVersionInfo: { current_version: '1.0.0' }, + ...appOverrides, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +describe('Pricing Modal Flow', () => { + const onCancel = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupContexts() + }) + + // ─── 1. Initial Rendering ──────────────────────────────────────────────── + describe('Initial rendering', () => { + it('should render header with close button and footer with pricing link', () => { + render() + + // Header close button exists (multiple plan buttons also exist) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + // Footer pricing link + expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument() + }) + + it('should default to cloud category with three cloud plans', () => { + render() + + // Three cloud plans: sandbox, professional, team + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument() + }) + + it('should show plan range switcher (annual billing toggle) by default for cloud', () => { + render() + + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + + it('should show tax tip in footer for cloud category', () => { + render() + + // Use exact match to avoid matching taxTipSecond + expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument() + }) + }) + + // ─── 2. Category Switching ─────────────────────────────────────────────── + describe('Category switching', () => { + it('should switch to self-hosted plans when clicking self-hosted tab', async () => { + const user = userEvent.setup() + render() + + // Click the self-hosted tab + const selfTab = screen.getByText(/plansCommon\.self/i) + await user.click(selfTab) + + // Self-hosted plans should appear + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + + // Cloud plans should disappear + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + }) + + it('should hide plan range switcher for self-hosted category', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Annual billing toggle should not be visible + expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument() + }) + + it('should hide tax tip in footer for self-hosted category', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument() + }) + + it('should switch back to cloud plans when clicking cloud tab', async () => { + const user = userEvent.setup() + render() + + // Switch to self-hosted + await user.click(screen.getByText(/plansCommon\.self/i)) + expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument() + + // Switch back to cloud + await user.click(screen.getByText(/plansCommon\.cloud/i)) + expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument() + }) + }) + + // ─── 3. Plan Range Switching (Monthly ↔ Yearly) ────────────────────────── + describe('Plan range switching', () => { + it('should show monthly prices by default', () => { + render() + + // Professional monthly price: $59 + const proPriceStr = `$${ALL_PLANS.professional.price}` + expect(screen.getByText(proPriceStr)).toBeInTheDocument() + + // Team monthly price: $159 + const teamPriceStr = `$${ALL_PLANS.team.price}` + expect(screen.getByText(teamPriceStr)).toBeInTheDocument() + }) + + it('should show "Free" for sandbox plan regardless of range', () => { + render() + + expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() + }) + + it('should show "most popular" badge only for professional plan', () => { + render() + + expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument() + }) + }) + + // ─── 4. Cloud Plan Button States ───────────────────────────────────────── + describe('Cloud plan button states', () => { + it('should show "Current Plan" for the current plan (sandbox)', () => { + setupContexts({ type: Plan.sandbox }) + render() + + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + + it('should show specific button text for non-current plans', () => { + setupContexts({ type: Plan.sandbox }) + render() + + // Professional button text + expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument() + // Team button text + expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument() + }) + + it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => { + setupContexts({ type: Plan.enterprise }) + render() + + // Enterprise is normalized to team for display, so team is "Current Plan" + expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument() + }) + }) + + // ─── 5. Self-Hosted Plan Details ───────────────────────────────────────── + describe('Self-hosted plan details', () => { + it('should show cloud provider icons only for premium plan', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + // Premium plan should show Azure and Google Cloud icons + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should show "coming soon" text for premium plan cloud providers', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText(/plansCommon\.self/i)) + + expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument() + }) + }) + + // ─── 6. Close Handling ─────────────────────────────────────────────────── + describe('Close handling', () => { + it('should call onCancel when pressing ESC key', () => { + render() + + // ahooks useKeyPress listens on document for keydown events + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + bubbles: true, + })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // ─── 7. Pricing URL ───────────────────────────────────────────────────── + describe('Pricing page URL', () => { + it('should render pricing link with correct URL', () => { + render() + + const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i) + expect(link.closest('a')).toHaveAttribute( + 'href', + 'https://dify.ai/en/pricing#plans-and-features', + ) + }) + }) +}) diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx new file mode 100644 index 0000000000..810d36da8a --- /dev/null +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -0,0 +1,225 @@ +/** + * Integration test: Self-Hosted Plan Flow + * + * Tests the self-hosted plan items: + * SelfHostedPlanItem → Button click → permission check → redirect to external URL + * + * Covers community/premium/enterprise plan rendering, external URL navigation, + * and workspace manager permission enforcement. + */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' +import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' +import { SelfHostedPlan } from '@/app/components/billing/type' + +let mockAppCtx: Record = {} +const mockToastNotify = vi.fn() + +const originalLocation = window.location +let assignedHref = '' + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppCtx, +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), + useTheme: () => ({ theme: 'light' }), +})) + +vi.mock('@/app/components/base/icons/src/public/billing', () => ({ + Azure: () => , + GoogleCloud: () => , + AwsMarketplaceLight: () => , + AwsMarketplaceDark: () => , +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (args: unknown) => mockToastNotify(args) }, +})) + +vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ + default: ({ plan }: { plan: string }) => ( +
Features
+ ), +})) + +const setupAppContext = (overrides: Record = {}) => { + mockAppCtx = { + isCurrentWorkspaceManager: true, + ...overrides, + } +} + +describe('Self-Hosted Plan Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + cleanup() + setupAppContext() + + // Mock window.location with minimal getter/setter (Location props are non-enumerable) + assignedHref = '' + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { return assignedHref }, + set href(value: string) { assignedHref = value }, + }, + }) + }) + + afterEach(() => { + // Restore original location + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) + }) + + // ─── 1. Plan Rendering ────────────────────────────────────────────────── + describe('Plan rendering', () => { + it('should render community plan with name and description', () => { + render() + + expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() + expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument() + }) + + it('should render premium plan with cloud provider icons', () => { + render() + + expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() + expect(screen.getByTestId('icon-azure')).toBeInTheDocument() + expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument() + }) + + it('should render enterprise plan without cloud provider icons', () => { + render() + + expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() + expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument() + }) + + it('should not show price tip for community (free) plan', () => { + render() + + expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument() + }) + + it('should show price tip for premium plan', () => { + render() + + expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument() + }) + + it('should render features list for each plan', () => { + const { unmount: unmount1 } = render() + expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument() + unmount1() + + const { unmount: unmount2 } = render() + expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument() + unmount2() + + render() + expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument() + }) + + it('should show AWS marketplace icon for premium plan button', () => { + render() + + expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument() + }) + }) + + // ─── 2. Navigation Flow ───────────────────────────────────────────────── + describe('Navigation flow', () => { + it('should redirect to GitHub when clicking community plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getStartedWithCommunityUrl) + }) + + it('should redirect to AWS Marketplace when clicking premium plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getWithPremiumUrl) + }) + + it('should redirect to Typeform when clicking enterprise plan button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(contactSalesUrl) + }) + }) + + // ─── 3. Permission Check ──────────────────────────────────────────────── + describe('Permission check', () => { + it('should show error toast when non-manager clicks community button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + // Should NOT redirect + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks premium button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + + it('should show error toast when non-manager clicks enterprise button', async () => { + setupAppContext({ isCurrentWorkspaceManager: false }) + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + expect(assignedHref).toBe('') + }) + }) +}) diff --git a/web/app/components/billing/__tests__/config.spec.ts b/web/app/components/billing/__tests__/config.spec.ts new file mode 100644 index 0000000000..9e62c2162f --- /dev/null +++ b/web/app/components/billing/__tests__/config.spec.ts @@ -0,0 +1,141 @@ +import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config' +import { Priority } from '../type' + +describe('Billing Config', () => { + describe('Constants', () => { + it('should define NUM_INFINITE as -1', () => { + expect(NUM_INFINITE).toBe(-1) + }) + + it('should define contractSales string', () => { + expect(contractSales).toBe('contractSales') + }) + + it('should define unAvailable string', () => { + expect(unAvailable).toBe('unAvailable') + }) + + it('should define valid URL constants', () => { + expect(contactSalesUrl).toMatch(/^https:\/\//) + expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//) + expect(getWithPremiumUrl).toMatch(/^https:\/\//) + }) + }) + + describe('ALL_PLANS', () => { + const requiredFields: (keyof typeof ALL_PLANS.sandbox)[] = [ + 'level', + 'price', + 'modelProviders', + 'teamWorkspace', + 'teamMembers', + 'buildApps', + 'documents', + 'vectorSpace', + 'documentsUploadQuota', + 'documentsRequestQuota', + 'apiRateLimit', + 'documentProcessingPriority', + 'messageRequest', + 'triggerEvents', + 'annotatedResponse', + 'logHistory', + ] + + it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => { + const plan = ALL_PLANS[planKey] + for (const field of requiredFields) + expect(plan[field]).toBeDefined() + }) + + it('should have ascending plan levels: sandbox < professional < team', () => { + expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level) + expect(ALL_PLANS.professional.level).toBeLessThan(ALL_PLANS.team.level) + }) + + it('should have ascending plan prices: sandbox < professional < team', () => { + expect(ALL_PLANS.sandbox.price).toBeLessThan(ALL_PLANS.professional.price) + expect(ALL_PLANS.professional.price).toBeLessThan(ALL_PLANS.team.price) + }) + + it('should have sandbox as the free plan', () => { + expect(ALL_PLANS.sandbox.price).toBe(0) + }) + + it('should have ascending team member limits', () => { + expect(ALL_PLANS.sandbox.teamMembers).toBeLessThan(ALL_PLANS.professional.teamMembers) + expect(ALL_PLANS.professional.teamMembers).toBeLessThan(ALL_PLANS.team.teamMembers) + }) + + it('should have ascending document processing priority', () => { + expect(ALL_PLANS.sandbox.documentProcessingPriority).toBe(Priority.standard) + expect(ALL_PLANS.professional.documentProcessingPriority).toBe(Priority.priority) + expect(ALL_PLANS.team.documentProcessingPriority).toBe(Priority.topPriority) + }) + + it('should have unlimited API rate limit for professional and team plans', () => { + expect(ALL_PLANS.sandbox.apiRateLimit).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.professional.apiRateLimit).toBe(NUM_INFINITE) + expect(ALL_PLANS.team.apiRateLimit).toBe(NUM_INFINITE) + }) + + it('should have unlimited log history for professional and team plans', () => { + expect(ALL_PLANS.professional.logHistory).toBe(NUM_INFINITE) + expect(ALL_PLANS.team.logHistory).toBe(NUM_INFINITE) + }) + + it('should have unlimited trigger events only for team plan', () => { + expect(ALL_PLANS.sandbox.triggerEvents).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.professional.triggerEvents).not.toBe(NUM_INFINITE) + expect(ALL_PLANS.team.triggerEvents).toBe(NUM_INFINITE) + }) + }) + + describe('defaultPlan', () => { + it('should default to sandbox plan type', () => { + expect(defaultPlan.type).toBe('sandbox') + }) + + it('should have usage object with all required fields', () => { + const { usage } = defaultPlan + expect(usage).toHaveProperty('documents') + expect(usage).toHaveProperty('vectorSpace') + expect(usage).toHaveProperty('buildApps') + expect(usage).toHaveProperty('teamMembers') + expect(usage).toHaveProperty('annotatedResponse') + expect(usage).toHaveProperty('documentsUploadQuota') + expect(usage).toHaveProperty('apiRateLimit') + expect(usage).toHaveProperty('triggerEvents') + }) + + it('should have total object with all required fields', () => { + const { total } = defaultPlan + expect(total).toHaveProperty('documents') + expect(total).toHaveProperty('vectorSpace') + expect(total).toHaveProperty('buildApps') + expect(total).toHaveProperty('teamMembers') + expect(total).toHaveProperty('annotatedResponse') + expect(total).toHaveProperty('documentsUploadQuota') + expect(total).toHaveProperty('apiRateLimit') + expect(total).toHaveProperty('triggerEvents') + }) + + it('should use sandbox plan API rate limit and trigger events in total', () => { + expect(defaultPlan.total.apiRateLimit).toBe(ALL_PLANS.sandbox.apiRateLimit) + expect(defaultPlan.total.triggerEvents).toBe(ALL_PLANS.sandbox.triggerEvents) + }) + + it('should have reset info with null values', () => { + expect(defaultPlan.reset.apiRateLimit).toBeNull() + expect(defaultPlan.reset.triggerEvents).toBeNull() + }) + + it('should have usage values not exceeding totals', () => { + expect(defaultPlan.usage.documents).toBeLessThanOrEqual(defaultPlan.total.documents) + expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace) + expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps) + expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers) + expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse) + }) + }) +}) diff --git a/web/app/components/billing/annotation-full/index.spec.tsx b/web/app/components/billing/annotation-full/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/billing/annotation-full/index.spec.tsx rename to web/app/components/billing/annotation-full/__tests__/index.spec.tsx index 2090605692..c98cb9fa5d 100644 --- a/web/app/components/billing/annotation-full/index.spec.tsx +++ b/web/app/components/billing/annotation-full/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' -import AnnotationFull from './index' +import AnnotationFull from '../index' -vi.mock('./usage', () => ({ +vi.mock('../usage', () => ({ default: (props: { className?: string }) => { return (
@@ -11,7 +11,7 @@ vi.mock('./usage', () => ({ }, })) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: (props: { loc?: string }) => { return ( , })) @@ -70,6 +70,42 @@ describe('HeaderBillingBtn', () => { expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) + it('renders team badge for team plan with correct styling', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.team }, + enableBilling: true, + isFetchedPlan: true, + }) + + render() + + const badge = screen.getByText('team').closest('div') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-[#E0EAFF]') + }) + + it('renders nothing when plan is not fetched', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.professional }, + enableBilling: true, + isFetchedPlan: false, + }) + + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders sandbox upgrade btn with undefined onClick in display-only mode', () => { + ensureProviderContextMock().mockReturnValueOnce({ + plan: { type: Plan.sandbox }, + enableBilling: true, + isFetchedPlan: true, + }) + + render() + expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() + }) + it('renders plan badge and forwards clicks when not display-only', () => { const onClick = vi.fn() diff --git a/web/app/components/billing/partner-stack/index.spec.tsx b/web/app/components/billing/partner-stack/__tests__/index.spec.tsx similarity index 57% rename from web/app/components/billing/partner-stack/index.spec.tsx rename to web/app/components/billing/partner-stack/__tests__/index.spec.tsx index d0dc9623c2..d8182c4103 100644 --- a/web/app/components/billing/partner-stack/index.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import PartnerStack from './index' +import PartnerStack from '../index' let isCloudEdition = true @@ -12,7 +12,7 @@ vi.mock('@/config', () => ({ }, })) -vi.mock('./use-ps-info', () => ({ +vi.mock('../use-ps-info', () => ({ default: () => ({ saveOrUpdate, bind, @@ -40,4 +40,23 @@ describe('PartnerStack', () => { expect(saveOrUpdate).toHaveBeenCalledTimes(1) expect(bind).toHaveBeenCalledTimes(1) }) + + it('renders null (no visible DOM)', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('does not call helpers again on rerender', () => { + const { rerender } = render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + expect(bind).toHaveBeenCalledTimes(1) + + rerender() + + // useEffect with [] should not run again on rerender + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + expect(bind).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx similarity index 60% rename from web/app/components/billing/partner-stack/use-ps-info.spec.tsx rename to web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx index 03ee03fc81..ec79d18d29 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react' import { PARTNER_STACK_CONFIG } from '@/config' -import usePSInfo from './use-ps-info' +import usePSInfo from '../use-ps-info' let searchParamsValues: Record = {} const setSearchParams = (values: Record) => { @@ -193,4 +193,107 @@ describe('usePSInfo', () => { domain: '.dify.ai', }) }) + + // Cookie parse failure: covers catch block (L14-16) + it('should fall back to empty object when cookie contains invalid JSON', () => { + const { get } = ensureCookieMocks() + get.mockReturnValue('not-valid-json{{{') + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + setSearchParams({ + ps_partner_key: 'from-url', + ps_xid: 'click-url', + }) + + const { result } = renderHook(() => usePSInfo()) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to parse partner stack info from cookie:', + expect.any(SyntaxError), + ) + // Should still pick up values from search params + expect(result.current.psPartnerKey).toBe('from-url') + expect(result.current.psClickId).toBe('click-url') + consoleSpy.mockRestore() + }) + + // No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch) + it('should not save or bind when neither search params nor cookie have keys', () => { + const { get, set } = ensureCookieMocks() + get.mockReturnValue('{}') + setSearchParams({}) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBeUndefined() + expect(result.current.psClickId).toBeUndefined() + + act(() => { + result.current.saveOrUpdate() + }) + expect(set).not.toHaveBeenCalled() + }) + + it('should not call mutateAsync when keys are missing during bind', async () => { + const { get } = ensureCookieMocks() + get.mockReturnValue('{}') + setSearchParams({}) + + const { result } = renderHook(() => usePSInfo()) + + const mutate = ensureMutateAsync() + await act(async () => { + await result.current.bind() + }) + + expect(mutate).not.toHaveBeenCalled() + }) + + // Non-400 error: covers L55 false branch (shouldRemoveCookie stays false) + it('should not remove cookie when bind fails with non-400 error', async () => { + const mutate = ensureMutateAsync() + mutate.mockRejectedValueOnce({ status: 500 }) + setSearchParams({ + ps_partner_key: 'bind-partner', + ps_xid: 'bind-click', + }) + + const { result } = renderHook(() => usePSInfo()) + + await act(async () => { + await result.current.bind() + }) + + const { remove } = ensureCookieMocks() + expect(remove).not.toHaveBeenCalled() + }) + + // Fallback to cookie values: covers L19-20 right side of || operator + it('should use cookie values when search params are absent', () => { + const { get } = ensureCookieMocks() + get.mockReturnValue(JSON.stringify({ + partnerKey: 'cookie-partner', + clickId: 'cookie-click', + })) + setSearchParams({}) + + const { result } = renderHook(() => usePSInfo()) + + expect(result.current.psPartnerKey).toBe('cookie-partner') + expect(result.current.psClickId).toBe('cookie-click') + }) + + // Partial key missing: only partnerKey present, no clickId + it('should not save when only one key is available', () => { + const { get, set } = ensureCookieMocks() + get.mockReturnValue('{}') + setSearchParams({ ps_partner_key: 'partial-key' }) + + const { result } = renderHook(() => usePSInfo()) + + act(() => { + result.current.saveOrUpdate() + }) + + expect(set).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/billing/plan-upgrade-modal/index.spec.tsx b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/billing/plan-upgrade-modal/index.spec.tsx rename to web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx index 5dc7515344..b28ffffa53 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.spec.tsx +++ b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import PlanUpgradeModal from './index' +import PlanUpgradeModal from '../index' const mockSetShowPricingModal = vi.fn() @@ -39,13 +39,11 @@ describe('PlanUpgradeModal', () => { // Rendering and props-driven content it('should render modal with provided content when visible', () => { - // Arrange const extraInfoText = 'Additional upgrade details' renderComponent({ extraInfo:
{extraInfoText}
, }) - // Assert expect(screen.getByText(baseProps.title)).toBeInTheDocument() expect(screen.getByText(baseProps.description)).toBeInTheDocument() expect(screen.getByText(extraInfoText)).toBeInTheDocument() @@ -55,40 +53,32 @@ describe('PlanUpgradeModal', () => { // Guard against rendering when modal is hidden it('should not render content when show is false', () => { - // Act renderComponent({ show: false }) - // Assert expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument() expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument() }) // User closes the modal from dismiss button it('should call onClose when dismiss button is clicked', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() renderComponent({ onClose }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.dismiss')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) }) // Upgrade path uses provided callback over pricing modal it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() const onUpgrade = vi.fn() renderComponent({ onClose, onUpgrade }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) expect(onUpgrade).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() @@ -96,15 +86,12 @@ describe('PlanUpgradeModal', () => { // Fallback upgrade path opens pricing modal when no onUpgrade is supplied it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => { - // Arrange const user = userEvent.setup() const onClose = vi.fn() renderComponent({ onClose, onUpgrade: undefined }) - // Act await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) - // Assert expect(onClose).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx similarity index 69% rename from web/app/components/billing/plan/index.spec.tsx rename to web/app/components/billing/plan/__tests__/index.spec.tsx index db22b47db4..79597b4b22 100644 --- a/web/app/components/billing/plan/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' -import { Plan } from '../type' -import PlanComp from './index' +import { Plan, SelfHostedPlan } from '../../type' +import PlanComp from '../index' let currentPath = '/billing' @@ -14,8 +14,7 @@ vi.mock('next/navigation', () => ({ const setShowAccountSettingModalMock = vi.fn() vi.mock('@/context/modal-context', () => ({ - // eslint-disable-next-line ts/no-explicit-any - useModalContextSelector: (selector: any) => selector({ + useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({ setShowAccountSettingModal: setShowAccountSettingModalMock, }), })) @@ -47,11 +46,10 @@ const verifyStateModalMock = vi.fn(props => (
)) vi.mock('@/app/education-apply/verify-state-modal', () => ({ - // eslint-disable-next-line ts/no-explicit-any - default: (props: any) => verifyStateModalMock(props), + default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props), })) -vi.mock('../upgrade-btn', () => ({ +vi.mock('../../upgrade-btn', () => ({ default: () => , })) @@ -172,6 +170,66 @@ describe('PlanComp', () => { expect(screen.getByText('education.toVerified')).toBeInTheDocument() }) + it('renders enterprise plan without upgrade button', () => { + providerContextMock.mockReturnValue({ + plan: { ...planMock, type: SelfHostedPlan.enterprise }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render() + + expect(screen.getByText('billing.plans.enterprise.name')).toBeInTheDocument() + expect(screen.queryByTestId('plan-upgrade-btn')).not.toBeInTheDocument() + }) + + it('shows apiRateLimit reset info for sandbox plan', () => { + providerContextMock.mockReturnValue({ + plan: { + ...planMock, + type: Plan.sandbox, + total: { ...planMock.total, apiRateLimit: 5000 }, + reset: { ...planMock.reset, apiRateLimit: null }, + }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render() + + // Sandbox plan with finite apiRateLimit and null reset uses getDaysUntilEndOfMonth() + expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument() + }) + + it('shows apiRateLimit reset info when reset is a number', () => { + providerContextMock.mockReturnValue({ + plan: { + ...planMock, + type: Plan.professional, + total: { ...planMock.total, apiRateLimit: 5000 }, + reset: { ...planMock.reset, apiRateLimit: 3 }, + }, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render() + + expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument() + }) + + it('does not show education verify when enableEducationPlan is false', () => { + providerContextMock.mockReturnValue({ + plan: planMock, + enableEducationPlan: false, + allowRefreshEducationVerify: false, + isEducationAccount: false, + }) + render() + + expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument() + }) + it('handles modal onConfirm and onCancel callbacks', async () => { mutateAsyncMock.mockRejectedValueOnce(new Error('boom')) render() diff --git a/web/app/components/billing/plan/assets/enterprise.spec.tsx b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/enterprise.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx index 8d5dd8347a..08458035ff 100644 --- a/web/app/components/billing/plan/assets/enterprise.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/enterprise.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Enterprise from './enterprise' +import Enterprise from '../enterprise' describe('Enterprise Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/index.spec.tsx b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/billing/plan/assets/index.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/index.spec.tsx index 4d44a6e6d1..9fde4a4094 100644 --- a/web/app/components/billing/plan/assets/index.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import { render } from '@testing-library/react' -import EnterpriseDirect from './enterprise' +import EnterpriseDirect from '../enterprise' -import { Enterprise, Professional, Sandbox, Team } from './index' -import ProfessionalDirect from './professional' +import { Enterprise, Professional, Sandbox, Team } from '../index' +import ProfessionalDirect from '../professional' // Import real components for comparison -import SandboxDirect from './sandbox' -import TeamDirect from './team' +import SandboxDirect from '../sandbox' +import TeamDirect from '../team' describe('Billing Plan Assets - Integration Tests', () => { describe('Exports', () => { diff --git a/web/app/components/billing/plan/assets/professional.spec.tsx b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/professional.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/professional.spec.tsx index f8cccac40f..dcd63711fa 100644 --- a/web/app/components/billing/plan/assets/professional.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/professional.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Professional from './professional' +import Professional from '../professional' describe('Professional Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/sandbox.spec.tsx b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/sandbox.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx index 024213cf5a..7d256b4fc1 100644 --- a/web/app/components/billing/plan/assets/sandbox.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/sandbox.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import * as React from 'react' -import Sandbox from './sandbox' +import Sandbox from '../sandbox' describe('Sandbox Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/plan/assets/team.spec.tsx b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx similarity index 99% rename from web/app/components/billing/plan/assets/team.spec.tsx rename to web/app/components/billing/plan/assets/__tests__/team.spec.tsx index d4d1e713d8..ffd5571a4d 100644 --- a/web/app/components/billing/plan/assets/team.spec.tsx +++ b/web/app/components/billing/plan/assets/__tests__/team.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react' -import Team from './team' +import Team from '../team' describe('Team Icon Component', () => { describe('Rendering', () => { diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/__tests__/footer.spec.tsx similarity index 87% rename from web/app/components/billing/pricing/footer.spec.tsx rename to web/app/components/billing/pricing/__tests__/footer.spec.tsx index 85bd72c247..7ef78180de 100644 --- a/web/app/components/billing/pricing/footer.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/footer.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import { CategoryEnum } from '.' -import Footer from './footer' +import { CategoryEnum } from '..' +import Footer from '../footer' vi.mock('next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( @@ -16,13 +16,10 @@ describe('Footer', () => { vi.clearAllMocks() }) - // Rendering behavior describe('Rendering', () => { it('should render tax tips and comparison link when in cloud category', () => { - // Arrange render(