Compare commits

...

5 Commits

Author SHA1 Message Date
CodingOnStar
79f1123e57 test: update self-hosted plan flow tests to use mocked location href
- Refactored self-hosted plan flow tests to replace the window.location.href setter with a mocked getter/setter for improved clarity and reliability.
- Updated assertions to check the assigned href directly instead of relying on the setter function, ensuring accurate validation of redirection behavior.
- Enhanced test setup and teardown processes for better isolation and consistency across test cases.
2026-02-11 15:47:33 +08:00
CodingOnStar
62d6187fbf test: add comprehensive integration tests for billing components
- Introduced new integration tests for various billing components, including Billing, CloudPlanItem, and SelfHostedPlanItem.
- Validated the functionality of the education verification flow and partner stack integration.
- Enhanced tests for pricing modal interactions, ensuring proper rendering and state management across components.
- Covered edge cases and user interactions to ensure robustness in billing-related features.
2026-02-11 13:47:20 +08:00
CodingOnStar
ba289c560b refactor: clean up eslint suppressions and enhance billing component tests
- Removed unnecessary eslint suppressions for self-hosted plan item and upgrade button test files.
- Added comprehensive tests for billing components, including AnnotationFull, AnnotationFullModal, and AppsFull, ensuring proper rendering and functionality.
- Introduced new tests for billing configuration and plan components, validating constants and plan properties.
- Enhanced existing tests for Billing and HeaderBillingBtn components to cover various scenarios and edge cases.
2026-02-11 13:28:42 +08:00
CodingOnStar
40f1d91545 Merge remote-tracking branch 'origin/main' into test/billing 2026-02-11 13:25:38 +08:00
CodingOnStar
c4a3be7fb6 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.
2026-02-10 12:39:46 +08:00
53 changed files with 3431 additions and 625 deletions

View File

@@ -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<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
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<string, unknown>) => 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 ? <div data-testid="verify-state-modal" /> : null,
}))
vi.mock('@/app/components/header/utils/util', () => ({
mailToSupport: () => 'mailto:support@test.com',
}))
// ─── Test data factories ────────────────────────────────────────────────────
type PlanOverrides = {
type?: string
usage?: Partial<UsagePlanInfo>
total?: Partial<UsagePlanInfo>
reset?: Partial<UsageResetInfo>
}
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<string, unknown> = {}) => {
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
...extra,
}
}
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
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(<Billing />)
// 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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
// 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(<Billing />)
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(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
render(<Billing />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<UpgradeBtn />)
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(<UpgradeBtn isPlain />)
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(<UpgradeBtn onClick={customOnClick} />)
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<string, unknown>).gtag = mockGtag
const user = userEvent.setup()
render(<UpgradeBtn loc="billing-page" />)
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<string, unknown>).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(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Upgrade Required"
description="You need a better plan"
/>,
)
// 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(
<PlanUpgradeModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
title="Test"
description="Test"
/>,
)
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(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Test"
description="Test"
/>,
)
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(<PlanComp loc="test-loc" />)
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(<AppsFull loc="test" />)
// 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(<AppsFull loc="test" />)
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(<AppsFull loc="test" />)
// 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(<AppsFull loc="test" />)
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(<VectorSpaceFull />)
// 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(<AnnotationFull />)
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(<AnnotationFullModal show={true} onHide={vi.fn()} />)
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(<AnnotationFullModal show={false} onHide={vi.fn()} />)
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(
<TriggerEventsLimitModal
show={true}
onClose={vi.fn()}
onUpgrade={vi.fn()}
usage={18000}
total={20000}
resetInDays={5}
/>,
)
// 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(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
usage={20000}
total={20000}
/>,
)
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(<HeaderBillingBtn />)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render "pro" badge for professional plan', () => {
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn />)
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(<HeaderBillingBtn />)
expect(screen.getByText('team')).toBeInTheDocument()
})
it('should return null when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should return null when plan is not fetched yet', () => {
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
const { container } = render(<HeaderBillingBtn />)
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(<HeaderBillingBtn onClick={onClick} />)
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(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
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(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
})
it('should display "priority" for professional plan with icon', () => {
setupProviderContext({ type: Plan.professional })
const { container } = render(<PriorityLabel />)
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(<PriorityLabel />)
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(<PriorityLabel />)
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(<PlanComp loc="test" />)
// Storage mode: usage below threshold shows "< 50"
expect(screen.getByText(/</)).toBeInTheDocument()
})
it('should show indeterminate progress bar for usage below threshold', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 10 },
total: { vectorSpace: 50 },
})
render(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
// 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(<AppsFull loc="app-create" />)
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(<VectorSpaceFull />)
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(<AnnotationFull />)
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
show={true}
onClose={onClose}
onUpgrade={vi.fn()}
usage={20000}
total={20000}
/>,
)
// 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(<AnnotationFullModal show={true} onHide={vi.fn()} />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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<string, unknown> = {}
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<string, unknown> = {}) => {
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(
<CloudPlanItem
currentPlan={currentPlan}
plan={plan}
planRange={planRange}
canPay={canPay}
/>,
)
}
// ═══════════════════════════════════════════════════════════════════════════════
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()
})
})
})

View File

@@ -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<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
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<string, unknown>) => 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
? (
<div data-testid="verify-state-modal">
{title && <span data-testid="modal-title">{title}</span>}
{content && <span data-testid="modal-content">{content}</span>}
{email && <span data-testid="modal-email">{email}</span>}
{showLink && <span data-testid="modal-show-link">link</span>}
</div>
)
: null,
}))
// ─── Test data factories ────────────────────────────────────────────────────
type PlanOverrides = {
type?: string
usage?: Partial<UsagePlanInfo>
total?: Partial<UsagePlanInfo>
reset?: Partial<UsageResetInfo>
}
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<string, unknown> = {},
appOverrides: Record<string, unknown> = {},
) => {
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
// 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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
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(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
})
})
})

View File

@@ -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<Record<string, unknown>>()
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<string, string>) => {
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(<PartnerStack />)
// 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(<PartnerStack />)
expect(container.innerHTML).toBe('')
})
})
})

View File

@@ -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<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
// ─── 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: () => <span data-testid="icon-azure" />,
GoogleCloud: () => <span data-testid="icon-gcloud" />,
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
}))
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 }) => (
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
),
}))
// ─── 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<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => {
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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
})
it('should show tax tip in footer for cloud category', () => {
render(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
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(<Pricing onCancel={onCancel} />)
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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
})
it('should show "most popular" badge only for professional plan', () => {
render(<Pricing onCancel={onCancel} />)
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(<Pricing onCancel={onCancel} />)
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
})
it('should show specific button text for non-current plans', () => {
setupContexts({ type: Plan.sandbox })
render(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
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(<Pricing onCancel={onCancel} />)
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(<Pricing onCancel={onCancel} />)
// 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(<Pricing onCancel={onCancel} />)
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
expect(link.closest('a')).toHaveAttribute(
'href',
'https://dify.ai/en/pricing#plans-and-features',
)
})
})
})

View File

@@ -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<string, unknown> = {}
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: () => <span data-testid="icon-azure" />,
GoogleCloud: () => <span data-testid="icon-gcloud" />,
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
}))
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 }) => (
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
),
}))
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
})
it('should show price tip for premium plan', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
})
it('should render features list for each plan', () => {
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
unmount1()
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
unmount2()
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
})
it('should show AWS marketplace icon for premium plan button', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
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(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
expect(assignedHref).toBe('')
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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 (
<div data-testid="usage-component" data-classname={props.className ?? ''}>
@@ -11,7 +11,7 @@ vi.mock('./usage', () => ({
},
}))
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: (props: { loc?: string }) => {
return (
<button type="button" data-testid="upgrade-btn">
@@ -29,27 +29,21 @@ describe('AnnotationFull', () => {
// Rendering marketing copy with action button
describe('Rendering', () => {
it('should render tips when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
})
it('should render upgrade button when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should render Usage component when rendered', () => {
// Act
render(<AnnotationFull />)
// Assert
const usageComponent = screen.getByTestId('usage-component')
expect(usageComponent).toBeInTheDocument()
})

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
import AnnotationFullModal from '../modal'
vi.mock('./usage', () => ({
vi.mock('../usage', () => ({
default: (props: { className?: string }) => {
return (
<div data-testid="usage-component" data-classname={props.className ?? ''}>
@@ -12,7 +12,7 @@ vi.mock('./usage', () => ({
}))
let mockUpgradeBtnProps: { loc?: string } | null = null
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: (props: { loc?: string }) => {
mockUpgradeBtnProps = props
return (
@@ -29,7 +29,7 @@ type ModalSnapshot = {
className?: string
}
let mockModalProps: ModalSnapshot | null = null
vi.mock('../../base/modal', () => ({
vi.mock('../../../base/modal', () => ({
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => {
mockModalProps = {
isShow,
@@ -61,10 +61,8 @@ describe('AnnotationFullModal', () => {
// Rendering marketing copy inside modal
describe('Rendering', () => {
it('should display main info when visible', () => {
// Act
render(<AnnotationFullModal show onHide={vi.fn()} />)
// Assert
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4')
@@ -81,10 +79,8 @@ describe('AnnotationFullModal', () => {
// Controlling modal visibility
describe('Visibility', () => {
it('should not render content when hidden', () => {
// Act
const { container } = render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
// Assert
expect(container).toBeEmptyDOMElement()
expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false }))
})
@@ -93,14 +89,11 @@ describe('AnnotationFullModal', () => {
// Handling close interactions
describe('Close handling', () => {
it('should trigger onHide when close control is clicked', () => {
// Arrange
const onHide = vi.fn()
// Act
render(<AnnotationFullModal show onHide={onHide} />)
fireEvent.click(screen.getByTestId('mock-modal-close'))
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,11 +1,5 @@
import { render, screen } from '@testing-library/react'
import Usage from './usage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
import Usage from '../usage'
const mockPlan = {
usage: {
@@ -23,33 +17,25 @@ vi.mock('@/context/provider-context', () => ({
}))
describe('Usage', () => {
// Rendering: renders UsageInfo with correct props from context
describe('Rendering', () => {
it('should render usage info with data from provider context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument()
})
it('should pass className to UsageInfo component', () => {
// Arrange
const testClassName = 'mt-4'
// Act
const { container } = render(<Usage className={testClassName} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass(testClassName)
})
it('should display usage and total values from context', () => {
// Arrange & Act
render(<Usage />)
// Assert
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
})

View File

@@ -8,7 +8,7 @@ import { Plan } from '@/app/components/billing/type'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import AppsFull from './index'
import AppsFull from '../index'
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
@@ -120,10 +120,8 @@ describe('AppsFull', () => {
// Rendering behavior for non-team plans.
describe('Rendering', () => {
it('should render the sandbox messaging and upgrade button', () => {
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
@@ -131,10 +129,8 @@ describe('AppsFull', () => {
})
})
// Prop-driven behavior for team plans and contact CTA.
describe('Props', () => {
it('should render team messaging and contact button for non-sandbox plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -149,7 +145,6 @@ describe('AppsFull', () => {
}))
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
@@ -158,7 +153,6 @@ describe('AppsFull', () => {
})
it('should render upgrade button for professional plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -172,17 +166,14 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
})
it('should render contact button for enterprise plans', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -196,10 +187,8 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
@@ -207,10 +196,8 @@ describe('AppsFull', () => {
})
})
// Edge cases for progress color thresholds.
describe('Edge Cases', () => {
it('should use the success color when usage is below 50%', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -224,15 +211,12 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
})
it('should use the warning color when usage is between 50% and 80%', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -246,15 +230,12 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
})
it('should use the error color when usage is 80% or higher', () => {
// Arrange
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -268,10 +249,8 @@ describe('AppsFull', () => {
},
}))
// Act
render(<AppsFull loc="billing_dialog" />)
// Assert
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
})
})

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Billing from './index'
import Billing from '../index'
let currentBillingUrl: string | null = 'https://billing'
let fetching = false
@@ -33,7 +33,7 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('../plan', () => ({
vi.mock('../../plan', () => ({
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
}))

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '../type'
import HeaderBillingBtn from './index'
import { Plan } from '../../type'
import HeaderBillingBtn from '../index'
type HeaderGlobal = typeof globalThis & {
__mockProviderContext?: ReturnType<typeof vi.fn>
@@ -26,7 +26,7 @@ vi.mock('@/context/provider-context', () => {
}
})
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
}))
@@ -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(<HeaderBillingBtn />)
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(<HeaderBillingBtn />)
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(<HeaderBillingBtn isDisplayOnly />)
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('renders plan badge and forwards clicks when not display-only', () => {
const onClick = vi.fn()

View File

@@ -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(<PartnerStack />)
expect(container.innerHTML).toBe('')
})
it('does not call helpers again on rerender', () => {
const { rerender } = render(<PartnerStack />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
rerender(<PartnerStack />)
// useEffect with [] should not run again on rerender
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
expect(bind).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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<string, string | null> = {}
const setSearchParams = (values: Record<string, string | null>) => {
@@ -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()
})
})

View File

@@ -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: <div>{extraInfoText}</div>,
})
// 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)
})

View File

@@ -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 => (
</div>
))
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: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
}))
@@ -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(<PlanComp loc="billing-page" />)
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(<PlanComp loc="billing-page" />)
// 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(<PlanComp loc="billing-page" />)
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(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument()
})
it('handles modal onConfirm and onCancel callbacks', async () => {
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
render(<PlanComp loc="billing-page" />)

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Enterprise from './enterprise'
import Enterprise from '../enterprise'
describe('Enterprise Icon Component', () => {
describe('Rendering', () => {

View File

@@ -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', () => {

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Professional from './professional'
import Professional from '../professional'
describe('Professional Icon Component', () => {
describe('Rendering', () => {

View File

@@ -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', () => {

View File

@@ -1,5 +1,5 @@
import { render } from '@testing-library/react'
import Team from './team'
import Team from '../team'
describe('Team Icon Component', () => {
describe('Rendering', () => {

View File

@@ -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(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />)
// Assert
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
@@ -30,25 +27,19 @@ describe('Footer', () => {
})
})
// Prop-driven behavior
describe('Props', () => {
it('should hide tax tips when category is self-hosted', () => {
// Arrange
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />)
// Assert
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render link even when pricing URL is empty', () => {
// Arrange
render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />)
// Assert
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
})
})

View File

@@ -1,74 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from './header'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import Header from '../header'
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render title and description translations', () => {
// Arrange
const handleClose = vi.fn()
// Act
render(<Header onClose={handleClose} />)
// Assert
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should invoke onClose when close button is clicked', () => {
// Arrange
const handleClose = vi.fn()
render(<Header onClose={handleClose} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render structure when translations are empty strings', () => {
// Arrange
mockTranslations = {
'billing.plansCommon.title.plans': '',
'billing.plansCommon.title.description': '',
}
// Act
it('should render structural elements with translation keys', () => {
const { container } = render(<Header onClose={vi.fn()} />)
// Assert
expect(container.querySelector('span')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()

View File

@@ -1,17 +1,24 @@
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../type'
import type { UsagePlanInfo } from '../../type'
import { fireEvent, render, screen } from '@testing-library/react'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import Pricing from './index'
import { Plan } from '../../type'
import Pricing from '../index'
let mockTranslations: Record<string, string> = {}
let mockLanguage: string | null = 'en'
vi.mock('../plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`list-${plan}`}>
List for
{plan}
</div>
),
}))
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
<a href={href} className={className} target={target} data-testid="pricing-link">
@@ -20,10 +27,6 @@ vi.mock('next/link', () => ({
),
}))
vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -36,24 +39,6 @@ vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
}))
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { returnObjects?: boolean, ns?: string }) => {
if (options?.returnObjects)
return mockTranslations[key] ?? []
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}
})
const buildUsage = (): UsagePlanInfo => ({
buildApps: 0,
teamMembers: 0,
@@ -67,7 +52,6 @@ const buildUsage = (): UsagePlanInfo => ({
describe('Pricing', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
mockLanguage = 'en'
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
;(useProviderContext as Mock).mockReturnValue({
@@ -80,42 +64,33 @@ describe('Pricing', () => {
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
})
// Rendering behavior
describe('Rendering', () => {
it('should render pricing header and localized footer link', () => {
// Arrange
render(<Pricing onCancel={vi.fn()} />)
// Assert
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should register esc key handler and allow switching categories', () => {
// Arrange
it('should allow switching categories and handle esc key', () => {
const handleCancel = vi.fn()
render(<Pricing onCancel={handleCancel} />)
// Act
fireEvent.click(screen.getByText('billing.plansCommon.self'))
// Assert
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(handleCancel).toHaveBeenCalled()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should fall back to default pricing URL when language is empty', () => {
// Arrange
mockLanguage = ''
render(<Pricing onCancel={vi.fn()} />)
// Assert
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
})
})

View File

@@ -0,0 +1,81 @@
import { render } from '@testing-library/react'
import {
Cloud,
Community,
Enterprise,
EnterpriseNoise,
NoiseBottom,
NoiseTop,
Premium,
PremiumNoise,
Professional,
Sandbox,
SelfHosted,
Team,
} from '../index'
// Static SVG components (no props)
describe('Static Pricing Asset Components', () => {
const staticComponents = [
{ name: 'Community', Component: Community },
{ name: 'Enterprise', Component: Enterprise },
{ name: 'EnterpriseNoise', Component: EnterpriseNoise },
{ name: 'NoiseBottom', Component: NoiseBottom },
{ name: 'NoiseTop', Component: NoiseTop },
{ name: 'Premium', Component: Premium },
{ name: 'PremiumNoise', Component: PremiumNoise },
{ name: 'Professional', Component: Professional },
{ name: 'Sandbox', Component: Sandbox },
{ name: 'Team', Component: Team },
]
it.each(staticComponents)('$name should render an SVG element', ({ Component }) => {
const { container } = render(<Component />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it.each(staticComponents)('$name should render without errors on rerender', ({ Component }) => {
const { container, rerender } = render(<Component />)
rerender(<Component />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
// Interactive SVG components with isActive prop
describe('Cloud', () => {
it('should render an SVG element', () => {
const { container } = render(<Cloud isActive={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should use primary color when inactive', () => {
const { container } = render(<Cloud isActive={false} />)
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
expect(rects.length).toBeGreaterThan(0)
})
it('should use accent color when active', () => {
const { container } = render(<Cloud isActive={true} />)
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
expect(rects.length).toBeGreaterThan(0)
})
})
describe('SelfHosted', () => {
it('should render an SVG element', () => {
const { container } = render(<SelfHosted isActive={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should use primary color when inactive', () => {
const { container } = render(<SelfHosted isActive={false} />)
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
expect(rects.length).toBeGreaterThan(0)
})
it('should use accent color when active', () => {
const { container } = render(<SelfHosted isActive={true} />)
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
expect(rects.length).toBeGreaterThan(0)
})
})

View File

@@ -12,13 +12,11 @@ import {
Sandbox,
SelfHosted,
Team,
} from './index'
} from '../index'
describe('Pricing Assets', () => {
// Rendering: each asset should render an svg.
describe('Rendering', () => {
it('should render static assets without crashing', () => {
// Arrange
const assets = [
<Community key="community" />,
<Enterprise key="enterprise" />,
@@ -44,37 +42,29 @@ describe('Pricing Assets', () => {
// Props: active state should change fill color for selectable assets.
describe('Props', () => {
it('should render active state for Cloud', () => {
// Arrange
const { container } = render(<Cloud isActive />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for Cloud', () => {
// Arrange
const { container } = render(<Cloud isActive={false} />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
})
it('should render active state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
})
it('should render inactive state for SelfHosted', () => {
// Arrange
const { container } = render(<SelfHosted isActive={false} />)
// Assert
const rects = Array.from(container.querySelectorAll('rect'))
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
})

View File

@@ -1,36 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '../index'
import PlanSwitcher from './index'
import { PlanRange } from './plan-range-switcher'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (key in mockTranslations)
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import { CategoryEnum } from '../../index'
import PlanSwitcher from '../index'
import { PlanRange } from '../plan-range-switcher'
describe('PlanSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render category tabs and plan range switcher for cloud', () => {
// Arrange
render(
<PlanSwitcher
currentCategory={CategoryEnum.CLOUD}
@@ -40,17 +20,14 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should call onChangeCategory when selecting a tab', () => {
// Arrange
const handleChangeCategory = vi.fn()
render(
<PlanSwitcher
@@ -61,16 +38,13 @@ describe('PlanSwitcher', () => {
/>,
)
// Act
fireEvent.click(screen.getByText('billing.plansCommon.self'))
// Assert
expect(handleChangeCategory).toHaveBeenCalledTimes(1)
expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
})
it('should hide plan range switcher when category is self-hosted', () => {
// Arrange
render(
<PlanSwitcher
currentCategory={CategoryEnum.SELF}
@@ -80,21 +54,12 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render tabs when translation strings are empty', () => {
// Arrange
mockTranslations = {
'plansCommon.cloud': '',
'plansCommon.self': '',
}
// Act
it('should render tabs with translation keys', () => {
const { container } = render(
<PlanSwitcher
currentCategory={CategoryEnum.SELF}
@@ -104,11 +69,10 @@ describe('PlanSwitcher', () => {
/>,
)
// Assert
const labels = container.querySelectorAll('span')
expect(labels).toHaveLength(2)
expect(labels[0]?.textContent).toBe('')
expect(labels[1]?.textContent).toBe('')
expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud')
expect(labels[1]?.textContent).toBe('billing.plansCommon.self')
})
})
})

View File

@@ -1,86 +1,50 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
let mockTranslations: Record<string, string> = {}
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
if (mockTranslations[key])
return mockTranslations[key]
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}
})
import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher'
describe('PlanRangeSwitcher', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTranslations = {}
})
// Rendering behavior
describe('Rendering', () => {
it('should render the annual billing label', () => {
// Arrange
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Assert
expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should switch to yearly when toggled from monthly', () => {
// Arrange
const handleChange = vi.fn()
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />)
// Act
fireEvent.click(screen.getByRole('switch'))
// Assert
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
})
it('should switch to monthly when toggled from yearly', () => {
// Arrange
const handleChange = vi.fn()
render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />)
// Act
fireEvent.click(screen.getByRole('switch'))
// Assert
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render when the translation string is empty', () => {
// Arrange
mockTranslations = {
'billing.plansCommon.annualBilling': '',
}
it('should render label with translation key and params', () => {
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Act
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
// Assert
const label = container.querySelector('span')
const label = screen.getByText(/billing\.plansCommon\.annualBilling/)
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('')
expect(label.textContent).toContain('percent')
})
})
})

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Tab from './tab'
import Tab from '../tab'
const Icon = ({ isActive }: { isActive: boolean }) => (
<svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} />
@@ -11,10 +11,8 @@ describe('PlanSwitcherTab', () => {
vi.clearAllMocks()
})
// Rendering behavior
describe('Rendering', () => {
it('should render label and icon', () => {
// Arrange
render(
<Tab
Icon={Icon}
@@ -25,16 +23,13 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
expect(screen.getByText('Cloud')).toBeInTheDocument()
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
})
})
// Prop-driven behavior
describe('Props', () => {
it('should call onClick with the provided value', () => {
// Arrange
const handleClick = vi.fn()
render(
<Tab
@@ -46,16 +41,13 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Act
fireEvent.click(screen.getByText('Self'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(handleClick).toHaveBeenCalledWith('self')
})
it('should apply active text class when isActive is true', () => {
// Arrange
render(
<Tab
Icon={Icon}
@@ -66,16 +58,13 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
})
})
// Edge case rendering behavior
describe('Edge Cases', () => {
it('should render when label is empty', () => {
// Arrange
const { container } = render(
<Tab
Icon={Icon}
@@ -86,7 +75,6 @@ describe('PlanSwitcherTab', () => {
/>,
)
// Assert
const label = container.querySelector('span')
expect(label).toBeInTheDocument()
expect(label?.textContent).toBe('')

View File

@@ -1,14 +1,14 @@
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import type { UsagePlanInfo } from '../../../type'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../type'
import { PlanRange } from '../plan-switcher/plan-range-switcher'
import cloudPlanItem from './cloud-plan-item'
import Plans from './index'
import selfHostedPlanItem from './self-hosted-plan-item'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import cloudPlanItem from '../cloud-plan-item'
import Plans from '../index'
import selfHostedPlanItem from '../self-hosted-plan-item'
vi.mock('./cloud-plan-item', () => ({
vi.mock('../cloud-plan-item', () => ({
default: vi.fn(props => (
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
Cloud
@@ -18,7 +18,7 @@ vi.mock('./cloud-plan-item', () => ({
)),
}))
vi.mock('./self-hosted-plan-item', () => ({
vi.mock('../self-hosted-plan-item', () => ({
default: vi.fn(props => (
<div data-testid={`self-plan-${props.plan}`}>
Self

View File

@@ -1,13 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../../type'
import Button from './button'
import { Plan } from '../../../../type'
import Button from '../button'
describe('CloudPlanButton', () => {
describe('Disabled state', () => {
it('should disable button and hide arrow when plan is not available', () => {
const handleGetPayUrl = vi.fn()
// Arrange
render(
<Button
plan={Plan.team}
@@ -18,7 +17,6 @@ describe('CloudPlanButton', () => {
)
const button = screen.getByRole('button', { name: /Get started/i })
// Assert
expect(button).toBeDisabled()
expect(button.className).toContain('cursor-not-allowed')
expect(handleGetPayUrl).not.toHaveBeenCalled()
@@ -28,7 +26,6 @@ describe('CloudPlanButton', () => {
describe('Enabled state', () => {
it('should invoke handler and render arrow when plan is available', () => {
const handleGetPayUrl = vi.fn()
// Arrange
render(
<Button
plan={Plan.sandbox}
@@ -39,10 +36,8 @@ describe('CloudPlanButton', () => {
)
const button = screen.getByRole('button', { name: /Start now/i })
// Act
fireEvent.click(button)
// Assert
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
expect(button).not.toBeDisabled()
})

View File

@@ -5,13 +5,13 @@ import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import CloudPlanItem from './index'
import Toast from '../../../../../base/toast'
import { ALL_PLANS } from '../../../../config'
import { Plan } from '../../../../type'
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
import CloudPlanItem from '../index'
vi.mock('../../../../base/toast', () => ({
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
@@ -37,7 +37,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: vi.fn(),
}))
vi.mock('../../assets', () => ({
vi.mock('../../../assets', () => ({
Sandbox: () => <div>Sandbox Icon</div>,
Professional: () => <div>Professional Icon</div>,
Team: () => <div>Team Icon</div>,
@@ -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', () => {
@@ -117,6 +117,32 @@ describe('CloudPlanItem', () => {
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
})
it('should show "most popular" badge for professional plan', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()
})
it('should not show "most popular" badge for non-professional plans', () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.queryByText('billing.plansCommon.mostPopular')).not.toBeInTheDocument()
})
it('should disable CTA when workspace already on higher tier', () => {
render(
<CloudPlanItem
@@ -192,5 +218,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(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
// 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(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
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<string>, opts: { onError?: (e: Error) => void }) => {
try {
await cb()
}
catch (e) {
opts.onError?.(e as Error)
}
})
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
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(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
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()
})
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { Plan } from '../../../../type'
import List from './index'
import { Plan } from '../../../../../type'
import List from '../index'
describe('CloudPlanItem/List', () => {
it('should show sandbox specific quotas', () => {

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Item from './index'
import Item from '../index'
describe('Item', () => {
beforeEach(() => {
@@ -9,13 +9,10 @@ describe('Item', () => {
// Rendering the plan item row
describe('Rendering', () => {
it('should render the provided label when tooltip is absent', () => {
// Arrange
const label = 'Monthly credits'
// Act
const { container } = render(<Item label={label} />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(container.querySelector('.group')).toBeNull()
})
@@ -24,27 +21,21 @@ describe('Item', () => {
// Toggling the optional tooltip indicator
describe('Tooltip behavior', () => {
it('should render tooltip content when tooltip text is provided', () => {
// Arrange
const label = 'Workspace seats'
const tooltip = 'Seats define how many teammates can join the workspace.'
// Act
const { container } = render(<Item label={label} tooltip={tooltip} />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(screen.getByText(tooltip)).toBeInTheDocument()
expect(container.querySelector('.group')).not.toBeNull()
})
it('should treat an empty tooltip string as absent', () => {
// Arrange
const label = 'Vector storage'
// Act
const { container } = render(<Item label={label} tooltip="" />)
// Assert
expect(screen.getByText(label)).toBeInTheDocument()
expect(container.querySelector('.group')).toBeNull()
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Tooltip from './tooltip'
import Tooltip from '../tooltip'
describe('Tooltip', () => {
beforeEach(() => {
@@ -9,26 +9,20 @@ describe('Tooltip', () => {
// Rendering the info tooltip container
describe('Rendering', () => {
it('should render the content panel when provide with text', () => {
// Arrange
const content = 'Usage resets on the first day of every month.'
// Act
render(<Tooltip content={content} />)
// Assert
expect(() => screen.getByText(content)).not.toThrow()
})
})
describe('Icon rendering', () => {
it('should render the icon when provided with content', () => {
// Arrange
const content = 'Tooltips explain each plan detail.'
// Act
render(<Tooltip content={content} />)
// Assert
expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument()
})
})
@@ -36,7 +30,6 @@ describe('Tooltip', () => {
// Handling empty strings while keeping structure consistent
describe('Edge cases', () => {
it('should render without crashing when passed empty content', () => {
// Arrange
const content = ''
// Act and Assert

View File

@@ -3,8 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { SelfHostedPlan } from '../../../type'
import Button from './button'
import { SelfHostedPlan } from '../../../../type'
import Button from '../button'
vi.mock('@/hooks/use-theme')

View File

@@ -2,30 +2,21 @@ import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import Toast from '../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { SelfHostedPlan } from '../../../type'
import SelfHostedPlanItem from './index'
import Toast from '../../../../../base/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config'
import { SelfHostedPlan } from '../../../../type'
import SelfHostedPlanItem from '../index'
const featuresTranslations: Record<string, string[]> = {
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
'billing.plans.premium.features': ['premium-feature-1'],
'billing.plans.enterprise.features': ['enterprise-feature-1'],
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const prefix = options?.ns ? `${options.ns}.` : ''
if (options?.returnObjects)
return featuresTranslations[`${prefix}${key}`] || []
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
vi.mock('../list', () => ({
default: ({ plan }: { plan: string }) => (
<div data-testid={`list-${plan}`}>
List for
{plan}
</div>
),
}))
vi.mock('../../../../base/toast', () => ({
vi.mock('../../../../../base/toast', () => ({
default: {
notify: vi.fn(),
},
@@ -35,7 +26,7 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('../../assets', () => ({
vi.mock('../../../assets', () => ({
Community: () => <div>Community Icon</div>,
Premium: () => <div>Premium Icon</div>,
Enterprise: () => <div>Enterprise Icon</div>,
@@ -63,6 +54,12 @@ beforeAll(() => {
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
@@ -70,14 +67,7 @@ afterAll(() => {
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
describe('SelfHostedPlanItem', () => {
// Copy rendering for each plan
describe('Rendering', () => {
it('should display community plan info', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
@@ -85,8 +75,7 @@ describe('SelfHostedPlanItem', () => {
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
expect(screen.getByTestId('list-community')).toBeInTheDocument()
})
it('should show premium extras such as cloud provider notice', () => {
@@ -97,7 +86,6 @@ describe('SelfHostedPlanItem', () => {
})
})
// CTA behavior for each plan
describe('CTA interactions', () => {
it('should show toast when non-manager tries to proceed', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })

View File

@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { SelfHostedPlan } from '@/app/components/billing/type'
import { createReactI18nextMock } from '@/test/i18n-mock'
import List from '../index'
// Override global i18n mock to support returnObjects: true for feature arrays
vi.mock('react-i18next', () => createReactI18nextMock({
'billing.plans.community.features': ['Feature A', 'Feature B'],
}))
describe('SelfHostedPlanItem/List', () => {
it('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Item from '../item'
describe('SelfHostedPlanItem/List/Item', () => {
it('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
it('should render the check icon', () => {
const { container } = render(<Item label="Custom branding" />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(svg).toHaveClass('size-4')
})
it('should render different labels correctly', () => {
const { rerender } = render(<Item label="Feature A" />)
expect(screen.getByText('Feature A')).toBeInTheDocument()
rerender(<Item label="Feature B" />)
expect(screen.getByText('Feature B')).toBeInTheDocument()
expect(screen.queryByText('Feature A')).not.toBeInTheDocument()
})
it('should render with empty label', () => {
const { container } = render(<Item label="" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})

View File

@@ -1,26 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { SelfHostedPlan } from '@/app/components/billing/type'
import List from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return ['Feature A', 'Feature B']
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
}))
describe('SelfHostedPlanItem/List', () => {
it('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@@ -1,12 +0,0 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Item from './item'
describe('SelfHostedPlanItem/List/Item', () => {
it('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@@ -2,8 +2,8 @@ import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { createMockPlan } from '@/__mocks__/provider-context'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import PriorityLabel from './index'
import { Plan } from '../../type'
import PriorityLabel from '../index'
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
@@ -20,16 +20,12 @@ describe('PriorityLabel', () => {
vi.clearAllMocks()
})
// Rendering: basic label output for sandbox plan.
describe('Rendering', () => {
it('should render the standard priority label when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
})
})
@@ -37,13 +33,10 @@ describe('PriorityLabel', () => {
// Props: custom class name applied to the label container.
describe('Props', () => {
it('should apply custom className to the label container', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel className="custom-class" />)
// Assert
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
expect(label).toHaveClass('custom-class')
})
@@ -52,54 +45,53 @@ describe('PriorityLabel', () => {
// Plan types: label text and icon visibility for different plans.
describe('Plan Types', () => {
it('should render priority label and icon when plan is professional', () => {
// Arrange
setupPlan(Plan.professional)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render top priority label and icon when plan is team', () => {
// Arrange
setupPlan(Plan.team)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render standard label without icon when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeInTheDocument()
})
})
// Edge cases: tooltip content varies by priority level.
// Enterprise plan tests
describe('Enterprise Plan', () => {
it('should render top-priority label with icon for enterprise plan', () => {
setupPlan(Plan.enterprise)
const { container } = render(<PriorityLabel />)
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should show the tip text when priority is not top priority', async () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
)).toBeInTheDocument()
@@ -107,15 +99,12 @@ describe('PriorityLabel', () => {
})
it('should hide the tip text when priority is top priority', async () => {
// Arrange
setupPlan(Plan.enterprise)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
)).toBeInTheDocument()

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import ProgressBar from './index'
import ProgressBar from '../index'
describe('ProgressBar', () => {
describe('Normal Mode (determinate)', () => {

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import TriggerEventsLimitModal from './index'
import TriggerEventsLimitModal from '../index'
const mockOnClose = vi.fn()
const mockOnUpgrade = vi.fn()
@@ -16,8 +16,7 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr
))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
// eslint-disable-next-line ts/no-explicit-any
default: (props: any) => planUpgradeModalMock(props),
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
}))
describe('TriggerEventsLimitModal', () => {
@@ -66,4 +65,53 @@ describe('TriggerEventsLimitModal', () => {
expect(planUpgradeModalMock).toHaveBeenCalled()
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
})
it('renders reset info when resetInDays is provided', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={18000}
total={20000}
resetInDays={7}
/>,
)
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
expect(screen.getByText('18000')).toBeInTheDocument()
expect(screen.getByText('20000')).toBeInTheDocument()
})
it('passes correct title and description translations', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={0}
total={0}
/>,
)
const modal = screen.getByTestId('plan-upgrade-modal')
expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title')
expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description')
})
it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => {
render(
<TriggerEventsLimitModal
show
onClose={mockOnClose}
onUpgrade={mockOnUpgrade}
usage={0}
total={0}
/>,
)
const passedProps = planUpgradeModalMock.mock.calls[0][0]
expect(passedProps.onClose).toBe(mockOnClose)
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
})
})

View File

@@ -1,7 +1,7 @@
import type { Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UpgradeBtn from './index'
import UpgradeBtn from '../index'
// ✅ Import real project components (DO NOT mock these)
// PremiumBadge, Button, SparklesSoft are all base components
@@ -14,146 +14,117 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
// Mock gtag for tracking tests
// Typed window accessor for gtag tracking tests
const gtagWindow = window as unknown as Record<string, Mock | undefined>
let mockGtag: Mock | undefined
describe('UpgradeBtn', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGtag = vi.fn()
;(window as any).gtag = mockGtag
gtagWindow.gtag = mockGtag
})
afterEach(() => {
delete (window as any).gtag
delete gtagWindow.gtag
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing with default props', () => {
// Act
render(<UpgradeBtn />)
// Assert - should render with default text
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render premium badge by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - PremiumBadge renders with text content
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render plain button when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Button should be rendered with plain text
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should render short text when isShort is true', () => {
// Act
render(<UpgradeBtn isShort />)
// Assert
expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render custom label when labelKey is provided', () => {
// Act
render(<UpgradeBtn labelKey={'custom.label.key' as any} />)
render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />)
// Assert
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
})
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
// Act
render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />)
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should apply custom className to premium badge', () => {
// Arrange
const customClass = 'custom-upgrade-btn'
// Act
const { container } = render(<UpgradeBtn className={customClass} />)
// Assert - Check the root element has the custom class
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
})
it('should apply custom className to plain button', () => {
// Arrange
const customClass = 'custom-button-class'
// Act
render(<UpgradeBtn isPlain className={customClass} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass(customClass)
})
it('should apply custom style to premium badge', () => {
// Arrange
const customStyle = { padding: '10px' }
// Act
const { container } = render(<UpgradeBtn style={customStyle} />)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveStyle(customStyle)
})
it('should apply custom style to plain button', () => {
// Arrange
const customStyle = { margin: '5px' }
// Act
render(<UpgradeBtn isPlain style={customStyle} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveStyle(customStyle)
})
it('should render with size "s"', () => {
// Act
render(<UpgradeBtn size="s" />)
// Assert - Component renders successfully with size prop
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render with size "m" by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - Component renders successfully
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should render with size "custom"', () => {
// Act
render(<UpgradeBtn size="custom" />)
// Assert - Component renders successfully with custom size
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
})
@@ -161,72 +132,57 @@ describe('UpgradeBtn', () => {
// User Interactions
describe('User Interactions', () => {
it('should call custom onClick when provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call custom onClick when provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn isPlain />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should track gtag event when loc is provided and badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'header-navigation'
// Act
render(<UpgradeBtn loc={loc} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
@@ -234,16 +190,13 @@ describe('UpgradeBtn', () => {
})
it('should track gtag event when loc is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'footer-section'
// Act
render(<UpgradeBtn isPlain loc={loc} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
@@ -251,44 +204,35 @@ describe('UpgradeBtn', () => {
})
it('should not track gtag event when loc is not provided', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(mockGtag).not.toHaveBeenCalled()
})
it('should not track gtag event when gtag is not available', async () => {
// Arrange
const user = userEvent.setup()
delete (window as any).gtag
delete gtagWindow.gtag
// Act
render(<UpgradeBtn loc="test-location" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should not throw error
expect(mockGtag).not.toHaveBeenCalled()
})
it('should call both custom onClick and track gtag when both are provided', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
const loc = 'settings-page'
// Act
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
@@ -300,121 +244,95 @@ describe('UpgradeBtn', () => {
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined className', () => {
// Act
render(<UpgradeBtn className={undefined} />)
// Assert - should render without error
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle undefined style', () => {
// Act
render(<UpgradeBtn style={undefined} />)
// Assert - should render without error
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle undefined onClick', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn onClick={undefined} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should fall back to setShowPricingModal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should handle undefined loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc={undefined} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - should not attempt to track gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle undefined labelKey', () => {
// Act
render(<UpgradeBtn labelKey={undefined} />)
// Assert - should use default label
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle empty string className', () => {
// Act
render(<UpgradeBtn className="" />)
// Assert
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
})
it('should handle empty string loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc="" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - empty loc should not trigger gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle empty string labelKey', () => {
// Act
render(<UpgradeBtn labelKey={'' as any} />)
it('should handle labelKey with isShort - labelKey takes precedence', () => {
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
// Assert - empty labelKey is falsy, so it falls back to default label
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
})
// Prop Combinations
describe('Prop Combinations', () => {
it('should handle isPlain with isShort', () => {
// Act
render(<UpgradeBtn isPlain isShort />)
// Assert - isShort should not affect plain button text
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should handle isPlain with custom labelKey', () => {
// Act
render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />)
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
// Assert - labelKey should override plain text
expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
})
it('should handle isShort with custom labelKey', () => {
// Act
render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />)
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
// Assert - labelKey should override isShort behavior
expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
it('should handle all custom props together', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
const customStyle = { margin: '10px' }
const customClass = 'all-custom'
// Act
const { container } = render(
<UpgradeBtn
className={customClass}
@@ -423,17 +341,16 @@ describe('UpgradeBtn', () => {
isShort
onClick={handleClick}
loc="test-loc"
labelKey={'custom.all' as any}
labelKey="triggerLimitModal.description"
/>,
)
const badge = screen.getByText(/custom\.all/i)
const badge = screen.getByText(/triggerLimitModal\.description/i)
await user.click(badge)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
expect(rootElement).toHaveStyle(customStyle)
expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc: 'test-loc',
@@ -444,11 +361,9 @@ describe('UpgradeBtn', () => {
// Accessibility Tests
describe('Accessibility', () => {
it('should be keyboard accessible with plain button', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
@@ -459,47 +374,38 @@ describe('UpgradeBtn', () => {
// Press Enter
await user.keyboard('{Enter}')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be keyboard accessible with Space key', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
// Tab to button and press Space
await user.tab()
await user.keyboard(' ')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be clickable for premium badge variant', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
// Click badge
await user.click(badge)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should have proper button role when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Plain button should have button role
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
@@ -508,31 +414,25 @@ describe('UpgradeBtn', () => {
// Integration Tests
describe('Integration', () => {
it('should work with modal context for pricing modal', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert
await waitFor(() => {
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
it('should integrate onClick with analytics tracking', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = vi.fn()
// Act
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
await user.click(badge)
// Assert - Both onClick and gtag should be called
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {

View File

@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../../config'
import AppsInfo from '../apps-info'
const mockProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext(),
}))
describe('AppsInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 7 },
total: { ...defaultPlan.total, buildApps: 15 },
},
})
})
it('renders build apps usage information with context data', () => {
render(<AppsInfo className="apps-info-class" />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText('15')).toBeInTheDocument()
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
})
it('renders without className', () => {
render(<AppsInfo />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
})
it('renders zero usage correctly', () => {
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 0 },
total: { ...defaultPlan.total, buildApps: 5 },
},
})
render(<AppsInfo />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('renders when usage equals total (at capacity)', () => {
mockProviderContext.mockReturnValue({
plan: {
...defaultPlan,
usage: { ...defaultPlan.usage, buildApps: 10 },
total: { ...defaultPlan.total, buildApps: 10 },
},
})
render(<AppsInfo />)
const tens = screen.getAllByText('10')
expect(tens.length).toBe(2)
})
})

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { NUM_INFINITE } from '../config'
import UsageInfo from './index'
import { NUM_INFINITE } from '../../config'
import UsageInfo from '../index'
const TestIcon = () => <span data-testid="usage-icon" />

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../config'
import { Plan } from '../type'
import VectorSpaceInfo from './vector-space-info'
import { defaultPlan } from '../../config'
import { Plan } from '../../type'
import VectorSpaceInfo from '../vector-space-info'
// Mock provider context with configurable plan
let mockPlanType = Plan.sandbox

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../config'
import AppsInfo from './apps-info'
const appsUsage = 7
const appsTotal = 15
const mockPlan = {
...defaultPlan,
usage: {
...defaultPlan.usage,
buildApps: appsUsage,
},
total: {
...defaultPlan.total,
buildApps: appsTotal,
},
}
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
}),
}))
describe('AppsInfo', () => {
it('renders build apps usage information with context data', () => {
render(<AppsInfo className="apps-info-class" />)
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
import type { CurrentPlanInfoBackend } from '../type'
import { DocumentProcessingPriority, Plan } from '../type'
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index'
import type { CurrentPlanInfoBackend } from '../../type'
import { DocumentProcessingPriority, Plan } from '../../type'
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from '../index'
describe('billing utils', () => {
// parseVectorSpaceToMB tests

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import VectorSpaceFull from './index'
import VectorSpaceFull from '../index'
type VectorProviderGlobal = typeof globalThis & {
__vectorProviderContext?: ReturnType<typeof vi.fn>
@@ -17,12 +17,12 @@ vi.mock('@/context/provider-context', () => {
}
})
vi.mock('../upgrade-btn', () => ({
vi.mock('../../upgrade-btn', () => ({
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
}))
// Mock utils to control threshold and plan limits
vi.mock('../utils', () => ({
vi.mock('../../utils', () => ({
getPlanVectorSpaceLimitMB: (planType: string) => {
// Return 5 for sandbox (threshold) and 100 for team
if (planType === 'sandbox')
@@ -66,4 +66,26 @@ describe('VectorSpaceFull', () => {
expect(screen.getByText('8')).toBeInTheDocument()
expect(screen.getByText('100MB')).toBeInTheDocument()
})
it('renders vector space info section', () => {
render(<VectorSpaceFull />)
expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument()
})
it('renders with sandbox plan', () => {
const globals = getVectorGlobal()
globals.__vectorProviderContext?.mockReturnValue({
plan: {
type: 'sandbox',
usage: { vectorSpace: 2 },
total: { vectorSpace: 50 },
},
})
render(<VectorSpaceFull />)
expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
})
})

View File

@@ -2997,11 +2997,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
@@ -3022,11 +3017,6 @@
"count": 1
}
},
"app/components/billing/pricing/plans/self-hosted-plan-item/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
}
},
"app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 4
@@ -3050,11 +3040,6 @@
"count": 1
}
},
"app/components/billing/upgrade-btn/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 9
}
},
"app/components/billing/upgrade-btn/index.tsx": {
"ts/no-explicit-any": {
"count": 3