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() + }) + }) +})