diff --git a/web/app/components/app/configuration/base/feature-panel/index.spec.tsx b/web/app/components/app/configuration/base/feature-panel/index.spec.tsx new file mode 100644 index 0000000000..7e1b661399 --- /dev/null +++ b/web/app/components/app/configuration/base/feature-panel/index.spec.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react' +import FeaturePanel from './index' + +describe('FeaturePanel', () => { + // Rendering behavior for standard layout. + describe('Rendering', () => { + it('should render the title and children when provided', () => { + // Arrange + render( + +
Panel Body
+
, + ) + + // Assert + expect(screen.getByText('Panel Title')).toBeInTheDocument() + expect(screen.getByText('Panel Body')).toBeInTheDocument() + }) + }) + + // Prop-driven presentation details like icons, actions, and spacing. + describe('Props', () => { + it('should render header icon and right slot and apply header border', () => { + // Arrange + render( + Icon} + headerRight={} + hasHeaderBottomBorder={true} + />, + ) + + // Assert + expect(screen.getByText('Icon')).toBeInTheDocument() + expect(screen.getByText('Action')).toBeInTheDocument() + const header = screen.getByTestId('feature-panel-header') + expect(header).toHaveClass('border-b') + }) + + it('should apply custom className and remove padding when noBodySpacing is true', () => { + // Arrange + const { container } = render( + +
Body
+
, + ) + + // Assert + const root = container.firstElementChild as HTMLElement + expect(root).toHaveClass('custom-panel') + expect(root).toHaveClass('pb-0') + const body = screen.getByTestId('feature-panel-body') + expect(body).not.toHaveClass('mt-1') + expect(body).not.toHaveClass('px-3') + }) + }) + + // Edge cases when optional content is missing. + describe('Edge Cases', () => { + it('should not render the body wrapper when children is undefined', () => { + // Arrange + render() + + // Assert + expect(screen.queryByText('No Body')).toBeInTheDocument() + expect(screen.queryByText('Panel Body')).not.toBeInTheDocument() + expect(screen.queryByTestId('feature-panel-body')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/configuration/base/feature-panel/index.tsx b/web/app/components/app/configuration/base/feature-panel/index.tsx index 20c4a8dc17..06ae2ab10a 100644 --- a/web/app/components/app/configuration/base/feature-panel/index.tsx +++ b/web/app/components/app/configuration/base/feature-panel/index.tsx @@ -25,7 +25,7 @@ const FeaturePanel: FC = ({ return (
{/* Header */} -
+
{headerIcon &&
{headerIcon}
} @@ -38,7 +38,7 @@ const FeaturePanel: FC = ({
{/* Body */} {children && ( -
+
{children}
)} diff --git a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx new file mode 100644 index 0000000000..a11b582b0f --- /dev/null +++ b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx @@ -0,0 +1,274 @@ +import type { Mock } from 'vitest' +import type { UsagePlanInfo } from '@/app/components/billing/type' +import type { AppContextValue } from '@/context/app-context' +import type { ProviderContextState } from '@/context/provider-context' +import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' +import { render, screen } from '@testing-library/react' +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' + +vi.mock('@/context/app-context', () => ({ + useAppContext: vi.fn(), +})) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: vi.fn(), + }), +})) + +vi.mock('@/app/components/header/utils/util', () => ({ + mailToSupport: vi.fn(), +})) + +const buildUsage = (overrides: Partial = {}): UsagePlanInfo => ({ + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + ...overrides, +}) + +const buildProviderContext = (overrides: Partial = {}): ProviderContextState => ({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 2 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + ...overrides, +}) + +const buildAppContext = (overrides: Partial = {}): AppContextValue => { + const userProfile: UserProfileResponse = { + id: 'user-id', + name: 'Test User', + email: 'user@example.com', + avatar: '', + avatar_url: '', + is_password_set: false, + } + const currentWorkspace: ICurrentWorkspace = { + id: 'workspace-id', + name: 'Workspace', + plan: '', + status: '', + created_at: 0, + role: 'normal', + providers: [], + } + const langGeniusVersionInfo: LangGeniusVersionResponse = { + current_env: '', + current_version: '1.0.0', + latest_version: '', + release_date: '', + release_notes: '', + version: '', + can_auto_update: false, + } + const base: Omit = { + userProfile, + currentWorkspace, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceDatasetOperator: false, + mutateUserProfile: vi.fn(), + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo, + isLoadingCurrentWorkspace: false, + } + const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector }) + return { + ...base, + useSelector, + ...overrides, + } +} + +describe('AppsFull', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext()) + ;(useAppContext as Mock).mockReturnValue(buildAppContext()) + ;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com') + }) + + // Rendering behavior for non-team plans. + describe('Rendering', () => { + it('should render the sandbox messaging and upgrade button', () => { + // Act + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument() + expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() + expect(screen.getByText('2/10')).toBeInTheDocument() + }) + }) + + // 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, + type: Plan.team, + usage: buildUsage({ buildApps: 8 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + render() + + // Assert + expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() + expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com') + expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0') + }) + + it('should render upgrade button for professional plans', () => { + // Arrange + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.professional, + usage: buildUsage({ buildApps: 4 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // 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, + type: Plan.enterprise, + usage: buildUsage({ buildApps: 9 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // 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') + expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.enterprise, '1.0.0') + }) + }) + + // 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, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 2 }), + total: buildUsage({ buildApps: 5 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // 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, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 6 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // 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, + type: Plan.sandbox, + usage: buildUsage({ buildApps: 8 }), + total: buildUsage({ buildApps: 10 }), + reset: { + apiRateLimit: null, + triggerEvents: null, + }, + }, + })) + + // Act + render() + + // Assert + expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress') + }) + }) +}) diff --git a/web/app/components/billing/pricing/assets/index.spec.tsx b/web/app/components/billing/pricing/assets/index.spec.tsx new file mode 100644 index 0000000000..7980f9a182 --- /dev/null +++ b/web/app/components/billing/pricing/assets/index.spec.tsx @@ -0,0 +1,64 @@ +import { render } from '@testing-library/react' +import { + Cloud, + Community, + Enterprise, + EnterpriseNoise, + NoiseBottom, + NoiseTop, + Premium, + PremiumNoise, + Professional, + Sandbox, + SelfHosted, + Team, +} from './index' + +describe('Pricing Assets', () => { + // Rendering: each asset should render an svg. + describe('Rendering', () => { + it('should render static assets without crashing', () => { + // Arrange + const assets = [ + , + , + , + , + , + , + , + , + , + , + ] + + // Act / Assert + assets.forEach((asset) => { + const { container, unmount } = render(asset) + expect(container.querySelector('svg')).toBeInTheDocument() + unmount() + }) + }) + }) + + // Props: active state should change fill color for selectable assets. + describe('Props', () => { + it('should render active state for Cloud', () => { + // 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() + + // Assert + const rects = Array.from(container.querySelectorAll('rect')) + expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true) + }) + }) +}) diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/footer.spec.tsx new file mode 100644 index 0000000000..f8e7965f5e --- /dev/null +++ b/web/app/components/billing/pricing/footer.spec.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { CategoryEnum } from '.' +import Footer from './footer' + +let mockTranslations: Record = {} + +vi.mock('next/link', () => ({ + default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( + + {children} + + ), +})) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => mockTranslations[key] ?? key, + }), + } +}) + +describe('Footer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTranslations = {} + }) + + // Rendering behavior + describe('Rendering', () => { + it('should render tax tips and comparison link when in cloud category', () => { + // Arrange + render(