diff --git a/web/app/components/billing/annotation-full/usage.spec.tsx b/web/app/components/billing/annotation-full/usage.spec.tsx
new file mode 100644
index 0000000000..c5fd1a2b10
--- /dev/null
+++ b/web/app/components/billing/annotation-full/usage.spec.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react'
+import Usage from './usage'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+const mockPlan = {
+ usage: {
+ annotatedResponse: 50,
+ },
+ total: {
+ annotatedResponse: 100,
+ },
+}
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: mockPlan,
+ }),
+}))
+
+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()
+
+ // Assert
+ expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
+ })
+
+ it('should pass className to UsageInfo component', () => {
+ // Arrange
+ const testClassName = 'mt-4'
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass(testClassName)
+ })
+
+ it('should display usage and total values from context', () => {
+ // Arrange & Act
+ render()
+
+ // Assert
+ expect(screen.getByText('50')).toBeInTheDocument()
+ expect(screen.getByText('100')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/billing/billing-page/index.spec.tsx b/web/app/components/billing/billing-page/index.spec.tsx
index 8b68f74012..f80c688d47 100644
--- a/web/app/components/billing/billing-page/index.spec.tsx
+++ b/web/app/components/billing/billing-page/index.spec.tsx
@@ -73,6 +73,56 @@ describe('Billing', () => {
})
})
+ it('returns the refetched url from the async callback', async () => {
+ const newUrl = 'https://new-billing-url'
+ refetchMock.mockResolvedValue({ data: newUrl })
+ render()
+
+ const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
+ fireEvent.click(actionButton)
+
+ await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
+ const [asyncCallback] = openAsyncWindowMock.mock.calls[0]
+
+ // Execute the async callback passed to openAsyncWindow
+ const result = await asyncCallback()
+ expect(result).toBe(newUrl)
+ expect(refetchMock).toHaveBeenCalled()
+ })
+
+ it('returns null when refetch returns no url', async () => {
+ refetchMock.mockResolvedValue({ data: null })
+ render()
+
+ const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
+ fireEvent.click(actionButton)
+
+ await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
+ const [asyncCallback] = openAsyncWindowMock.mock.calls[0]
+
+ // Execute the async callback when url is null
+ const result = await asyncCallback()
+ expect(result).toBeNull()
+ })
+
+ it('handles errors in onError callback', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ render()
+
+ const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
+ fireEvent.click(actionButton)
+
+ await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
+ const [, options] = openAsyncWindowMock.mock.calls[0]
+
+ // Execute the onError callback
+ const testError = new Error('Test error')
+ options.onError(testError)
+ expect(consoleError).toHaveBeenCalledWith('Failed to fetch billing url', testError)
+
+ consoleError.mockRestore()
+ })
+
it('disables the button while billing url is fetching', () => {
fetching = true
render()
diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx
index 473f81f9f4..fb1800653e 100644
--- a/web/app/components/billing/plan/index.spec.tsx
+++ b/web/app/components/billing/plan/index.spec.tsx
@@ -125,4 +125,70 @@ describe('PlanComp', () => {
expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
})
+
+ it('does not trigger verify when isPending is true', async () => {
+ isPending = true
+ render()
+
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(mutateAsyncMock).not.toHaveBeenCalled())
+ })
+
+ it('renders sandbox plan', () => {
+ providerContextMock.mockReturnValue({
+ plan: { ...planMock, type: Plan.sandbox },
+ enableEducationPlan: false,
+ allowRefreshEducationVerify: false,
+ isEducationAccount: false,
+ })
+ render()
+
+ expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
+ })
+
+ it('renders team plan', () => {
+ providerContextMock.mockReturnValue({
+ plan: { ...planMock, type: Plan.team },
+ enableEducationPlan: false,
+ allowRefreshEducationVerify: false,
+ isEducationAccount: false,
+ })
+ render()
+
+ expect(screen.getByText('billing.plans.team.name')).toBeInTheDocument()
+ })
+
+ it('shows verify button when education account is about to expire', () => {
+ providerContextMock.mockReturnValue({
+ plan: planMock,
+ enableEducationPlan: true,
+ allowRefreshEducationVerify: true,
+ isEducationAccount: true,
+ })
+ render()
+
+ expect(screen.getByText('education.toVerified')).toBeInTheDocument()
+ })
+
+ it('handles modal onConfirm and onCancel callbacks', async () => {
+ mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
+ render()
+
+ // Trigger verify to show modal
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
+
+ // Get the props passed to the modal and call onConfirm/onCancel
+ const lastCall = verifyStateModalMock.mock.calls[verifyStateModalMock.mock.calls.length - 1][0]
+ expect(lastCall.onConfirm).toBeDefined()
+ expect(lastCall.onCancel).toBeDefined()
+
+ // Call onConfirm to close modal
+ lastCall.onConfirm()
+ lastCall.onCancel()
+ })
})
diff --git a/web/app/components/billing/pricing/assets/index.spec.tsx b/web/app/components/billing/pricing/assets/index.spec.tsx
index 7980f9a182..cc56c57593 100644
--- a/web/app/components/billing/pricing/assets/index.spec.tsx
+++ b/web/app/components/billing/pricing/assets/index.spec.tsx
@@ -52,6 +52,24 @@ describe('Pricing Assets', () => {
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()
+
+ // 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()
+
+ // 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()
diff --git a/web/app/components/billing/utils/index.spec.ts b/web/app/components/billing/utils/index.spec.ts
new file mode 100644
index 0000000000..03a159c18a
--- /dev/null
+++ b/web/app/components/billing/utils/index.spec.ts
@@ -0,0 +1,301 @@
+import type { CurrentPlanInfoBackend } from '../type'
+import { DocumentProcessingPriority, Plan } from '../type'
+import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index'
+
+describe('billing utils', () => {
+ // parseVectorSpaceToMB tests
+ describe('parseVectorSpaceToMB', () => {
+ it('should parse MB values correctly', () => {
+ expect(parseVectorSpaceToMB('50MB')).toBe(50)
+ expect(parseVectorSpaceToMB('100MB')).toBe(100)
+ })
+
+ it('should parse GB values and convert to MB', () => {
+ expect(parseVectorSpaceToMB('5GB')).toBe(5 * 1024)
+ expect(parseVectorSpaceToMB('20GB')).toBe(20 * 1024)
+ })
+
+ it('should be case insensitive', () => {
+ expect(parseVectorSpaceToMB('50mb')).toBe(50)
+ expect(parseVectorSpaceToMB('5gb')).toBe(5 * 1024)
+ })
+
+ it('should return 0 for invalid format', () => {
+ expect(parseVectorSpaceToMB('50')).toBe(0)
+ expect(parseVectorSpaceToMB('invalid')).toBe(0)
+ expect(parseVectorSpaceToMB('')).toBe(0)
+ expect(parseVectorSpaceToMB('50TB')).toBe(0)
+ })
+ })
+
+ // getPlanVectorSpaceLimitMB tests
+ describe('getPlanVectorSpaceLimitMB', () => {
+ it('should return correct vector space for sandbox plan', () => {
+ expect(getPlanVectorSpaceLimitMB(Plan.sandbox)).toBe(50)
+ })
+
+ it('should return correct vector space for professional plan', () => {
+ expect(getPlanVectorSpaceLimitMB(Plan.professional)).toBe(5 * 1024)
+ })
+
+ it('should return correct vector space for team plan', () => {
+ expect(getPlanVectorSpaceLimitMB(Plan.team)).toBe(20 * 1024)
+ })
+
+ it('should return 0 for invalid plan', () => {
+ // @ts-expect-error - Testing invalid plan input
+ expect(getPlanVectorSpaceLimitMB('invalid')).toBe(0)
+ })
+ })
+
+ // parseCurrentPlan tests
+ describe('parseCurrentPlan', () => {
+ const createMockPlanData = (overrides: Partial = {}): CurrentPlanInfoBackend => ({
+ billing: {
+ enabled: true,
+ subscription: {
+ plan: Plan.sandbox,
+ },
+ },
+ members: {
+ size: 1,
+ limit: 1,
+ },
+ apps: {
+ size: 2,
+ limit: 5,
+ },
+ vector_space: {
+ size: 10,
+ limit: 50,
+ },
+ annotation_quota_limit: {
+ size: 5,
+ limit: 10,
+ },
+ documents_upload_quota: {
+ size: 20,
+ limit: 0,
+ },
+ docs_processing: DocumentProcessingPriority.standard,
+ can_replace_logo: false,
+ model_load_balancing_enabled: false,
+ dataset_operator_enabled: false,
+ education: {
+ enabled: false,
+ activated: false,
+ },
+ webapp_copyright_enabled: false,
+ workspace_members: {
+ size: 1,
+ limit: 1,
+ },
+ is_allow_transfer_workspace: false,
+ knowledge_pipeline: {
+ publish_enabled: false,
+ },
+ ...overrides,
+ })
+
+ it('should parse plan type correctly', () => {
+ const data = createMockPlanData()
+ const result = parseCurrentPlan(data)
+ expect(result.type).toBe(Plan.sandbox)
+ })
+
+ it('should parse usage values correctly', () => {
+ const data = createMockPlanData()
+ const result = parseCurrentPlan(data)
+
+ expect(result.usage.vectorSpace).toBe(10)
+ expect(result.usage.buildApps).toBe(2)
+ expect(result.usage.teamMembers).toBe(1)
+ expect(result.usage.annotatedResponse).toBe(5)
+ expect(result.usage.documentsUploadQuota).toBe(20)
+ })
+
+ it('should parse total limits correctly', () => {
+ const data = createMockPlanData()
+ const result = parseCurrentPlan(data)
+
+ expect(result.total.vectorSpace).toBe(50)
+ expect(result.total.buildApps).toBe(5)
+ expect(result.total.teamMembers).toBe(1)
+ expect(result.total.annotatedResponse).toBe(10)
+ })
+
+ it('should convert 0 limits to NUM_INFINITE (-1)', () => {
+ const data = createMockPlanData({
+ documents_upload_quota: {
+ size: 20,
+ limit: 0,
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.total.documentsUploadQuota).toBe(-1)
+ })
+
+ it('should handle api_rate_limit quota', () => {
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: null,
+ },
+ })
+ const result = parseCurrentPlan(data)
+
+ expect(result.usage.apiRateLimit).toBe(100)
+ expect(result.total.apiRateLimit).toBe(5000)
+ })
+
+ it('should handle trigger_event quota', () => {
+ const data = createMockPlanData({
+ trigger_event: {
+ usage: 50,
+ limit: 3000,
+ reset_date: null,
+ },
+ })
+ const result = parseCurrentPlan(data)
+
+ expect(result.usage.triggerEvents).toBe(50)
+ expect(result.total.triggerEvents).toBe(3000)
+ })
+
+ it('should use fallback for api_rate_limit when not provided', () => {
+ const data = createMockPlanData()
+ const result = parseCurrentPlan(data)
+
+ // Fallback to plan preset value for sandbox: 5000
+ expect(result.total.apiRateLimit).toBe(5000)
+ })
+
+ it('should convert 0 or -1 rate limits to NUM_INFINITE', () => {
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 0,
+ limit: 0,
+ reset_date: null,
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.total.apiRateLimit).toBe(-1)
+
+ const data2 = createMockPlanData({
+ api_rate_limit: {
+ usage: 0,
+ limit: -1,
+ reset_date: null,
+ },
+ })
+ const result2 = parseCurrentPlan(data2)
+ expect(result2.total.apiRateLimit).toBe(-1)
+ })
+
+ it('should handle reset dates with milliseconds timestamp', () => {
+ const futureDate = Date.now() + 86400000 // Tomorrow in ms
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: futureDate,
+ },
+ })
+ const result = parseCurrentPlan(data)
+
+ expect(result.reset.apiRateLimit).toBe(1)
+ })
+
+ it('should handle reset dates with seconds timestamp', () => {
+ const futureDate = Math.floor(Date.now() / 1000) + 86400 // Tomorrow in seconds
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: futureDate,
+ },
+ })
+ const result = parseCurrentPlan(data)
+
+ expect(result.reset.apiRateLimit).toBe(1)
+ })
+
+ it('should handle reset dates in YYYYMMDD format', () => {
+ const tomorrow = new Date()
+ tomorrow.setDate(tomorrow.getDate() + 1)
+ const year = tomorrow.getFullYear()
+ const month = String(tomorrow.getMonth() + 1).padStart(2, '0')
+ const day = String(tomorrow.getDate()).padStart(2, '0')
+ const dateNumber = Number.parseInt(`${year}${month}${day}`, 10)
+
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: dateNumber,
+ },
+ })
+ const result = parseCurrentPlan(data)
+
+ expect(result.reset.apiRateLimit).toBe(1)
+ })
+
+ it('should return null for invalid reset dates', () => {
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: 0,
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.reset.apiRateLimit).toBeNull()
+ })
+
+ it('should return null for negative reset dates', () => {
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: -1,
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.reset.apiRateLimit).toBeNull()
+ })
+
+ it('should return null when reset date is in the past', () => {
+ const pastDate = Date.now() - 86400000 // Yesterday
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: pastDate,
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.reset.apiRateLimit).toBeNull()
+ })
+
+ it('should handle missing apps field', () => {
+ const data = createMockPlanData()
+ // @ts-expect-error - Testing edge case
+ delete data.apps
+ const result = parseCurrentPlan(data)
+ expect(result.usage.buildApps).toBe(0)
+ })
+
+ it('should return null for unrecognized date format', () => {
+ const data = createMockPlanData({
+ api_rate_limit: {
+ usage: 100,
+ limit: 5000,
+ reset_date: 12345, // Unrecognized format
+ },
+ })
+ const result = parseCurrentPlan(data)
+ expect(result.reset.apiRateLimit).toBeNull()
+ })
+ })
+})