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.
This commit is contained in:
CodingOnStar
2026-02-11 13:28:42 +08:00
parent 40f1d91545
commit ba289c560b
48 changed files with 727 additions and 628 deletions

View File

@ -2,8 +2,19 @@ import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/typ
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'
// ─── Module-level mock state ────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
@ -113,21 +124,7 @@ const setupAppContext = (overrides: Record<string, unknown> = {}) => {
}
}
// ─── Imports (after mocks) ──────────────────────────────────────────────────
// These must be imported after all vi.mock() calls
/* eslint-disable import/first */
import AnnotationFull from '@/app/components/billing/annotation-full'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import Billing from '@/app/components/billing/billing-page'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import PlanComp from '@/app/components/billing/plan'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import PriorityLabel from '@/app/components/billing/priority-label'
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
/* eslint-enable import/first */
// Vitest hoists vi.mock() calls, so imports above will use mocked modules
// ═══════════════════════════════════════════════════════════════════════════
// 1. Billing Page + Plan Component Integration

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>) => {

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>,
@ -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

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

@ -3017,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
@ -3045,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