From c4a3be7fb65bd5b4f362ddde66747d7d807ed46f Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 10 Feb 2026 12:39:46 +0800 Subject: [PATCH] test: add comprehensive tests for billing integration and partner stack info handling - Introduced a new test suite for the billing integration, covering the rendering of the billing page and plan components, ensuring all usage metrics are displayed correctly. - Enhanced tests for the partner stack info hook to handle various scenarios, including invalid cookie JSON, absence of keys, and error handling during binding. - Updated existing tests for the CloudPlanItem component to cover additional edge cases and ensure proper behavior during user interactions. --- web/__tests__/billing-integration.test.tsx | 996 ++++++++++++++++++ .../partner-stack/use-ps-info.spec.tsx | 103 ++ .../plans/cloud-plan-item/index.spec.tsx | 137 ++- web/eslint-suppressions.json | 5 - 4 files changed, 1229 insertions(+), 12 deletions(-) create mode 100644 web/__tests__/billing-integration.test.tsx diff --git a/web/__tests__/billing-integration.test.tsx b/web/__tests__/billing-integration.test.tsx new file mode 100644 index 0000000000..edf92bce62 --- /dev/null +++ b/web/__tests__/billing-integration.test.tsx @@ -0,0 +1,996 @@ +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 { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' + +// ─── Module-level mock state ──────────────────────────────────────────────── +let mockProviderCtx: Record = {} +let mockAppCtx: Record = {} +const mockSetShowPricingModal = vi.fn() +const mockSetShowAccountSettingModal = 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', + 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, + } +} + +// ─── Imports (after mocks) ────────────────────────────────────────────────── +// These must be imported after all vi.mock() calls +/* eslint-disable import/first */ +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 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 UpgradeBtn from '@/app/components/billing/upgrade-btn' +import VectorSpaceFull from '@/app/components/billing/vector-space-full' +/* eslint-enable import/first */ + +// ═══════════════════════════════════════════════════════════════════════════ +// 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/app/components/billing/partner-stack/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx index 03ee03fc81..730b0d5d43 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx +++ b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx @@ -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/pricing/plans/cloud-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx index a7945a7203..42406fc91f 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx @@ -66,13 +66,6 @@ beforeAll(() => { }) }) -afterAll(() => { - Object.defineProperty(window, 'location', { - configurable: true, - value: originalLocation, - }) -}) - beforeEach(() => { vi.clearAllMocks() mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) @@ -82,6 +75,13 @@ beforeEach(() => { assignedHref = '' }) +afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) +}) + describe('CloudPlanItem', () => { // Static content for each plan describe('Rendering', () => { @@ -192,5 +192,128 @@ describe('CloudPlanItem', () => { expect(assignedHref).toBe('https://subscription.example') }) }) + + // Covers L92-93: isFreePlan guard inside handleGetPayUrl + it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => { + render( + , + ) + + // Sandbox viewed from a higher plan is disabled, but let's verify no API calls + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + expect(mockBillingInvoices).not.toHaveBeenCalled() + expect(assignedHref).toBe('') + }) + }) + + // Covers L95: yearly subscription URL ('year' parameter) + it('should fetch yearly subscription url when planRange is yearly', async () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' })) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year') + expect(assignedHref).toBe('https://subscription.example') + }) + }) + + // Covers L62-63: loading guard prevents double click + it('should ignore second click while loading', async () => { + // Make the first fetch hang until we resolve it + let resolveFirst!: (v: { url: string }) => void + mockFetchSubscriptionUrls.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve }), + ) + + render( + , + ) + + const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }) + + // First click starts loading + fireEvent.click(button) + // Second click while loading should be ignored + fireEvent.click(button) + + // Resolve first request + resolveFirst({ url: 'https://first.example' }) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1) + }) + }) + + // Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url + it('should invoke onError when billing invoices returns empty url', async () => { + mockBillingInvoices.mockResolvedValue({ url: '' }) + const openWindow = vi.fn(async (cb: () => Promise, opts: { onError?: (e: Error) => void }) => { + try { + await cb() + } + catch (e) { + opts.onError?.(e as Error) + } + }) + mockUseAsyncWindowOpen.mockReturnValue(openWindow) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) + + await waitFor(() => { + expect(openWindow).toHaveBeenCalledTimes(1) + // The onError callback should have been passed to openAsyncWindow + const callArgs = openWindow.mock.calls[0] + expect(callArgs[1]).toHaveProperty('onError') + }) + }) + + // Covers monthly price display (L139 !isYear branch for price) + it('should display monthly pricing without discount', () => { + render( + , + ) + + const teamPlan = ALL_PLANS[Plan.team] + expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument() + expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument() + // Should NOT show crossed-out yearly price + expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument() + }) }) }) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 5684380979..475d0c14e4 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3002,11 +3002,6 @@ "count": 1 } }, - "app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": { - "test/prefer-hooks-in-order": { - "count": 1 - } - }, "app/components/billing/pricing/plans/cloud-plan-item/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 6