diff --git a/web/app/components/billing/billing-page/index.spec.tsx b/web/app/components/billing/billing-page/index.spec.tsx
new file mode 100644
index 0000000000..2310baa4f4
--- /dev/null
+++ b/web/app/components/billing/billing-page/index.spec.tsx
@@ -0,0 +1,84 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Billing from './index'
+
+let currentBillingUrl: string | null = 'https://billing'
+let fetching = false
+let isManager = true
+let enableBilling = true
+
+const refetchMock = vi.fn()
+const openAsyncWindowMock = vi.fn()
+
+vi.mock('@/service/use-billing', () => ({
+ useBillingUrl: () => ({
+ data: currentBillingUrl,
+ isFetching: fetching,
+ refetch: refetchMock,
+ }),
+}))
+
+vi.mock('@/hooks/use-async-window-open', () => ({
+ useAsyncWindowOpen: () => openAsyncWindowMock,
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceManager: isManager,
+ }),
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ enableBilling,
+ }),
+}))
+
+vi.mock('../plan', () => ({
+ __esModule: true,
+ default: ({ loc }: { loc: string }) =>
,
+}))
+
+describe('Billing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentBillingUrl = 'https://billing'
+ fetching = false
+ isManager = true
+ enableBilling = true
+ refetchMock.mockResolvedValue({ data: 'https://billing' })
+ })
+
+ it('hides the billing action when user is not manager or billing is disabled', () => {
+ isManager = false
+ render()
+ expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
+
+ vi.clearAllMocks()
+ isManager = true
+ enableBilling = false
+ render()
+ expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
+ })
+
+ it('opens the billing window with the immediate url when the button is clicked', async () => {
+ render()
+
+ const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
+ fireEvent.click(actionButton)
+
+ await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
+ const [, options] = openAsyncWindowMock.mock.calls[0]
+ expect(options).toMatchObject({
+ immediateUrl: currentBillingUrl,
+ features: 'noopener,noreferrer',
+ })
+ })
+
+ it('disables the button while billing url is fetching', () => {
+ fetching = true
+ render()
+
+ const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
+ expect(actionButton).toBeDisabled()
+ })
+})
diff --git a/web/app/components/billing/header-billing-btn/index.spec.tsx b/web/app/components/billing/header-billing-btn/index.spec.tsx
new file mode 100644
index 0000000000..b87b733353
--- /dev/null
+++ b/web/app/components/billing/header-billing-btn/index.spec.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { Plan } from '../type'
+import HeaderBillingBtn from './index'
+
+type HeaderGlobal = typeof globalThis & {
+ __mockProviderContext?: ReturnType
+}
+
+function getHeaderGlobal(): HeaderGlobal {
+ return globalThis as HeaderGlobal
+}
+
+const ensureProviderContextMock = () => {
+ const globals = getHeaderGlobal()
+ if (!globals.__mockProviderContext)
+ throw new Error('Provider context mock not set')
+ return globals.__mockProviderContext
+}
+
+vi.mock('@/context/provider-context', () => {
+ const mock = vi.fn()
+ const globals = getHeaderGlobal()
+ globals.__mockProviderContext = mock
+ return {
+ useProviderContext: () => mock(),
+ }
+})
+
+vi.mock('../upgrade-btn', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+describe('HeaderBillingBtn', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ ensureProviderContextMock().mockReturnValue({
+ plan: {
+ type: Plan.professional,
+ },
+ enableBilling: true,
+ isFetchedPlan: true,
+ })
+ })
+
+ it('renders nothing when billing is disabled or plan is not fetched', () => {
+ ensureProviderContextMock().mockReturnValueOnce({
+ plan: {
+ type: Plan.professional,
+ },
+ enableBilling: false,
+ isFetchedPlan: true,
+ })
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders upgrade button for sandbox plan', () => {
+ ensureProviderContextMock().mockReturnValueOnce({
+ plan: {
+ type: Plan.sandbox,
+ },
+ enableBilling: true,
+ isFetchedPlan: true,
+ })
+
+ render()
+
+ expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
+ })
+
+ it('renders plan badge and forwards clicks when not display-only', () => {
+ const onClick = vi.fn()
+
+ const { rerender } = render()
+
+ const badge = screen.getByText('pro').closest('div')
+
+ expect(badge).toHaveClass('cursor-pointer')
+
+ fireEvent.click(badge!)
+ expect(onClick).toHaveBeenCalledTimes(1)
+
+ rerender()
+ expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
+
+ fireEvent.click(screen.getByText('pro').closest('div')!)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/billing/partner-stack/index.spec.tsx b/web/app/components/billing/partner-stack/index.spec.tsx
new file mode 100644
index 0000000000..7b4658cf0f
--- /dev/null
+++ b/web/app/components/billing/partner-stack/index.spec.tsx
@@ -0,0 +1,44 @@
+import { render } from '@testing-library/react'
+import PartnerStack from './index'
+
+let isCloudEdition = true
+
+const saveOrUpdate = vi.fn()
+const bind = vi.fn()
+
+vi.mock('@/config', () => ({
+ get IS_CLOUD_EDITION() {
+ return isCloudEdition
+ },
+}))
+
+vi.mock('./use-ps-info', () => ({
+ __esModule: true,
+ default: () => ({
+ saveOrUpdate,
+ bind,
+ }),
+}))
+
+describe('PartnerStack', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ isCloudEdition = true
+ })
+
+ it('does not call partner stack helpers when not in cloud edition', () => {
+ isCloudEdition = false
+
+ render()
+
+ expect(saveOrUpdate).not.toHaveBeenCalled()
+ expect(bind).not.toHaveBeenCalled()
+ })
+
+ it('calls saveOrUpdate and bind once when running in cloud edition', () => {
+ render()
+
+ expect(saveOrUpdate).toHaveBeenCalledTimes(1)
+ expect(bind).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/billing/partner-stack/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx
new file mode 100644
index 0000000000..14215f2514
--- /dev/null
+++ b/web/app/components/billing/partner-stack/use-ps-info.spec.tsx
@@ -0,0 +1,197 @@
+import { act, renderHook } from '@testing-library/react'
+import { PARTNER_STACK_CONFIG } from '@/config'
+import usePSInfo from './use-ps-info'
+
+let searchParamsValues: Record = {}
+const setSearchParams = (values: Record) => {
+ searchParamsValues = values
+}
+
+type PartnerStackGlobal = typeof globalThis & {
+ __partnerStackCookieMocks?: {
+ get: ReturnType
+ set: ReturnType
+ remove: ReturnType
+ }
+ __partnerStackMutateAsync?: ReturnType
+}
+
+function getPartnerStackGlobal(): PartnerStackGlobal {
+ return globalThis as PartnerStackGlobal
+}
+
+const ensureCookieMocks = () => {
+ const globals = getPartnerStackGlobal()
+ if (!globals.__partnerStackCookieMocks)
+ throw new Error('Cookie mocks not initialized')
+ return globals.__partnerStackCookieMocks
+}
+
+const ensureMutateAsync = () => {
+ const globals = getPartnerStackGlobal()
+ if (!globals.__partnerStackMutateAsync)
+ throw new Error('Mutate mock not initialized')
+ return globals.__partnerStackMutateAsync
+}
+
+vi.mock('js-cookie', () => {
+ const get = vi.fn()
+ const set = vi.fn()
+ const remove = vi.fn()
+ const globals = getPartnerStackGlobal()
+ globals.__partnerStackCookieMocks = { get, set, remove }
+ const cookieApi = { get, set, remove }
+ return {
+ __esModule: true,
+ default: cookieApi,
+ get,
+ set,
+ remove,
+ }
+})
+vi.mock('next/navigation', () => ({
+ useSearchParams: () => ({
+ get: (key: string) => searchParamsValues[key] ?? null,
+ }),
+}))
+vi.mock('@/service/use-billing', () => {
+ const mutateAsync = vi.fn()
+ const globals = getPartnerStackGlobal()
+ globals.__partnerStackMutateAsync = mutateAsync
+ return {
+ useBindPartnerStackInfo: () => ({
+ mutateAsync,
+ }),
+ }
+})
+
+describe('usePSInfo', () => {
+ const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'location', {
+ value: { hostname: 'cloud.dify.ai' },
+ configurable: true,
+ })
+ })
+
+ beforeEach(() => {
+ setSearchParams({})
+ const { get, set, remove } = ensureCookieMocks()
+ get.mockReset()
+ set.mockReset()
+ remove.mockReset()
+ const mutate = ensureMutateAsync()
+ mutate.mockReset()
+ mutate.mockResolvedValue(undefined)
+ get.mockReturnValue('{}')
+ })
+
+ afterAll(() => {
+ if (originalLocationDescriptor)
+ Object.defineProperty(globalThis, 'location', originalLocationDescriptor)
+ })
+
+ it('saves partner info when query params change', () => {
+ const { get, set } = ensureCookieMocks()
+ get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
+ setSearchParams({
+ ps_partner_key: 'new-partner',
+ ps_xid: 'new-click',
+ })
+
+ const { result } = renderHook(() => usePSInfo())
+
+ expect(result.current.psPartnerKey).toBe('new-partner')
+ expect(result.current.psClickId).toBe('new-click')
+
+ act(() => {
+ result.current.saveOrUpdate()
+ })
+
+ expect(set).toHaveBeenCalledWith(
+ PARTNER_STACK_CONFIG.cookieName,
+ JSON.stringify({
+ partnerKey: 'new-partner',
+ clickId: 'new-click',
+ }),
+ {
+ expires: PARTNER_STACK_CONFIG.saveCookieDays,
+ path: '/',
+ domain: '.dify.ai',
+ },
+ )
+ })
+
+ it('does not overwrite cookie when params do not change', () => {
+ setSearchParams({
+ ps_partner_key: 'existing',
+ ps_xid: 'existing-click',
+ })
+ const { get } = ensureCookieMocks()
+ get.mockReturnValue(JSON.stringify({
+ partnerKey: 'existing',
+ clickId: 'existing-click',
+ }))
+
+ const { result } = renderHook(() => usePSInfo())
+
+ act(() => {
+ result.current.saveOrUpdate()
+ })
+
+ const { set } = ensureCookieMocks()
+ expect(set).not.toHaveBeenCalled()
+ })
+
+ it('binds partner info and clears cookie once', async () => {
+ setSearchParams({
+ ps_partner_key: 'bind-partner',
+ ps_xid: 'bind-click',
+ })
+
+ const { result } = renderHook(() => usePSInfo())
+
+ const mutate = ensureMutateAsync()
+ const { remove } = ensureCookieMocks()
+ await act(async () => {
+ await result.current.bind()
+ })
+
+ expect(mutate).toHaveBeenCalledWith({
+ partnerKey: 'bind-partner',
+ clickId: 'bind-click',
+ })
+ expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
+ path: '/',
+ domain: '.dify.ai',
+ })
+
+ await act(async () => {
+ await result.current.bind()
+ })
+
+ expect(mutate).toHaveBeenCalledTimes(1)
+ })
+
+ it('still removes cookie when bind fails with status 400', async () => {
+ const mutate = ensureMutateAsync()
+ mutate.mockRejectedValueOnce({ status: 400 })
+ 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).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
+ path: '/',
+ domain: '.dify.ai',
+ })
+ })
+})
diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx
new file mode 100644
index 0000000000..bcdb83b5df
--- /dev/null
+++ b/web/app/components/billing/plan/index.spec.tsx
@@ -0,0 +1,130 @@
+import { 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'
+
+let currentPath = '/billing'
+
+const push = vi.fn()
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push }),
+ usePathname: () => currentPath,
+}))
+
+const setShowAccountSettingModalMock = vi.fn()
+vi.mock('@/context/modal-context', () => ({
+ // eslint-disable-next-line ts/no-explicit-any
+ useModalContextSelector: (selector: any) => selector({
+ setShowAccountSettingModal: setShowAccountSettingModalMock,
+ }),
+}))
+
+const providerContextMock = vi.fn()
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => providerContextMock(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: { email: 'user@example.com' },
+ isCurrentWorkspaceManager: true,
+ }),
+}))
+
+const mutateAsyncMock = vi.fn()
+let isPending = false
+vi.mock('@/service/use-education', () => ({
+ useEducationVerify: () => ({
+ mutateAsync: mutateAsyncMock,
+ isPending,
+ }),
+}))
+
+const verifyStateModalMock = vi.fn(props => (
+
+ {props.isShow ? 'visible' : 'hidden'}
+
+))
+vi.mock('@/app/education-apply/verify-state-modal', () => ({
+ __esModule: true,
+ // eslint-disable-next-line ts/no-explicit-any
+ default: (props: any) => verifyStateModalMock(props),
+}))
+
+vi.mock('../upgrade-btn', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+describe('PlanComp', () => {
+ const planMock = {
+ type: Plan.professional,
+ usage: {
+ teamMembers: 4,
+ documentsUploadQuota: 3,
+ vectorSpace: 8,
+ annotatedResponse: 5,
+ triggerEvents: 60,
+ apiRateLimit: 100,
+ },
+ total: {
+ teamMembers: 10,
+ documentsUploadQuota: 20,
+ vectorSpace: 10,
+ annotatedResponse: 500,
+ triggerEvents: 100,
+ apiRateLimit: 200,
+ },
+ reset: {
+ triggerEvents: 2,
+ apiRateLimit: 1,
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentPath = '/billing'
+ isPending = false
+ providerContextMock.mockReturnValue({
+ plan: planMock,
+ enableEducationPlan: true,
+ allowRefreshEducationVerify: false,
+ isEducationAccount: false,
+ })
+ mutateAsyncMock.mockReset()
+ mutateAsyncMock.mockResolvedValue({ token: 'token' })
+ })
+
+ it('renders plan info and handles education verify success', async () => {
+ render()
+
+ expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
+ expect(screen.getByTestId('plan-upgrade-btn')).toBeInTheDocument()
+
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token'))
+ expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+ })
+
+ it('shows modal when education verify fails', async () => {
+ mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
+ render()
+
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
+ await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
+ })
+
+ it('resets modal context when on education apply path', () => {
+ currentPath = '/education-apply/setup'
+ render()
+
+ expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
+ })
+})
diff --git a/web/app/components/billing/progress-bar/index.spec.tsx b/web/app/components/billing/progress-bar/index.spec.tsx
new file mode 100644
index 0000000000..a9c91468de
--- /dev/null
+++ b/web/app/components/billing/progress-bar/index.spec.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react'
+import ProgressBar from './index'
+
+describe('ProgressBar', () => {
+ it('renders with provided percent and color', () => {
+ render()
+
+ const bar = screen.getByTestId('billing-progress-bar')
+ expect(bar).toHaveClass('bg-test-color')
+ expect(bar.getAttribute('style')).toContain('width: 42%')
+ })
+
+ it('caps width at 100% when percent exceeds max', () => {
+ render()
+
+ const bar = screen.getByTestId('billing-progress-bar')
+ expect(bar.getAttribute('style')).toContain('width: 100%')
+ })
+
+ it('uses the default color when no color prop is provided', () => {
+ render()
+
+ expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF')
+ })
+})
diff --git a/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx
new file mode 100644
index 0000000000..a3d04c6031
--- /dev/null
+++ b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react'
+import TriggerEventsLimitModal from './index'
+
+const mockOnClose = vi.fn()
+const mockOnUpgrade = vi.fn()
+
+const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => (
+
+ {props.extraInfo}
+
+))
+
+vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
+ __esModule: true,
+ // eslint-disable-next-line ts/no-explicit-any
+ default: (props: any) => planUpgradeModalMock(props),
+}))
+
+describe('TriggerEventsLimitModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('passes the trigger usage props to the upgrade modal', () => {
+ render(
+ ,
+ )
+
+ const modal = screen.getByTestId('plan-upgrade-modal')
+ expect(modal.getAttribute('data-show')).toBe('true')
+ expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title')
+ expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description')
+ expect(planUpgradeModalMock).toHaveBeenCalled()
+
+ const passedProps = planUpgradeModalMock.mock.calls[0][0]
+ expect(passedProps.onClose).toBe(mockOnClose)
+ expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
+
+ expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
+ expect(screen.getByText('12')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ })
+
+ it('renders even when trigger modal is hidden', () => {
+ render(
+ ,
+ )
+
+ expect(planUpgradeModalMock).toHaveBeenCalled()
+ expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
+ })
+})
diff --git a/web/app/components/billing/usage-info/apps-info.spec.tsx b/web/app/components/billing/usage-info/apps-info.spec.tsx
new file mode 100644
index 0000000000..7289b474e5
--- /dev/null
+++ b/web/app/components/billing/usage-info/apps-info.spec.tsx
@@ -0,0 +1,35 @@
+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()
+
+ 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()
+ })
+})
diff --git a/web/app/components/billing/usage-info/index.spec.tsx b/web/app/components/billing/usage-info/index.spec.tsx
new file mode 100644
index 0000000000..3137c4865f
--- /dev/null
+++ b/web/app/components/billing/usage-info/index.spec.tsx
@@ -0,0 +1,114 @@
+import { render, screen } from '@testing-library/react'
+import { NUM_INFINITE } from '../config'
+import UsageInfo from './index'
+
+const TestIcon = () =>
+
+describe('UsageInfo', () => {
+ it('renders the metric with a suffix unit and tooltip text', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
+ expect(screen.getByText('Apps')).toBeInTheDocument()
+ expect(screen.getByText('30')).toBeInTheDocument()
+ expect(screen.getByText('100')).toBeInTheDocument()
+ expect(screen.getByText('GB')).toBeInTheDocument()
+ })
+
+ it('renders inline unit when unitPosition is inline', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('100GB')).toBeInTheDocument()
+ })
+
+ it('shows reset hint text instead of the unit when resetHint is provided', () => {
+ const resetHint = 'Resets in 3 days'
+ render(
+ ,
+ )
+
+ expect(screen.getByText(resetHint)).toBeInTheDocument()
+ expect(screen.queryByText('GB')).not.toBeInTheDocument()
+ })
+
+ it('displays unlimited text when total is infinite', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
+ })
+
+ it('applies warning color when usage is close to the limit', () => {
+ render(
+ ,
+ )
+
+ const progressBar = screen.getByTestId('billing-progress-bar')
+ expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
+ })
+
+ it('applies error color when usage exceeds the limit', () => {
+ render(
+ ,
+ )
+
+ const progressBar = screen.getByTestId('billing-progress-bar')
+ expect(progressBar).toHaveClass('bg-components-progress-error-progress')
+ })
+
+ it('does not render the icon when hideIcon is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/vector-space-full/index.spec.tsx b/web/app/components/billing/vector-space-full/index.spec.tsx
new file mode 100644
index 0000000000..de5607df41
--- /dev/null
+++ b/web/app/components/billing/vector-space-full/index.spec.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react'
+import VectorSpaceFull from './index'
+
+type VectorProviderGlobal = typeof globalThis & {
+ __vectorProviderContext?: ReturnType
+}
+
+function getVectorGlobal(): VectorProviderGlobal {
+ return globalThis as VectorProviderGlobal
+}
+
+vi.mock('@/context/provider-context', () => {
+ const mock = vi.fn()
+ getVectorGlobal().__vectorProviderContext = mock
+ return {
+ useProviderContext: () => mock(),
+ }
+})
+
+vi.mock('../upgrade-btn', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+describe('VectorSpaceFull', () => {
+ const planMock = {
+ type: 'team',
+ usage: {
+ vectorSpace: 8,
+ },
+ total: {
+ vectorSpace: 10,
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ const globals = getVectorGlobal()
+ globals.__vectorProviderContext?.mockReturnValue({
+ plan: planMock,
+ })
+ })
+
+ it('renders tip text and upgrade button', () => {
+ render()
+
+ expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
+ expect(screen.getByText('billing.vectorSpace.fullSolution')).toBeInTheDocument()
+ expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
+ })
+
+ it('shows vector usage and total', () => {
+ render()
+
+ expect(screen.getByText('8')).toBeInTheDocument()
+ expect(screen.getByText('10MB')).toBeInTheDocument()
+ })
+})