mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 22:05:43 +08:00
Compare commits
14 Commits
test/billi
...
test/integ
| Author | SHA1 | Date | |
|---|---|---|---|
| ca9c2a62fb | |||
| f6c03362e7 | |||
| e92867884f | |||
| a9f56716fc | |||
| 94eefaaee2 | |||
| 59f3acb021 | |||
| 4655a6a244 | |||
| 5006a5e804 | |||
| 5e6e8a16ce | |||
| 0301d6b690 | |||
| 755c9b0c15 | |||
| f1cce53bc2 | |||
| a29e74422e | |||
| 83ef687d00 |
@ -1,991 +0,0 @@
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
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'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Vitest hoists vi.mock() calls, so imports above will use mocked modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Billing Page + Plan Component Integration
|
||||
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Billing Page + Plan Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
// Plan name
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
|
||||
// All 7 usage items should be visible
|
||||
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display usage values as "usage / total" format', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 3, teamMembers: 1 },
|
||||
total: { buildApps: 5, teamMembers: 1 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Check that the buildApps usage fraction "3 / 5" is rendered
|
||||
const usageContainers = screen.getAllByText('3')
|
||||
expect(usageContainers.length).toBeGreaterThan(0)
|
||||
const totalContainers = screen.getAllByText('5')
|
||||
expect(totalContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Reset text should be visible
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: true })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when user is not workspace manager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Plan Type Display Integration
|
||||
// Tests that different plan types render correct visual elements
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Plan Type Display Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
|
||||
// Sandbox shows premium badge upgrade button (not plain)
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
// Professional shows plain button because it's not team
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render upgrade button for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Upgrade Flow Integration
|
||||
// Tests the flow: UpgradeBtn click → setShowPricingModal
|
||||
// and PlanUpgradeModal → close + trigger pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
describe('UpgradeBtn triggers pricing modal', () => {
|
||||
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
|
||||
const customOnClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn onClick={customOnClick} />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(customOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire gtag event with loc parameter when clicked', async () => {
|
||||
const mockGtag = vi.fn()
|
||||
;(window as unknown as Record<string, unknown>).gtag = mockGtag
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn loc="billing-page" />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
|
||||
delete (window as unknown as Record<string, unknown>).gtag
|
||||
})
|
||||
})
|
||||
|
||||
// PlanUpgradeModal integration: close modal and trigger pricing
|
||||
describe('PlanUpgradeModal upgrade flow', () => {
|
||||
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Upgrade Required"
|
||||
description="You need a better plan"
|
||||
/>,
|
||||
)
|
||||
|
||||
// The modal should show title and description
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
|
||||
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
|
||||
|
||||
// Click the upgrade button inside the modal
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
// Should close the current modal first
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Then open pricing modal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose and custom onUpgrade when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
// Custom onUpgrade replaces default setShowPricingModal
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking dismiss button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
|
||||
await user.click(dismissBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Capacity Full Components Integration
|
||||
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
|
||||
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Capacity Full Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Should show "full" tip
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.team,
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Team plan shows different tip
|
||||
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
|
||||
// Team plan shows "Contact Us" instead of upgrade
|
||||
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
// Should show full tip
|
||||
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Should show vector space usage info
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
|
||||
// UpgradeBtn rendered
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Usage component should show annotation quota
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={vi.fn()}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal title and description
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
// Embedded UsageInfo with trigger events data
|
||||
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
// Reset info
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
// Upgrade and dismiss buttons
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose and onUpgrade when clicking upgrade', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Header Billing Button Integration
|
||||
// Tests HeaderBillingBtn behavior for different plan states
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Header Billing Button Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "pro" badge for professional plan', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "team" badge for team plan', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plan is not fetched yet', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisplayOnly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. PriorityLabel Integration
|
||||
// Tests priority badge display for different plan types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('PriorityLabel Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
|
||||
// Professional plan should show the priority icon
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Usage Display Edge Cases
|
||||
// Tests storage mode, threshold logic, and progress bar color integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Usage Display Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Storage mode: usage below threshold shows "< 50"
|
||||
expect(screen.getByText(/</)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Pro plan above threshold shows actual value
|
||||
expect(screen.getByText('1024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Find the trigger events section - should not have reset text
|
||||
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
|
||||
const parent = triggerSection.closest('[class*="flex flex-col"]')
|
||||
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
|
||||
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Professional plan with finite triggerEvents should show reset
|
||||
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
|
||||
expect(resetTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Cross-Component Upgrade Flow (End-to-End)
|
||||
// Tests the complete chain: capacity alert → upgrade button → pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Cross-Component Upgrade Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
|
||||
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Integration test: Cloud Plan Payment Flow
|
||||
*
|
||||
* Tests the payment flow for cloud plan items:
|
||||
* CloudPlanItem → Button click → permission check → fetch URL → redirect
|
||||
*
|
||||
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import type { BasicPlan } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockFetchSubscriptionUrls = vi.fn()
|
||||
const mockInvoices = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: () => mockInvoices(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type RenderCloudPlanItemOptions = {
|
||||
currentPlan?: BasicPlan
|
||||
plan?: BasicPlan
|
||||
planRange?: PlanRange
|
||||
canPay?: boolean
|
||||
}
|
||||
|
||||
const renderCloudPlanItem = ({
|
||||
currentPlan = Plan.sandbox,
|
||||
plan = Plan.professional,
|
||||
planRange = PlanRange.monthly,
|
||||
canPay = true,
|
||||
}: RenderCloudPlanItemOptions = {}) => {
|
||||
return render(
|
||||
<CloudPlanItem
|
||||
currentPlan={currentPlan}
|
||||
plan={plan}
|
||||
planRange={planRange}
|
||||
canPay={canPay}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Cloud Plan Payment Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
|
||||
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
|
||||
})
|
||||
|
||||
// ─── 1. Plan Display ────────────────────────────────────────────────────
|
||||
describe('Plan display', () => {
|
||||
it('should render plan name and description', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" price for sandbox plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show monthly price for paid plans', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly })
|
||||
|
||||
expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly })
|
||||
|
||||
const yearlyPrice = ALL_PLANS.professional.price * 10
|
||||
const originalPrice = ALL_PLANS.professional.price * 12
|
||||
|
||||
expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge for professional plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show "most popular" badge for sandbox or team plans', () => {
|
||||
const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ plan: Plan.team })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Button Text Logic ───────────────────────────────────────────────
|
||||
describe('Button text logic', () => {
|
||||
it('should show "Current Plan" when plan matches current plan', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start for Free" for sandbox plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start Building" for professional plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Get Started" for team plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Downgrade Prevention ────────────────────────────────────────────
|
||||
describe('Downgrade prevention', () => {
|
||||
it('should disable sandbox button when user is on professional plan (downgrade)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable sandbox and professional buttons when user is on team plan', () => {
|
||||
const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable current paid plan button (for invoice management)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable higher-tier plan buttons for upgrade', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Payment URL Flow ────────────────────────────────────────────────
|
||||
describe('Payment URL flow', () => {
|
||||
it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Simulate clicking on a professional plan button (user is on sandbox)
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.professional,
|
||||
planRange: PlanRange.monthly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.team,
|
||||
planRange: PlanRange.yearly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
})
|
||||
})
|
||||
|
||||
it('should open invoice management for current paid plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalled()
|
||||
})
|
||||
// Should NOT call fetchSubscriptionUrls (invoice, not subscription)
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not do anything when clicking on sandbox free plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Wait a tick and verify no actions were taken
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks upgrade button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Should not proceed with payment
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Integration test: Education Verification Flow
|
||||
*
|
||||
* Tests the education plan verification flow in PlanComp:
|
||||
* PlanComp → handleVerify → useEducationVerify → router.push → education-apply
|
||||
* PlanComp → handleVerify → error → show VerifyStateModal
|
||||
*
|
||||
* Also covers education button visibility based on context flags.
|
||||
*/
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow, title, content, email, showLink }: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
content?: string
|
||||
email?: string
|
||||
showLink?: boolean
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="verify-state-modal">
|
||||
{title && <span data-testid="modal-title">{title}</span>}
|
||||
{content && <span data-testid="modal-content">{content}</span>}
|
||||
{email && <span data-testid="modal-email">{email}</span>}
|
||||
{showLink && <span data-testid="modal-show-link">link</span>}
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupContexts = (
|
||||
planOverrides: PlanOverrides = {},
|
||||
providerOverrides: Record<string, unknown> = {},
|
||||
appOverrides: Record<string, unknown> = {},
|
||||
) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'student@university.edu' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Education Verification Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Education Button Visibility ─────────────────────────────────────
|
||||
describe('Education button visibility', () => {
|
||||
it('should not show verify button when enableEducationPlan is false', () => {
|
||||
setupContexts({}, { enableEducationPlan: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show verify button when already verified and not about to expire', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: true,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Shown because isAboutToExpire = allowRefreshEducationVerify = true
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Successful Verification Flow ────────────────────────────────────
|
||||
describe('Successful verification flow', () => {
|
||||
it('should navigate to education-apply with token on successful verification', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove education verifying flag from localStorage on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Failed Verification Flow ────────────────────────────────────────
|
||||
describe('Failed verification flow', () => {
|
||||
it('should show VerifyStateModal with rejection info on error', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Verification failed'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Modal should not be visible initially
|
||||
expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument()
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
// Modal should appear after verification failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Modal should display rejection title and content
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i)
|
||||
expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i)
|
||||
})
|
||||
|
||||
it('should show email and link in VerifyStateModal', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu')
|
||||
expect(screen.getByTestId('modal-show-link')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not redirect on verification failure', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT navigate
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
describe('Education and upgrade button coexistence', () => {
|
||||
it('should show both education verify and upgrade buttons for sandbox user', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.sandbox },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show upgrade button for enterprise plan', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.enterprise },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show team plan with plain upgrade button and education button', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.team },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Integration test: Partner Stack Flow
|
||||
*
|
||||
* Tests the PartnerStack integration:
|
||||
* PartnerStack component → usePSInfo hook → cookie management → bind API call
|
||||
*
|
||||
* Covers URL param reading, cookie persistence, API bind on mount,
|
||||
* cookie cleanup after successful bind, and error handling for 400 status.
|
||||
*/
|
||||
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import * as React from 'react'
|
||||
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Module mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBindPartnerStackInfo: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useBillingUrl: () => ({
|
||||
data: '',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
PARTNER_STACK_CONFIG: {
|
||||
cookieName: 'partner_stack_info',
|
||||
saveCookieDays: 90,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Cookie helpers ──────────────────────────────────────────────────────────
|
||||
const getCookieData = () => {
|
||||
const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName)
|
||||
if (!raw)
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const setCookieData = (data: Record<string, string>) => {
|
||||
Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const clearCookie = () => {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Partner Stack Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
clearCookie()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// ─── 1. URL Param Reading ───────────────────────────────────────────────
|
||||
describe('URL param reading', () => {
|
||||
it('should read ps_partner_key and ps_xid from URL search params', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-123',
|
||||
ps_xid: 'click-456',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('partner-123')
|
||||
expect(result.current.psClickId).toBe('click-456')
|
||||
})
|
||||
|
||||
it('should fall back to cookie when URL params are not present', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
it('should prefer URL params over cookie values', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'url-partner',
|
||||
ps_xid: 'url-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('url-partner')
|
||||
expect(result.current.psClickId).toBe('url-click')
|
||||
})
|
||||
|
||||
it('should return null for both values when no params and no cookie', () => {
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Cookie Persistence (saveOrUpdate) ───────────────────────────────
|
||||
describe('Cookie persistence via saveOrUpdate', () => {
|
||||
it('should save PS info to cookie when URL params provide new values', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toEqual({
|
||||
partnerKey: 'new-partner',
|
||||
clickId: 'new-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update cookie when values have not changed', () => {
|
||||
setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'same-partner',
|
||||
ps_xid: 'same-click',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
// Should not call set because values haven't changed
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when partner key is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when click ID is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
|
||||
describe('Bind API flow', () => {
|
||||
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'bind-partner',
|
||||
clickId: 'bind-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove cookie after successful bind', async () => {
|
||||
setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'rm-partner',
|
||||
ps_xid: 'rm-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed after successful bind
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove cookie on 400 error (already bound)', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 400 })
|
||||
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'err-partner',
|
||||
ps_xid: 'err-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed even on 400
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not remove cookie on non-400 errors', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 500 })
|
||||
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'keep-partner',
|
||||
ps_xid: 'keep-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should still exist for non-400 errors
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call bind when partner key is missing', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call bind a second time (idempotency)', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-once',
|
||||
ps_xid: 'click-once',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
// First bind
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second bind should be skipped (hasBind = true)
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
|
||||
describe('PartnerStack component mount behavior', () => {
|
||||
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'mount-partner',
|
||||
ps_xid: 'mount-click',
|
||||
})
|
||||
|
||||
// Use lazy import so the mocks are applied
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
render(<PartnerStack />)
|
||||
|
||||
// The component calls saveOrUpdate and bind in useEffect
|
||||
await waitFor(() => {
|
||||
// Bind should have been called
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'mount-partner',
|
||||
clickId: 'mount-click',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie should have been saved (saveOrUpdate was called before bind)
|
||||
// After bind succeeds, cookie is removed
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render nothing (return null)', async () => {
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
const { container } = render(<PartnerStack />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Integration test: Pricing Modal Flow
|
||||
*
|
||||
* Tests the full Pricing modal lifecycle:
|
||||
* Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted)
|
||||
* → CloudPlanItem / SelfHostedPlanItem → Footer
|
||||
*
|
||||
* Validates cross-component state propagation when the user switches between
|
||||
* cloud / self-hosted categories and monthly / yearly plan ranges.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import Pricing from '@/app/components/billing/pricing'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks (lightweight) ─────────────────────────────────
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
// Self-hosted List uses t() with returnObjects which returns string in mock;
|
||||
// mock it to avoid deep i18n dependency (unit tests cover this component)
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const defaultPlanData = {
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 1,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 0,
|
||||
vectorSpace: 10,
|
||||
annotatedResponse: 1,
|
||||
triggerEvents: 0,
|
||||
apiRateLimit: 0,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
}
|
||||
|
||||
const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: { ...defaultPlanData, ...planOverrides },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Pricing Modal Flow', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Initial Rendering ────────────────────────────────────────────────
|
||||
describe('Initial rendering', () => {
|
||||
it('should render header with close button and footer with pricing link', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Header close button exists (multiple plan buttons also exist)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
// Footer pricing link
|
||||
expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should default to cloud category with three cloud plans', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Three cloud plans: sandbox, professional, team
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show plan range switcher (annual billing toggle) by default for cloud', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tax tip in footer for cloud category', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Use exact match to avoid matching taxTipSecond
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Category Switching ───────────────────────────────────────────────
|
||||
describe('Category switching', () => {
|
||||
it('should switch to self-hosted plans when clicking self-hosted tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Click the self-hosted tab
|
||||
const selfTab = screen.getByText(/plansCommon\.self/i)
|
||||
await user.click(selfTab)
|
||||
|
||||
// Self-hosted plans should appear
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
|
||||
// Cloud plans should disappear
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide plan range switcher for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Annual billing toggle should not be visible
|
||||
expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tax tip in footer for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch back to cloud plans when clicking cloud tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Switch to self-hosted
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to cloud
|
||||
await user.click(screen.getByText(/plansCommon\.cloud/i))
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Plan Range Switching (Monthly ↔ Yearly) ──────────────────────────
|
||||
describe('Plan range switching', () => {
|
||||
it('should show monthly prices by default', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional monthly price: $59
|
||||
const proPriceStr = `$${ALL_PLANS.professional.price}`
|
||||
expect(screen.getByText(proPriceStr)).toBeInTheDocument()
|
||||
|
||||
// Team monthly price: $159
|
||||
const teamPriceStr = `$${ALL_PLANS.team.price}`
|
||||
expect(screen.getByText(teamPriceStr)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" for sandbox plan regardless of range', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge only for professional plan', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
|
||||
describe('Cloud plan button states', () => {
|
||||
it('should show "Current Plan" for the current plan (sandbox)', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show specific button text for non-current plans', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional button text
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
// Team button text
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => {
|
||||
setupContexts({ type: Plan.enterprise })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Enterprise is normalized to team for display, so team is "Current Plan"
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Self-Hosted Plan Details ─────────────────────────────────────────
|
||||
describe('Self-hosted plan details', () => {
|
||||
it('should show cloud provider icons only for premium plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Premium plan should show Azure and Google Cloud icons
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "coming soon" text for premium plan cloud providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 6. Close Handling ───────────────────────────────────────────────────
|
||||
describe('Close handling', () => {
|
||||
it('should call onCancel when pressing ESC key', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// ahooks useKeyPress listens on document for keydown events
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
|
||||
describe('Pricing page URL', () => {
|
||||
it('should render pricing link with correct URL', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
|
||||
expect(link.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://dify.ai/en/pricing#plans-and-features',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Integration test: Self-Hosted Plan Flow
|
||||
*
|
||||
* Tests the self-hosted plan items:
|
||||
* SelfHostedPlanItem → Button click → permission check → redirect to external URL
|
||||
*
|
||||
* Covers community/premium/enterprise plan rendering, external URL navigation,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
|
||||
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
const originalLocation = window.location
|
||||
let assignedHref = ''
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Self-Hosted Plan Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
|
||||
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
|
||||
assignedHref = ''
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() { return assignedHref },
|
||||
set href(value: string) { assignedHref = value },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original location
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
|
||||
describe('Plan rendering', () => {
|
||||
it('should render community plan with name and description', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium plan with cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render enterprise plan without cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show price tip for community (free) plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show price tip for premium plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render features list for each plan', () => {
|
||||
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
|
||||
unmount1()
|
||||
|
||||
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
|
||||
unmount2()
|
||||
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show AWS marketplace icon for premium plan button', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Navigation Flow ─────────────────────────────────────────────────
|
||||
describe('Navigation flow', () => {
|
||||
it('should redirect to GitHub when clicking community plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getStartedWithCommunityUrl)
|
||||
})
|
||||
|
||||
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getWithPremiumUrl)
|
||||
})
|
||||
|
||||
it('should redirect to Typeform when clicking enterprise plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(contactSalesUrl)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks community button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
// Should NOT redirect
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks premium button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks enterprise button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
301
web/__tests__/datasets/create-dataset-flow.test.tsx
Normal file
301
web/__tests__/datasets/create-dataset-flow.test.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Integration Test: Create Dataset Flow
|
||||
*
|
||||
* Tests cross-module data flow: step-one data → step-two hooks → creation params → API call
|
||||
* Validates data contracts between steps.
|
||||
*/
|
||||
|
||||
import type { CustomFile } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
const mockCreateFirstDocument = vi.fn()
|
||||
const mockCreateDocument = vi.fn()
|
||||
vi.mock('@/service/knowledge/use-create-dataset', () => ({
|
||||
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
|
||||
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
|
||||
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
|
||||
workspace_id: 'ws-1',
|
||||
pages: pages.map(p => p.page_id),
|
||||
notion_credential_id: credentialId,
|
||||
}),
|
||||
getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({
|
||||
urls: opts.websitePages.map(p => p.url),
|
||||
only_main_content: true,
|
||||
provider: opts.websiteCrawlProvider,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import hooks after mocks
|
||||
const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP }
|
||||
= await import('@/app/components/datasets/create/step-two/hooks')
|
||||
const { useDocumentCreation, IndexingType }
|
||||
= await import('@/app/components/datasets/create/step-two/hooks')
|
||||
|
||||
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 1024,
|
||||
extension: '.txt',
|
||||
mime_type: 'text/plain',
|
||||
created_at: 0,
|
||||
created_by: '',
|
||||
...overrides,
|
||||
} as CustomFile)
|
||||
|
||||
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Step-One → Step-Two: Segmentation Defaults', () => {
|
||||
it('should initialise with correct default segmentation values', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
|
||||
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
|
||||
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
|
||||
expect(result.current.segmentationType).toBe(ProcessMode.general)
|
||||
})
|
||||
|
||||
it('should produce valid process rule for general chunking', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
const processRule = result.current.getProcessRule(ChunkingMode.text)
|
||||
|
||||
// mode should be segmentationType = ProcessMode.general = 'custom'
|
||||
expect(processRule.mode).toBe('custom')
|
||||
expect(processRule.rules.segmentation).toEqual({
|
||||
separator: '\n\n', // unescaped from \\n\\n
|
||||
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
|
||||
chunk_overlap: DEFAULT_OVERLAP,
|
||||
})
|
||||
// rules is empty initially since no default config loaded
|
||||
expect(processRule.rules.pre_processing_rules).toEqual([])
|
||||
})
|
||||
|
||||
it('should produce valid process rule for parent-child chunking', () => {
|
||||
const { result } = renderHook(() => useSegmentationState())
|
||||
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
|
||||
|
||||
expect(processRule.mode).toBe('hierarchical')
|
||||
expect(processRule.rules.parent_mode).toBe('paragraph')
|
||||
expect(processRule.rules.segmentation).toEqual({
|
||||
separator: '\n\n',
|
||||
max_tokens: 1024,
|
||||
})
|
||||
expect(processRule.rules.subchunk_segmentation).toEqual({
|
||||
separator: '\n',
|
||||
max_tokens: 512,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Step-Two → Creation API: Params Building', () => {
|
||||
it('should build valid creation params for file upload workflow', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
|
||||
const retrievalConfig: RetrievalConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.text,
|
||||
'English',
|
||||
processRule,
|
||||
retrievalConfig,
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
// File IDs come from file.id (not file.file.id)
|
||||
expect(params!.data_source.type).toBe(DataSourceType.FILE)
|
||||
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
|
||||
|
||||
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
|
||||
expect(params!.doc_form).toBe(ChunkingMode.text)
|
||||
expect(params!.doc_language).toBe('English')
|
||||
expect(params!.embedding_model).toBe('text-embedding-ada-002')
|
||||
expect(params!.embedding_model_provider).toBe('openai')
|
||||
expect(params!.process_rule.mode).toBe('custom')
|
||||
})
|
||||
|
||||
it('should validate params: overlap must not exceed maxChunkLength', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files: [createMockFile()],
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
|
||||
const isValid = result.current.validateParams({
|
||||
segmentationType: 'general',
|
||||
maxChunkLength: 100,
|
||||
limitMaxChunkLength: 4000,
|
||||
overlap: 200, // overlap > maxChunkLength
|
||||
indexType: IndexingType.QUALIFIED,
|
||||
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
rerankModelList: [],
|
||||
retrievalConfig: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate params: maxChunkLength must not exceed limit', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files: [createMockFile()],
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const isValid = result.current.validateParams({
|
||||
segmentationType: 'general',
|
||||
maxChunkLength: 5000,
|
||||
limitMaxChunkLength: 4000, // limit < maxChunkLength
|
||||
overlap: 50,
|
||||
indexType: IndexingType.QUALIFIED,
|
||||
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
rerankModelList: [],
|
||||
retrievalConfig: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
|
||||
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
// Change segmentation settings
|
||||
act(() => {
|
||||
segResult.current.setMaxChunkLength(2048)
|
||||
segResult.current.setOverlap(100)
|
||||
})
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
|
||||
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
|
||||
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
|
||||
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.text,
|
||||
'Chinese',
|
||||
processRule,
|
||||
{
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
|
||||
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
|
||||
expect(params!.doc_language).toBe('Chinese')
|
||||
})
|
||||
|
||||
it('should support parent-child mode through the full pipeline', () => {
|
||||
const files = [createMockFile()]
|
||||
const { result: segResult } = renderHook(() => useSegmentationState())
|
||||
const { result: creationResult } = renderHook(() =>
|
||||
useDocumentCreation({
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
files,
|
||||
notionPages: [],
|
||||
notionCredentialId: '',
|
||||
websitePages: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
|
||||
const params = creationResult.current.buildCreationParams(
|
||||
ChunkingMode.parentChild,
|
||||
'English',
|
||||
processRule,
|
||||
{
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
{ provider: 'openai', model: 'text-embedding-ada-002' },
|
||||
IndexingType.QUALIFIED,
|
||||
)
|
||||
|
||||
expect(params).not.toBeNull()
|
||||
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
|
||||
expect(params!.process_rule.mode).toBe('hierarchical')
|
||||
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
|
||||
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal file
451
web/__tests__/datasets/dataset-settings-flow.test.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Integration Test: Dataset Settings Flow
|
||||
*
|
||||
* Tests cross-module data contracts in the dataset settings form:
|
||||
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
|
||||
*
|
||||
* The unit-level use-form-state.spec.ts validates the hook in isolation.
|
||||
* This integration test verifies that changing one configuration dimension
|
||||
* correctly cascades to dependent parts (index method → retrieval config,
|
||||
* permission → member list visibility, embedding model → embedding available state).
|
||||
*/
|
||||
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const mockMutateDatasets = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
|
||||
isReRankModelSelected: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
// --- Dataset factory ---
|
||||
|
||||
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
|
||||
id: 'ds-settings-1',
|
||||
name: 'Settings Test Dataset',
|
||||
description: 'Integration test dataset',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
indexing_technique: 'high_quality',
|
||||
indexing_status: 'completed',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
doc_form: ChunkingMode.text,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
app_count: 2,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 5000,
|
||||
provider: 'vendor',
|
||||
tags: [],
|
||||
partial_member_list: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
built_in_field_enabled: false,
|
||||
keyword_number: 10,
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: Date.now(),
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
let mockDataset: DataSet = createMockDataset()
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (
|
||||
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
|
||||
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
|
||||
}))
|
||||
|
||||
// Import after mocks are registered
|
||||
const { useFormState } = await import(
|
||||
'@/app/components/datasets/settings/form/hooks/use-form-state',
|
||||
)
|
||||
|
||||
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUpdateDatasetSetting.mockResolvedValue({})
|
||||
mockDataset = createMockDataset()
|
||||
})
|
||||
|
||||
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
|
||||
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.name).toBe('Settings Test Dataset')
|
||||
expect(result.current.description).toBe('Integration test dataset')
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-ada-002',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
|
||||
})
|
||||
|
||||
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
|
||||
mockDataset = createMockDataset({
|
||||
indexing_technique: IndexingType.ECONOMICAL,
|
||||
embedding_model: '',
|
||||
embedding_model_provider: '',
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
} as RetrievalConfig,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Method Change → Retrieval Config Sync', () => {
|
||||
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should allow updating retrieval config after index method switch', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve retrieval config when switching back to QUALIFIED', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setIndexMethod(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
expect(result.current.indexMethod).toBe('high_quality')
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Change → Member List Visibility Logic', () => {
|
||||
it('should start with onlyMe permission and empty member selection', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
|
||||
expect(result.current.selectedMemberIDs).toEqual([])
|
||||
})
|
||||
|
||||
it('should enable member selection when switching to partialMembers', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
|
||||
expect(result.current.memberList).toHaveLength(3)
|
||||
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
|
||||
})
|
||||
|
||||
it('should persist member selection through permission toggle', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
})
|
||||
|
||||
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
|
||||
})
|
||||
|
||||
it('should include partial_member_list in save payload only for partialMembers', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.partialMembers)
|
||||
result.current.setSelectedMemberIDs(['user-2'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
permission: DatasetPermission.partialMembers,
|
||||
partial_member_list: [
|
||||
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should not include partial_member_list for allTeamMembers permission', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setPermission(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
|
||||
expect(savedBody).not.toHaveProperty('partial_member_list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission Validation → All Fields Together', () => {
|
||||
it('should reject empty name on save', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should include all configuration dimensions in a successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setName('Updated Name')
|
||||
result.current.setDescription('Updated Description')
|
||||
result.current.setIndexMethod(IndexingType.ECONOMICAL)
|
||||
result.current.setKeywordNumber(15)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
name: 'Updated Name',
|
||||
description: 'Updated Description',
|
||||
indexing_technique: 'economy',
|
||||
keyword_number: 15,
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateDatasets).toHaveBeenCalled()
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Model Change → Retrieval Config Cascade', () => {
|
||||
it('should update embedding model independently of retrieval config', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
const originalRetrievalConfig = { ...result.current.retrievalConfig }
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
|
||||
})
|
||||
|
||||
expect(result.current.embeddingModel).toEqual({
|
||||
provider: 'cohere',
|
||||
model: 'embed-english-v3.0',
|
||||
})
|
||||
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
|
||||
})
|
||||
|
||||
it('should propagate embedding model into weighted retrieval config on save', async () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.6,
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
keyword_setting: { keyword_weight: 0.4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
|
||||
datasetId: 'ds-settings-1',
|
||||
body: expect.objectContaining({
|
||||
embedding_model: 'embed-v3',
|
||||
embedding_model_provider: 'cohere',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'cohere',
|
||||
embedding_model_name: 'embed-v3',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle switching from semantic to hybrid search with embedding model', () => {
|
||||
const { result } = renderHook(() => useFormState())
|
||||
|
||||
act(() => {
|
||||
result.current.setRetrievalConfig({
|
||||
...result.current.retrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v3.0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
|
||||
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
|
||||
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
|
||||
})
|
||||
})
|
||||
})
|
||||
335
web/__tests__/datasets/document-management.test.tsx
Normal file
335
web/__tests__/datasets/document-management.test.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Integration Test: Document Management Flow
|
||||
*
|
||||
* Tests cross-module interactions: query state (URL-based) → document list sorting →
|
||||
* document selection → status filter utilities.
|
||||
* Validates the data contract between documents page hooks and list component hooks.
|
||||
*/
|
||||
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/datasets/ds-1/documents',
|
||||
}))
|
||||
|
||||
const { sanitizeStatusValue, normalizeStatusForQuery } = await import(
|
||||
'@/app/components/datasets/documents/status-filter',
|
||||
)
|
||||
|
||||
const { useDocumentSort } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-sort',
|
||||
)
|
||||
const { useDocumentSelection } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
|
||||
)
|
||||
const { default: useDocumentListQueryState } = await import(
|
||||
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
|
||||
)
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
word_count: 500,
|
||||
hit_count: 10,
|
||||
created_at: Date.now() / 1000,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
display_status: 'available',
|
||||
indexing_status: 'completed',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
doc_type: null,
|
||||
doc_metadata: null,
|
||||
position: 1,
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
...overrides,
|
||||
} as LocalDoc)
|
||||
|
||||
describe('Document Management Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Status Filter Utilities', () => {
|
||||
it('should sanitize valid status values', () => {
|
||||
expect(sanitizeStatusValue('all')).toBe('all')
|
||||
expect(sanitizeStatusValue('available')).toBe('available')
|
||||
expect(sanitizeStatusValue('error')).toBe('error')
|
||||
})
|
||||
|
||||
it('should fallback to "all" for invalid values', () => {
|
||||
expect(sanitizeStatusValue(null)).toBe('all')
|
||||
expect(sanitizeStatusValue(undefined)).toBe('all')
|
||||
expect(sanitizeStatusValue('')).toBe('all')
|
||||
expect(sanitizeStatusValue('nonexistent')).toBe('all')
|
||||
})
|
||||
|
||||
it('should handle URL aliases', () => {
|
||||
// 'active' is aliased to 'available'
|
||||
expect(sanitizeStatusValue('active')).toBe('available')
|
||||
})
|
||||
|
||||
it('should normalize status for API query', () => {
|
||||
expect(normalizeStatusForQuery('all')).toBe('all')
|
||||
// 'enabled' normalized to 'available' for query
|
||||
expect(normalizeStatusForQuery('enabled')).toBe('available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL-based Query State', () => {
|
||||
it('should parse default query from empty URL params', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query and push to router', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test', page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// The push call should contain the updated query params
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toContain('keyword=test')
|
||||
expect(pushUrl).toContain('page=2')
|
||||
})
|
||||
|
||||
it('should reset query to defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// Default query omits default values from URL
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toBe('/datasets/ds-1/documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Sort Integration', () => {
|
||||
it('should return documents unsorted when no sort field set', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortedDocuments).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
|
||||
})
|
||||
|
||||
it('should toggle sort order on same field click', () => {
|
||||
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
})
|
||||
|
||||
it('should filter by status before sorting', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
|
||||
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
|
||||
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'available',
|
||||
remoteSortValue: '-created_at',
|
||||
}))
|
||||
|
||||
// Only 'available' documents should remain
|
||||
expect(result.current.sortedDocuments).toHaveLength(2)
|
||||
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Selection Integration', () => {
|
||||
it('should manage selection state externally', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
createDoc({ id: 'doc-3' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
expect(result.current.isSomeSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should select all documents', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
]
|
||||
const onSelectedIdChange = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectAll()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['doc-1', 'doc-2']),
|
||||
)
|
||||
})
|
||||
|
||||
it('should detect all-selected state', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1', 'doc-2'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isAllSelected).toBe(true)
|
||||
})
|
||||
|
||||
it('should detect partial selection', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1' }),
|
||||
createDoc({ id: 'doc-2' }),
|
||||
createDoc({ id: 'doc-3' }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isSomeSelected).toBe(true)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify downloadable selected documents (FILE type only)', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
|
||||
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1', 'doc-2'],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
|
||||
})
|
||||
|
||||
it('should clear selection', () => {
|
||||
const onSelectedIdChange = vi.fn()
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
|
||||
const { result } = renderHook(() => useDocumentSelection({
|
||||
documents: docs,
|
||||
selectedIds: ['doc-1'],
|
||||
onSelectedIdChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.clearSelection()
|
||||
})
|
||||
|
||||
expect(onSelectedIdChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
|
||||
it('should maintain consistent default state across all hooks', () => {
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
|
||||
const { result: sortResult } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: queryResult.current.query.status,
|
||||
remoteSortValue: queryResult.current.query.sort,
|
||||
}))
|
||||
const { result: selResult } = renderHook(() => useDocumentSelection({
|
||||
documents: sortResult.current.sortedDocuments,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
|
||||
// Query defaults
|
||||
expect(queryResult.current.query.sort).toBe('-created_at')
|
||||
expect(queryResult.current.query.status).toBe('all')
|
||||
|
||||
// Sort inherits 'all' status → no filtering applied
|
||||
expect(sortResult.current.sortedDocuments).toHaveLength(1)
|
||||
|
||||
// Selection starts empty
|
||||
expect(selResult.current.isAllSelected).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
web/__tests__/datasets/external-knowledge-base.test.tsx
Normal file
215
web/__tests__/datasets/external-knowledge-base.test.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Integration Test: External Knowledge Base Creation Flow
|
||||
*
|
||||
* Tests the data contract, validation logic, and API interaction
|
||||
* for external knowledge base creation.
|
||||
*/
|
||||
|
||||
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// --- Factory ---
|
||||
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
|
||||
name: 'My External KB',
|
||||
description: 'A test external knowledge base',
|
||||
external_knowledge_api_id: 'api-1',
|
||||
external_knowledge_id: 'ext-kb-123',
|
||||
external_retrieval_model: {
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
provider: 'external',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('External Knowledge Base Creation Flow', () => {
|
||||
describe('Data Contract: CreateKnowledgeBaseReq', () => {
|
||||
it('should define a complete form structure', () => {
|
||||
const form = createFormData()
|
||||
|
||||
expect(form).toHaveProperty('name')
|
||||
expect(form).toHaveProperty('external_knowledge_api_id')
|
||||
expect(form).toHaveProperty('external_knowledge_id')
|
||||
expect(form).toHaveProperty('external_retrieval_model')
|
||||
expect(form).toHaveProperty('provider')
|
||||
expect(form.provider).toBe('external')
|
||||
})
|
||||
|
||||
it('should include retrieval model settings', () => {
|
||||
const form = createFormData()
|
||||
|
||||
expect(form.external_retrieval_model).toEqual({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow partial overrides', () => {
|
||||
const form = createFormData({
|
||||
name: 'Custom Name',
|
||||
external_retrieval_model: {
|
||||
top_k: 10,
|
||||
score_threshold: 0.8,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(form.name).toBe('Custom Name')
|
||||
expect(form.external_retrieval_model.top_k).toBe(10)
|
||||
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Validation Logic', () => {
|
||||
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
|
||||
return (
|
||||
form.name.trim() !== ''
|
||||
&& form.external_knowledge_api_id !== ''
|
||||
&& form.external_knowledge_id !== ''
|
||||
&& form.external_retrieval_model.top_k !== undefined
|
||||
&& form.external_retrieval_model.score_threshold !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
it('should validate a complete form', () => {
|
||||
const form = createFormData()
|
||||
expect(isFormValid(form)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty name', () => {
|
||||
const form = createFormData({ name: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject whitespace-only name', () => {
|
||||
const form = createFormData({ name: ' ' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty external_knowledge_api_id', () => {
|
||||
const form = createFormData({ external_knowledge_api_id: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty external_knowledge_id', () => {
|
||||
const form = createFormData({ external_knowledge_id: '' })
|
||||
expect(isFormValid(form)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form State Transitions', () => {
|
||||
it('should start with empty default state', () => {
|
||||
const defaultForm: CreateKnowledgeBaseReq = {
|
||||
name: '',
|
||||
description: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_id: '',
|
||||
external_retrieval_model: {
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
provider: 'external',
|
||||
}
|
||||
|
||||
// Verify default state matches component's initial useState
|
||||
expect(defaultForm.name).toBe('')
|
||||
expect(defaultForm.external_knowledge_api_id).toBe('')
|
||||
expect(defaultForm.external_knowledge_id).toBe('')
|
||||
expect(defaultForm.provider).toBe('external')
|
||||
})
|
||||
|
||||
it('should support immutable form updates', () => {
|
||||
const form = createFormData({ name: '' })
|
||||
const updated = { ...form, name: 'Updated Name' }
|
||||
|
||||
expect(form.name).toBe('')
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
// Other fields should remain unchanged
|
||||
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
|
||||
})
|
||||
|
||||
it('should support retrieval model updates', () => {
|
||||
const form = createFormData()
|
||||
const updated = {
|
||||
...form,
|
||||
external_retrieval_model: {
|
||||
...form.external_retrieval_model,
|
||||
top_k: 10,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
expect(updated.external_retrieval_model.top_k).toBe(10)
|
||||
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
|
||||
// Unchanged field
|
||||
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Call Data Contract', () => {
|
||||
it('should produce a valid API payload from form data', () => {
|
||||
const form = createFormData()
|
||||
|
||||
// The API expects the full CreateKnowledgeBaseReq
|
||||
expect(form.name).toBeTruthy()
|
||||
expect(form.external_knowledge_api_id).toBeTruthy()
|
||||
expect(form.external_knowledge_id).toBeTruthy()
|
||||
expect(form.provider).toBe('external')
|
||||
expect(typeof form.external_retrieval_model.top_k).toBe('number')
|
||||
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
|
||||
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should support optional description', () => {
|
||||
const formWithDesc = createFormData({ description: 'Some description' })
|
||||
const formWithoutDesc = createFormData({ description: '' })
|
||||
|
||||
expect(formWithDesc.description).toBe('Some description')
|
||||
expect(formWithoutDesc.description).toBe('')
|
||||
})
|
||||
|
||||
it('should validate retrieval model bounds', () => {
|
||||
const form = createFormData({
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(form.external_retrieval_model.top_k).toBe(0)
|
||||
expect(form.external_retrieval_model.score_threshold).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('External API List Integration', () => {
|
||||
it('should validate API item structure', () => {
|
||||
const apiItem = {
|
||||
id: 'api-1',
|
||||
name: 'Production API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'key-123',
|
||||
},
|
||||
}
|
||||
|
||||
expect(apiItem).toHaveProperty('id')
|
||||
expect(apiItem).toHaveProperty('name')
|
||||
expect(apiItem).toHaveProperty('settings')
|
||||
expect(apiItem.settings).toHaveProperty('endpoint')
|
||||
expect(apiItem.settings).toHaveProperty('api_key')
|
||||
})
|
||||
|
||||
it('should link API selection to form data', () => {
|
||||
const selectedApi = { id: 'api-2', name: 'Staging API' }
|
||||
const form = createFormData({
|
||||
external_knowledge_api_id: selectedApi.id,
|
||||
})
|
||||
|
||||
expect(form.external_knowledge_api_id).toBe('api-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
404
web/__tests__/datasets/hit-testing-flow.test.tsx
Normal file
404
web/__tests__/datasets/hit-testing-flow.test.tsx
Normal file
@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Integration Test: Hit Testing Flow
|
||||
*
|
||||
* Tests the query submission → API response → callback chain flow
|
||||
* by rendering the actual QueryInput component and triggering user interactions.
|
||||
* Validates that the production onSubmit logic correctly constructs payloads
|
||||
* and invokes callbacks on success/failure.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HitTestingResponse,
|
||||
Query,
|
||||
} from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
default: {},
|
||||
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
|
||||
useDatasetDetailContextWithSelector: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(() => ({})),
|
||||
useContextSelector: vi.fn(() => false),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
|
||||
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
|
||||
<div data-testid="image-uploader-mock">
|
||||
{textArea}
|
||||
{actionButton}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// --- Factories ---
|
||||
|
||||
const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: undefined,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
weights: undefined,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
...overrides,
|
||||
} as RetrievalConfig)
|
||||
|
||||
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
|
||||
query: {
|
||||
content: 'What is Dify?',
|
||||
tsne_position: { x: 0, y: 0 },
|
||||
},
|
||||
records: Array.from({ length: numResults }, (_, i) => ({
|
||||
segment: {
|
||||
id: `seg-${i}`,
|
||||
document: {
|
||||
id: `doc-${i}`,
|
||||
data_source_type: 'upload_file',
|
||||
name: `document-${i}.txt`,
|
||||
doc_type: null as unknown as import('@/models/datasets').DocType,
|
||||
},
|
||||
content: `Result content ${i}`,
|
||||
sign_content: `Result content ${i}`,
|
||||
position: i + 1,
|
||||
word_count: 100 + i * 50,
|
||||
tokens: 50 + i * 25,
|
||||
keywords: ['test', 'dify'],
|
||||
hit_count: i * 5,
|
||||
index_node_hash: `hash-${i}`,
|
||||
answer: '',
|
||||
},
|
||||
content: {
|
||||
id: `seg-${i}`,
|
||||
document: {
|
||||
id: `doc-${i}`,
|
||||
data_source_type: 'upload_file',
|
||||
name: `document-${i}.txt`,
|
||||
doc_type: null as unknown as import('@/models/datasets').DocType,
|
||||
},
|
||||
content: `Result content ${i}`,
|
||||
sign_content: `Result content ${i}`,
|
||||
position: i + 1,
|
||||
word_count: 100 + i * 50,
|
||||
tokens: 50 + i * 25,
|
||||
keywords: ['test', 'dify'],
|
||||
hit_count: i * 5,
|
||||
index_node_hash: `hash-${i}`,
|
||||
answer: '',
|
||||
},
|
||||
score: 0.95 - i * 0.1,
|
||||
tsne_position: { x: 0, y: 0 },
|
||||
child_chunks: null,
|
||||
files: [],
|
||||
})),
|
||||
})
|
||||
|
||||
const createTextQuery = (content: string): Query[] => [
|
||||
{ content, content_type: 'text_query', file_info: null },
|
||||
]
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const findSubmitButton = () => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
|
||||
expect(submitButton).toBeTruthy()
|
||||
return submitButton!
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('Hit Testing Flow', () => {
|
||||
const mockHitTestingMutation = vi.fn()
|
||||
const mockExternalMutation = vi.fn()
|
||||
const mockSetHitResult = vi.fn()
|
||||
const mockSetExternalHitResult = vi.fn()
|
||||
const mockOnUpdateList = vi.fn()
|
||||
const mockSetQueries = vi.fn()
|
||||
const mockOnClickRetrievalMethod = vi.fn()
|
||||
const mockOnSubmit = vi.fn()
|
||||
|
||||
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
onUpdateList: mockOnUpdateList,
|
||||
setHitResult: mockSetHitResult,
|
||||
setExternalHitResult: mockSetExternalHitResult,
|
||||
loading: false,
|
||||
queries: [] as Query[],
|
||||
setQueries: mockSetQueries,
|
||||
isExternal: false,
|
||||
onClickRetrievalMethod: mockOnClickRetrievalMethod,
|
||||
retrievalConfig: createRetrievalConfig(),
|
||||
isEconomy: false,
|
||||
onSubmit: mockOnSubmit,
|
||||
hitTestingMutation: mockHitTestingMutation,
|
||||
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Query Submission → API Call', () => {
|
||||
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
|
||||
const retrievalConfig = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
})
|
||||
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('How does RAG work?'),
|
||||
retrievalConfig,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'How does RAG work?',
|
||||
attachment_ids: [],
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should override search_method to keywordSearch when isEconomy is true', async () => {
|
||||
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
|
||||
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test query'),
|
||||
retrievalConfig,
|
||||
isEconomy: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty results by calling setHitResult with empty records', async () => {
|
||||
const emptyResponse = createHitTestingResponse(0)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(emptyResponse)
|
||||
return emptyResponse
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('nonexistent topic'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetHitResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ records: [] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
|
||||
// Simulate a mutation that resolves but does not invoke the onSuccess callback
|
||||
mockHitTestingMutation.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHitTestingMutation).toHaveBeenCalled()
|
||||
})
|
||||
// Success callbacks should not fire when onSuccess is not invoked
|
||||
expect(mockSetHitResult).not.toHaveBeenCalled()
|
||||
expect(mockOnUpdateList).not.toHaveBeenCalled()
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Response → Results Data Contract', () => {
|
||||
it('should produce results with required segment fields for rendering', () => {
|
||||
const response = createHitTestingResponse(3)
|
||||
|
||||
// Validate each result has the fields needed by ResultItem component
|
||||
response.records.forEach((record) => {
|
||||
expect(record.segment).toHaveProperty('id')
|
||||
expect(record.segment).toHaveProperty('content')
|
||||
expect(record.segment).toHaveProperty('position')
|
||||
expect(record.segment).toHaveProperty('word_count')
|
||||
expect(record.segment).toHaveProperty('document')
|
||||
expect(record.segment.document).toHaveProperty('name')
|
||||
expect(record.score).toBeGreaterThanOrEqual(0)
|
||||
expect(record.score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain correct score ordering', () => {
|
||||
const response = createHitTestingResponse(5)
|
||||
|
||||
for (let i = 1; i < response.records.length; i++) {
|
||||
expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include document metadata for result item display', () => {
|
||||
const response = createHitTestingResponse(1)
|
||||
const record = response.records[0]
|
||||
|
||||
expect(record.segment.document.name).toBeTruthy()
|
||||
expect(record.segment.document.data_source_type).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Successful Submission → Callback Chain', () => {
|
||||
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
|
||||
const response = createHitTestingResponse(3)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('Test query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetHitResult).toHaveBeenCalledWith(response)
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger records list refresh via onUpdateList after query', async () => {
|
||||
const response = createHitTestingResponse(1)
|
||||
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('new query'),
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('External KB Hit Testing', () => {
|
||||
it('should use external mutation with correct payload for external datasets', async () => {
|
||||
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
|
||||
const response = { records: [] }
|
||||
options?.onSuccess?.(response)
|
||||
return response
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('test'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExternalMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: 'test',
|
||||
external_retrieval_model: expect.objectContaining({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
// Internal mutation should NOT be called
|
||||
expect(mockHitTestingMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
|
||||
const externalResponse = { records: [] }
|
||||
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
|
||||
options?.onSuccess?.(externalResponse)
|
||||
return externalResponse
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryInput {...createDefaultProps({
|
||||
queries: createTextQuery('external query'),
|
||||
isExternal: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(findSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
|
||||
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal file
337
web/__tests__/datasets/metadata-management-flow.test.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Integration Test: Metadata Management Flow
|
||||
*
|
||||
* Tests the cross-module composition of metadata name validation, type constraints,
|
||||
* and duplicate detection across the metadata management hooks.
|
||||
*
|
||||
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
|
||||
* This integration test verifies:
|
||||
* - Name validation combined with existing metadata list (duplicate detection)
|
||||
* - Metadata type enum constraints matching expected data model
|
||||
* - Full add/rename workflow: validate name → check duplicates → allow or reject
|
||||
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
|
||||
*/
|
||||
|
||||
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { DataType } from '@/app/components/datasets/metadata/types'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { default: useCheckMetadataName } = await import(
|
||||
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
|
||||
)
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createMetadataItem = (
|
||||
id: string,
|
||||
name: string,
|
||||
type = DataType.string,
|
||||
count = 0,
|
||||
): MetadataItemWithValueLength => ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
count,
|
||||
})
|
||||
|
||||
const createMetadataList = (): MetadataItemWithValueLength[] => [
|
||||
createMetadataItem('meta-1', 'author', DataType.string, 5),
|
||||
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
|
||||
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
|
||||
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
|
||||
createMetadataItem('meta-5', 'version', DataType.number, 2),
|
||||
]
|
||||
|
||||
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
|
||||
describe('Name Validation Flow: Format Rules', () => {
|
||||
it('should accept valid lowercase names with underscores', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('valid_name').errorMsg).toBe('')
|
||||
expect(result.current.checkName('author').errorMsg).toBe('')
|
||||
expect(result.current.checkName('page_count').errorMsg).toBe('')
|
||||
expect(result.current.checkName('v2_field').errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should reject empty names', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
|
||||
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject names exceeding 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const longName = 'a'.repeat(256)
|
||||
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
|
||||
|
||||
const maxName = 'a'.repeat(255)
|
||||
expect(result.current.checkName(maxName).errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
|
||||
it('should define exactly three data types', () => {
|
||||
const typeValues = Object.values(DataType)
|
||||
expect(typeValues).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should include string, number, and time types', () => {
|
||||
expect(DataType.string).toBe('string')
|
||||
expect(DataType.number).toBe('number')
|
||||
expect(DataType.time).toBe('time')
|
||||
})
|
||||
|
||||
it('should use consistent types in metadata items', () => {
|
||||
const metadataList = createMetadataList()
|
||||
|
||||
const stringItems = metadataList.filter(m => m.type === DataType.string)
|
||||
const numberItems = metadataList.filter(m => m.type === DataType.number)
|
||||
const timeItems = metadataList.filter(m => m.type === DataType.time)
|
||||
|
||||
expect(stringItems).toHaveLength(2)
|
||||
expect(numberItems).toHaveLength(2)
|
||||
expect(timeItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should enforce type-safe metadata item construction', () => {
|
||||
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
|
||||
|
||||
expect(item.id).toBe('test-1')
|
||||
expect(item.name).toBe('test_field')
|
||||
expect(item.type).toBe(DataType.number)
|
||||
expect(item.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
|
||||
it('should detect duplicate names against an existing metadata list', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const checkDuplicate = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(checkDuplicate('author')).toBe(true)
|
||||
expect(checkDuplicate('created_date')).toBe(true)
|
||||
expect(checkDuplicate('page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow names that do not conflict with existing metadata', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isNameAvailable = (newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName)
|
||||
}
|
||||
|
||||
expect(isNameAvailable('category')).toBe(true)
|
||||
expect(isNameAvailable('file_size')).toBe(true)
|
||||
expect(isNameAvailable('language')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject names that fail format validation before duplicate check', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { valid: false, reason: 'format' }
|
||||
return { valid: true, reason: '' }
|
||||
}
|
||||
|
||||
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('').reason).toBe('format')
|
||||
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
|
||||
it('should allow an existing metadata item to keep its own name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
// Allow keeping the same name (skip self in duplicate check)
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author keeping its own name should be valid
|
||||
expect(isRenameValid('meta-1', 'author')).toBe(true)
|
||||
// page_count keeping its own name should be valid
|
||||
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming to another existing metadata name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
// Author trying to rename to "page_count" (taken by meta-3)
|
||||
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
|
||||
// version trying to rename to "source_url" (taken by meta-4)
|
||||
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow renaming to a completely new valid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
|
||||
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
|
||||
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject renaming with an invalid format even if name is unique', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const isRenameValid = (itemId: string, newName: string): boolean => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return false
|
||||
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
|
||||
}
|
||||
|
||||
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
|
||||
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
|
||||
expect(isRenameValid('meta-3', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Metadata Management Workflow', () => {
|
||||
it('should support a complete add-validate-check-duplicate cycle', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const addMetadataField = (
|
||||
name: string,
|
||||
type: DataType,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(name)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === name))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Add a valid new field
|
||||
const result1 = addMetadataField('department', DataType.string)
|
||||
expect(result1.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add a duplicate
|
||||
const result2 = addMetadataField('author', DataType.string)
|
||||
expect(result2.success).toBe(false)
|
||||
expect(result2.error).toBe('duplicate_name')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Try to add an invalid name
|
||||
const result3 = addMetadataField('Invalid Name', DataType.string)
|
||||
expect(result3.success).toBe(false)
|
||||
expect(result3.error).toBe('invalid_format')
|
||||
expect(existingMetadata).toHaveLength(6)
|
||||
|
||||
// Add another valid field
|
||||
const result4 = addMetadataField('priority_level', DataType.number)
|
||||
expect(result4.success).toBe(true)
|
||||
expect(existingMetadata).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should support a complete rename workflow with validation chain', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const existingMetadata = createMetadataList()
|
||||
|
||||
const renameMetadataField = (
|
||||
itemId: string,
|
||||
newName: string,
|
||||
): { success: boolean, error?: string } => {
|
||||
const formatCheck = result.current.checkName(newName)
|
||||
if (formatCheck.errorMsg)
|
||||
return { success: false, error: 'invalid_format' }
|
||||
|
||||
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
|
||||
return { success: false, error: 'duplicate_name' }
|
||||
|
||||
const item = existingMetadata.find(m => m.id === itemId)
|
||||
if (!item)
|
||||
return { success: false, error: 'not_found' }
|
||||
|
||||
// Simulate the rename in-place
|
||||
const index = existingMetadata.indexOf(item)
|
||||
existingMetadata[index] = { ...item, name: newName }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Rename author to document_author
|
||||
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
|
||||
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
|
||||
|
||||
// Try renaming created_date to page_count (already taken)
|
||||
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
|
||||
|
||||
// Rename to invalid format
|
||||
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
|
||||
|
||||
// Rename non-existent item
|
||||
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
|
||||
})
|
||||
|
||||
it('should maintain validation consistency across multiple operations', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
|
||||
// Validate the same name multiple times for consistency
|
||||
const name = 'consistent_field'
|
||||
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
|
||||
|
||||
expect(results.every(r => r.errorMsg === '')).toBe(true)
|
||||
|
||||
// Validate an invalid name multiple times
|
||||
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
|
||||
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal file
477
web/__tests__/datasets/pipeline-datasource-flow.test.tsx
Normal file
@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Integration Test: Pipeline Data Source Store Composition
|
||||
*
|
||||
* Tests cross-slice interactions in the pipeline data source Zustand store.
|
||||
* The unit-level slice specs test each slice in isolation.
|
||||
* This integration test verifies:
|
||||
* - Store initialization produces correct defaults across all slices
|
||||
* - Cross-slice coordination (e.g. credential shared across slices)
|
||||
* - State isolation: changes in one slice do not affect others
|
||||
* - Full workflow simulation through credential → source → data path
|
||||
*/
|
||||
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, FileItem } from '@/models/datasets'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
|
||||
import { CrawlStep } from '@/models/datasets'
|
||||
import { OnlineDriveFileType } from '@/models/pipeline'
|
||||
|
||||
// --- Factory functions ---
|
||||
|
||||
const createFileItem = (id: string): FileItem => ({
|
||||
fileID: id,
|
||||
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
|
||||
progress: 100,
|
||||
})
|
||||
|
||||
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
|
||||
title: title ?? `Page: ${url}`,
|
||||
markdown: `# ${title ?? url}\n\nContent for ${url}`,
|
||||
description: `Description for ${url}`,
|
||||
source_url: url,
|
||||
})
|
||||
|
||||
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
|
||||
id,
|
||||
name,
|
||||
size: 2048,
|
||||
type,
|
||||
})
|
||||
|
||||
const createNotionPage = (pageId: string): NotionPage => ({
|
||||
page_id: pageId,
|
||||
page_name: `Page ${pageId}`,
|
||||
page_icon: null,
|
||||
is_bound: true,
|
||||
parent_id: 'parent-1',
|
||||
type: 'page',
|
||||
workspace_id: 'ws-1',
|
||||
})
|
||||
|
||||
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
|
||||
describe('Store Initialization → All Slices Have Correct Defaults', () => {
|
||||
it('should create a store with all five slices combined', () => {
|
||||
const store = createDataSourceStore()
|
||||
const state = store.getState()
|
||||
|
||||
// Common slice defaults
|
||||
expect(state.currentCredentialId).toBe('')
|
||||
expect(state.currentNodeIdRef.current).toBe('')
|
||||
|
||||
// Local file slice defaults
|
||||
expect(state.localFileList).toEqual([])
|
||||
expect(state.currentLocalFile).toBeUndefined()
|
||||
|
||||
// Online document slice defaults
|
||||
expect(state.documentsData).toEqual([])
|
||||
expect(state.onlineDocuments).toEqual([])
|
||||
expect(state.searchValue).toBe('')
|
||||
expect(state.selectedPagesId).toEqual(new Set())
|
||||
|
||||
// Website crawl slice defaults
|
||||
expect(state.websitePages).toEqual([])
|
||||
expect(state.step).toBe(CrawlStep.init)
|
||||
expect(state.previewIndex).toBe(-1)
|
||||
|
||||
// Online drive slice defaults
|
||||
expect(state.breadcrumbs).toEqual([])
|
||||
expect(state.prefix).toEqual([])
|
||||
expect(state.keywords).toBe('')
|
||||
expect(state.selectedFileIds).toEqual([])
|
||||
expect(state.onlineDriveFileList).toEqual([])
|
||||
expect(state.bucket).toBe('')
|
||||
expect(state.hasBucket).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Slice Coordination: Shared Credential', () => {
|
||||
it('should set credential that is accessible from the common slice', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setCurrentCredentialId('cred-abc-123')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
|
||||
})
|
||||
|
||||
it('should allow credential update independently of all other slices', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
store.getState().setCurrentCredentialId('cred-xyz')
|
||||
|
||||
expect(store.getState().currentCredentialId).toBe('cred-xyz')
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
|
||||
it('should set and retrieve local file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(3)
|
||||
expect(store.getState().localFileList[0].fileID).toBe('f1')
|
||||
expect(store.getState().localFileList[2].fileID).toBe('f3')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting file list', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [createFileItem('f-preview')]
|
||||
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should clear files by setting empty list', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('f1')])
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should set and clear current local file selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
|
||||
|
||||
store.getState().setCurrentLocalFile(file)
|
||||
expect(store.getState().currentLocalFile).toBeDefined()
|
||||
expect(store.getState().currentLocalFile?.id).toBe('current-file')
|
||||
|
||||
store.getState().setCurrentLocalFile(undefined)
|
||||
expect(store.getState().currentLocalFile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
|
||||
it('should set documents data and online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().onlineDocuments).toHaveLength(2)
|
||||
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
|
||||
})
|
||||
|
||||
it('should update preview ref when setting online documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('page-preview')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
|
||||
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
|
||||
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
|
||||
})
|
||||
|
||||
it('should track selected page IDs', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
|
||||
|
||||
store.getState().setOnlineDocuments(pages)
|
||||
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
|
||||
|
||||
expect(store.getState().selectedPagesId.size).toBe(2)
|
||||
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
|
||||
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
|
||||
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage search value for filtering documents', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setSearchValue('meeting notes')
|
||||
|
||||
expect(store.getState().searchValue).toBe('meeting notes')
|
||||
})
|
||||
|
||||
it('should set and clear current document selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createNotionPage('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(page)
|
||||
expect(store.getState().currentDocument?.page_id).toBe('current-page')
|
||||
|
||||
store.getState().setCurrentDocument(undefined)
|
||||
expect(store.getState().currentDocument).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
|
||||
it('should set website pages and update preview ref', () => {
|
||||
const store = createDataSourceStore()
|
||||
const pages = [
|
||||
createCrawlResultItem('https://example.com'),
|
||||
createCrawlResultItem('https://example.com/about'),
|
||||
]
|
||||
|
||||
store.getState().setWebsitePages(pages)
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(2)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('should manage crawl step transitions', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
expect(store.getState().step).toBe(CrawlStep.init)
|
||||
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
expect(store.getState().step).toBe(CrawlStep.running)
|
||||
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
})
|
||||
|
||||
it('should set crawl result with data and timing', () => {
|
||||
const store = createDataSourceStore()
|
||||
const result = {
|
||||
data: [createCrawlResultItem('https://test.com')],
|
||||
time_consuming: 3.5,
|
||||
}
|
||||
|
||||
store.getState().setCrawlResult(result)
|
||||
|
||||
expect(store.getState().crawlResult?.data).toHaveLength(1)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
|
||||
})
|
||||
|
||||
it('should manage preview index for page navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setPreviewIndex(2)
|
||||
expect(store.getState().previewIndex).toBe(2)
|
||||
|
||||
store.getState().setPreviewIndex(-1)
|
||||
expect(store.getState().previewIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('should set and clear current website selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const page = createCrawlResultItem('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(page)
|
||||
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
|
||||
|
||||
store.getState().setCurrentWebsite(undefined)
|
||||
expect(store.getState().currentWebsite).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
|
||||
it('should manage breadcrumb navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
|
||||
})
|
||||
|
||||
it('should support breadcrumb push/pop pattern', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBreadcrumbs(['root'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
|
||||
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
|
||||
|
||||
// Pop back one level
|
||||
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
|
||||
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
|
||||
})
|
||||
|
||||
it('should manage file list and selection', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-1', 'report.pdf'),
|
||||
createOnlineDriveFile('drive-2', 'data.csv'),
|
||||
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(3)
|
||||
|
||||
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
|
||||
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
|
||||
})
|
||||
|
||||
it('should update preview ref when selecting files', () => {
|
||||
const store = createDataSourceStore()
|
||||
const files = [
|
||||
createOnlineDriveFile('drive-a', 'file-a.txt'),
|
||||
createOnlineDriveFile('drive-b', 'file-b.txt'),
|
||||
]
|
||||
|
||||
store.getState().setOnlineDriveFileList(files)
|
||||
store.getState().setSelectedFileIds(['drive-b'])
|
||||
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
|
||||
})
|
||||
|
||||
it('should manage bucket and prefix for S3-like navigation', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setBucket('my-data-bucket')
|
||||
store.getState().setPrefix(['data', '2024'])
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
expect(store.getState().bucket).toBe('my-data-bucket')
|
||||
expect(store.getState().prefix).toEqual(['data', '2024'])
|
||||
expect(store.getState().hasBucket).toBe(true)
|
||||
})
|
||||
|
||||
it('should manage keywords for search filtering', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setKeywords('quarterly report')
|
||||
expect(store.getState().keywords).toBe('quarterly report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
|
||||
it('should keep local file state independent from online document state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setLocalFileList([createFileItem('local-1')])
|
||||
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
|
||||
|
||||
expect(store.getState().localFileList).toHaveLength(1)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
|
||||
// Clearing local files should not affect online documents
|
||||
store.getState().setLocalFileList([])
|
||||
expect(store.getState().localFileList).toHaveLength(0)
|
||||
expect(store.getState().onlineDocuments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should keep website crawl state independent from online drive state', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
|
||||
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
|
||||
|
||||
expect(store.getState().websitePages).toHaveLength(1)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
|
||||
// Clearing website pages should not affect drive files
|
||||
store.getState().setWebsitePages([])
|
||||
expect(store.getState().websitePages).toHaveLength(0)
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should create fully independent store instances', () => {
|
||||
const storeA = createDataSourceStore()
|
||||
const storeB = createDataSourceStore()
|
||||
|
||||
storeA.getState().setCurrentCredentialId('cred-A')
|
||||
storeA.getState().setLocalFileList([createFileItem('fa-1')])
|
||||
|
||||
expect(storeA.getState().currentCredentialId).toBe('cred-A')
|
||||
expect(storeB.getState().currentCredentialId).toBe('')
|
||||
expect(storeB.getState().localFileList).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
|
||||
it('should support a complete local file upload workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('upload-cred-1')
|
||||
|
||||
// Step 2: Set file list
|
||||
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
|
||||
store.getState().setLocalFileList(files)
|
||||
|
||||
// Step 3: Select current file for preview
|
||||
store.getState().setCurrentLocalFile(files[0].file)
|
||||
|
||||
// Verify all state is consistent
|
||||
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
|
||||
expect(store.getState().localFileList).toHaveLength(2)
|
||||
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
|
||||
expect(store.getState().previewLocalFileRef.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should support a complete website crawl workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('crawl-cred-1')
|
||||
|
||||
// Step 2: Init crawl
|
||||
store.getState().setStep(CrawlStep.running)
|
||||
|
||||
// Step 3: Crawl completes with results
|
||||
const crawledPages = [
|
||||
createCrawlResultItem('https://docs.example.com/guide'),
|
||||
createCrawlResultItem('https://docs.example.com/api'),
|
||||
createCrawlResultItem('https://docs.example.com/faq'),
|
||||
]
|
||||
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
|
||||
store.getState().setStep(CrawlStep.finished)
|
||||
|
||||
// Step 4: Set website pages from results
|
||||
store.getState().setWebsitePages(crawledPages)
|
||||
|
||||
// Step 5: Set preview
|
||||
store.getState().setPreviewIndex(1)
|
||||
|
||||
// Verify all state
|
||||
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
|
||||
expect(store.getState().step).toBe(CrawlStep.finished)
|
||||
expect(store.getState().websitePages).toHaveLength(3)
|
||||
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
|
||||
expect(store.getState().previewIndex).toBe(1)
|
||||
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
|
||||
})
|
||||
|
||||
it('should support a complete online drive navigation workflow', () => {
|
||||
const store = createDataSourceStore()
|
||||
|
||||
// Step 1: Set credential
|
||||
store.getState().setCurrentCredentialId('drive-cred-1')
|
||||
|
||||
// Step 2: Set bucket
|
||||
store.getState().setBucket('company-docs')
|
||||
store.getState().setHasBucket(true)
|
||||
|
||||
// Step 3: Navigate into folders
|
||||
store.getState().setBreadcrumbs(['company-docs'])
|
||||
store.getState().setPrefix(['projects'])
|
||||
const folderFiles = [
|
||||
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
|
||||
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
|
||||
]
|
||||
store.getState().setOnlineDriveFileList(folderFiles)
|
||||
|
||||
// Step 4: Navigate deeper
|
||||
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
|
||||
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
|
||||
|
||||
// Step 5: Select files
|
||||
store.getState().setOnlineDriveFileList([
|
||||
createOnlineDriveFile('doc-1', 'spec.pdf'),
|
||||
createOnlineDriveFile('doc-2', 'design.fig'),
|
||||
])
|
||||
store.getState().setSelectedFileIds(['doc-1'])
|
||||
|
||||
// Verify full state
|
||||
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
|
||||
expect(store.getState().bucket).toBe('company-docs')
|
||||
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
|
||||
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
|
||||
expect(store.getState().onlineDriveFileList).toHaveLength(2)
|
||||
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
|
||||
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
|
||||
})
|
||||
})
|
||||
})
|
||||
301
web/__tests__/datasets/segment-crud.test.tsx
Normal file
301
web/__tests__/datasets/segment-crud.test.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Integration Test: Segment CRUD Flow
|
||||
*
|
||||
* Tests segment selection, search/filter, and modal state management across hooks.
|
||||
* Validates cross-hook data contracts in the completed segment module.
|
||||
*/
|
||||
|
||||
import type { SegmentDetailModel } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
|
||||
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
|
||||
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
|
||||
|
||||
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({
|
||||
id,
|
||||
position: 1,
|
||||
document_id: 'doc-1',
|
||||
content,
|
||||
sign_content: content,
|
||||
answer: '',
|
||||
word_count: 50,
|
||||
tokens: 25,
|
||||
keywords: ['test'],
|
||||
index_node_id: 'idx-1',
|
||||
index_node_hash: 'hash-1',
|
||||
hit_count: 0,
|
||||
enabled: true,
|
||||
disabled_at: 0,
|
||||
disabled_by: '',
|
||||
status: 'completed',
|
||||
created_by: 'user-1',
|
||||
created_at: Date.now(),
|
||||
indexing_at: Date.now(),
|
||||
completed_at: Date.now(),
|
||||
error: null,
|
||||
stopped_at: 0,
|
||||
updated_at: Date.now(),
|
||||
attachments: [],
|
||||
} as SegmentDetailModel)
|
||||
|
||||
describe('Segment CRUD Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Search and Filter → Segment List Query', () => {
|
||||
it('should manage search input with debounce', () => {
|
||||
vi.useFakeTimers()
|
||||
const onPageChange = vi.fn()
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputChange('keyword')
|
||||
})
|
||||
|
||||
expect(result.current.inputValue).toBe('keyword')
|
||||
expect(result.current.searchValue).toBe('')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.searchValue).toBe('keyword')
|
||||
expect(onPageChange).toHaveBeenCalledWith(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should manage status filter state', () => {
|
||||
const onPageChange = vi.fn()
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
|
||||
|
||||
// status value 1 maps to !!1 = true (enabled)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
// onChangeStatus converts: value === 'all' ? 'all' : !!value
|
||||
expect(result.current.selectedStatus).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onClearFilter()
|
||||
})
|
||||
expect(result.current.selectedStatus).toBe('all')
|
||||
expect(result.current.inputValue).toBe('')
|
||||
})
|
||||
|
||||
it('should provide status list for filter dropdown', () => {
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
|
||||
expect(result.current.statusList).toBeInstanceOf(Array)
|
||||
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
|
||||
})
|
||||
|
||||
it('should compute selectDefaultValue based on selectedStatus', () => {
|
||||
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
|
||||
|
||||
// Initial state: 'all'
|
||||
expect(result.current.selectDefaultValue).toBe('all')
|
||||
|
||||
// Set to enabled (true)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
expect(result.current.selectDefaultValue).toBe(1)
|
||||
|
||||
// Set to disabled (false)
|
||||
act(() => {
|
||||
result.current.onChangeStatus({ value: 0, name: 'disabled' })
|
||||
})
|
||||
expect(result.current.selectDefaultValue).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Segment Selection → Batch Operations', () => {
|
||||
const segments = [
|
||||
createSegment('seg-1'),
|
||||
createSegment('seg-2'),
|
||||
createSegment('seg-3'),
|
||||
]
|
||||
|
||||
it('should manage individual segment selection', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-2')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-2')
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should toggle selection on repeated click', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toContain('seg-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
|
||||
})
|
||||
|
||||
it('should support select all toggle', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectedAll()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(3)
|
||||
expect(result.current.isAllSelected).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onSelectedAll()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(0)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect partial selection via isSomeSelected', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
})
|
||||
|
||||
// After selecting one of three, isSomeSelected should be true
|
||||
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
|
||||
expect(result.current.isSomeSelected).toBe(true)
|
||||
expect(result.current.isAllSelected).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear selection via onCancelBatchOperation', () => {
|
||||
const { result } = renderHook(() => useSegmentSelection(segments))
|
||||
|
||||
act(() => {
|
||||
result.current.onSelected('seg-1')
|
||||
result.current.onSelected('seg-2')
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(2)
|
||||
|
||||
act(() => {
|
||||
result.current.onCancelBatchOperation()
|
||||
})
|
||||
expect(result.current.selectedSegmentIds).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal State Management', () => {
|
||||
const onNewSegmentModalChange = vi.fn()
|
||||
|
||||
it('should open segment detail modal on card click', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
const segment = createSegment('seg-detail-1', 'Detail content')
|
||||
act(() => {
|
||||
result.current.onClickCard(segment)
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
expect(result.current.currSegment.segInfo).toBeDefined()
|
||||
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
|
||||
})
|
||||
|
||||
it('should close segment detail modal', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
const segment = createSegment('seg-1')
|
||||
act(() => {
|
||||
result.current.onClickCard(segment)
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseSegmentDetail()
|
||||
})
|
||||
expect(result.current.currSegment.showModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage full screen toggle', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
})
|
||||
expect(result.current.fullScreen).toBe(true)
|
||||
act(() => {
|
||||
result.current.toggleFullScreen()
|
||||
})
|
||||
expect(result.current.fullScreen).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage collapsed state', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.isCollapsed).toBe(true)
|
||||
act(() => {
|
||||
result.current.toggleCollapsed()
|
||||
})
|
||||
expect(result.current.isCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('should manage new child segment modal', () => {
|
||||
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
|
||||
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
act(() => {
|
||||
result.current.handleAddNewChildChunk('chunk-parent-1')
|
||||
})
|
||||
expect(result.current.showNewChildSegmentModal).toBe(true)
|
||||
expect(result.current.currChunkId).toBe('chunk-parent-1')
|
||||
|
||||
act(() => {
|
||||
result.current.onCloseNewChildChunkModal()
|
||||
})
|
||||
expect(result.current.showNewChildSegmentModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
|
||||
it('should maintain independent state across all three hooks', () => {
|
||||
const segments = [createSegment('seg-1'), createSegment('seg-2')]
|
||||
|
||||
const { result: filterResult } = renderHook(() =>
|
||||
useSearchFilter({ onPageChange: vi.fn() }),
|
||||
)
|
||||
const { result: selectionResult } = renderHook(() =>
|
||||
useSegmentSelection(segments),
|
||||
)
|
||||
const { result: modalResult } = renderHook(() =>
|
||||
useModalState({ onNewSegmentModalChange: vi.fn() }),
|
||||
)
|
||||
|
||||
// Set search filter to enabled
|
||||
act(() => {
|
||||
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
|
||||
})
|
||||
|
||||
// Select a segment
|
||||
act(() => {
|
||||
selectionResult.current.onSelected('seg-1')
|
||||
})
|
||||
|
||||
// Open detail modal
|
||||
act(() => {
|
||||
modalResult.current.onClickCard(segments[0])
|
||||
})
|
||||
|
||||
// All states should be independent
|
||||
expect(filterResult.current.selectedStatus).toBe(true) // !!1
|
||||
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
|
||||
expect(modalResult.current.currSegment.showModal).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
await waitFor(() => {
|
||||
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
|
||||
expect(result.current.conversationList).toEqual(listData.data)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,21 +29,27 @@ 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()
|
||||
})
|
||||
@ -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,8 +61,10 @@ 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')
|
||||
@ -79,8 +81,10 @@ 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 }))
|
||||
})
|
||||
@ -89,11 +93,14 @@ 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)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Usage from '../usage'
|
||||
import Usage from './usage'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockPlan = {
|
||||
usage: {
|
||||
@ -17,25 +23,33 @@ 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 />)
|
||||
|
||||
expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText('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()
|
||||
})
|
||||
@ -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,8 +120,10 @@ 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()
|
||||
@ -129,8 +131,10 @@ 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,
|
||||
@ -145,6 +149,7 @@ 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()
|
||||
@ -153,6 +158,7 @@ describe('AppsFull', () => {
|
||||
})
|
||||
|
||||
it('should render upgrade button for professional plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@ -166,14 +172,17 @@ 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,
|
||||
@ -187,8 +196,10 @@ 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')
|
||||
@ -196,8 +207,10 @@ 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,
|
||||
@ -211,12 +224,15 @@ 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,
|
||||
@ -230,12 +246,15 @@ 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,
|
||||
@ -249,8 +268,10 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
@ -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} />,
|
||||
}))
|
||||
|
||||
@ -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,42 +70,6 @@ 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()
|
||||
|
||||
@ -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,23 +40,4 @@ 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)
|
||||
})
|
||||
})
|
||||
@ -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>) => {
|
||||
@ -193,107 +193,4 @@ describe('usePSInfo', () => {
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie parse failure: covers catch block (L14-16)
|
||||
it('should fall back to empty object when cookie contains invalid JSON', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('not-valid-json{{{')
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams({
|
||||
ps_partner_key: 'from-url',
|
||||
ps_xid: 'click-url',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse partner stack info from cookie:',
|
||||
expect.any(SyntaxError),
|
||||
)
|
||||
// Should still pick up values from search params
|
||||
expect(result.current.psPartnerKey).toBe('from-url')
|
||||
expect(result.current.psClickId).toBe('click-url')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
|
||||
it('should not save or bind when neither search params nor cookie have keys', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateAsync when keys are missing during bind', async () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
|
||||
it('should not remove cookie when bind fails with non-400 error', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 500 })
|
||||
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).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fallback to cookie values: covers L19-20 right side of || operator
|
||||
it('should use cookie values when search params are absent', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'cookie-partner',
|
||||
clickId: 'cookie-click',
|
||||
}))
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
// Partial key missing: only partnerKey present, no clickId
|
||||
it('should not save when only one key is available', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({ ps_partner_key: 'partial-key' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -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,11 +39,13 @@ 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()
|
||||
@ -53,32 +55,40 @@ 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()
|
||||
@ -86,12 +96,15 @@ 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)
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Enterprise from '../enterprise'
|
||||
import Enterprise from './enterprise'
|
||||
|
||||
describe('Enterprise Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -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', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Professional from '../professional'
|
||||
import Professional from './professional'
|
||||
|
||||
describe('Professional Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -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', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Team from '../team'
|
||||
import Team from './team'
|
||||
|
||||
describe('Team Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -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, SelfHostedPlan } from '../../type'
|
||||
import PlanComp from '../index'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
|
||||
let currentPath = '/billing'
|
||||
|
||||
@ -14,7 +14,8 @@ vi.mock('next/navigation', () => ({
|
||||
|
||||
const setShowAccountSettingModalMock = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
useModalContextSelector: (selector: any) => selector({
|
||||
setShowAccountSettingModal: setShowAccountSettingModalMock,
|
||||
}),
|
||||
}))
|
||||
@ -46,10 +47,11 @@ const verifyStateModalMock = vi.fn(props => (
|
||||
</div>
|
||||
))
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => verifyStateModalMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
@ -170,66 +172,6 @@ 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" />)
|
||||
@ -1,81 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@ -12,11 +12,13 @@ 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" />,
|
||||
@ -42,29 +44,37 @@ 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)
|
||||
})
|
||||
@ -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,10 +16,13 @@ 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')
|
||||
@ -27,19 +30,25 @@ 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', '')
|
||||
})
|
||||
})
|
||||
@ -1,39 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../header'
|
||||
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}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
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 structural elements with translation keys', () => {
|
||||
it('should render structure when translations are empty strings', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.title.plans': '',
|
||||
'billing.plansCommon.title.description': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
@ -1,24 +1,17 @@
|
||||
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">
|
||||
@ -27,6 +20,10 @@ vi.mock('next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
@ -39,6 +36,24 @@ 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,
|
||||
@ -52,6 +67,7 @@ const buildUsage = (): UsagePlanInfo => ({
|
||||
describe('Pricing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
mockLanguage = 'en'
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
@ -64,33 +80,42 @@ 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 allow switching categories and handle esc key', () => {
|
||||
it('should register esc key handler and allow switching categories', () => {
|
||||
// Arrange
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
// Assert
|
||||
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,36 @@
|
||||
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'
|
||||
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}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
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}
|
||||
@ -20,14 +40,17 @@ 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
|
||||
@ -38,13 +61,16 @@ 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}
|
||||
@ -54,12 +80,21 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render tabs with translation keys', () => {
|
||||
it('should render tabs when translation strings are empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'plansCommon.cloud': '',
|
||||
'plansCommon.self': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
@ -69,10 +104,11 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labels = container.querySelectorAll('span')
|
||||
expect(labels).toHaveLength(2)
|
||||
expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud')
|
||||
expect(labels[1]?.textContent).toBe('billing.plansCommon.self')
|
||||
expect(labels[0]?.textContent).toBe('')
|
||||
expect(labels[1]?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,50 +1,86 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher'
|
||||
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}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
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()} />)
|
||||
|
||||
expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument()
|
||||
// Assert
|
||||
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 label with translation key and params', () => {
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
it('should render when the translation string is empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.annualBilling': '',
|
||||
}
|
||||
|
||||
const label = screen.getByText(/billing\.plansCommon\.annualBilling/)
|
||||
// Act
|
||||
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label.textContent).toContain('percent')
|
||||
expect(label?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,8 +11,10 @@ describe('PlanSwitcherTab', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render label and icon', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@ -23,13 +25,16 @@ 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
|
||||
@ -41,13 +46,16 @@ 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}
|
||||
@ -58,13 +66,16 @@ 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}
|
||||
@ -75,6 +86,7 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('')
|
||||
@ -1,12 +1,13 @@
|
||||
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}
|
||||
@ -17,6 +18,7 @@ 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()
|
||||
@ -26,6 +28,7 @@ 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}
|
||||
@ -36,8 +39,10 @@ describe('CloudPlanButton', () => {
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Start now/i })
|
||||
// Act
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
@ -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>,
|
||||
@ -66,6 +66,13 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
@ -75,13 +82,6 @@ beforeEach(() => {
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
@ -117,32 +117,6 @@ 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
|
||||
@ -218,128 +192,5 @@ describe('CloudPlanItem', () => {
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
|
||||
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L95: yearly subscription URL ('year' parameter)
|
||||
it('should fetch yearly subscription url when planRange is yearly', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L62-63: loading guard prevents double click
|
||||
it('should ignore second click while loading', async () => {
|
||||
// Make the first fetch hang until we resolve it
|
||||
let resolveFirst!: (v: { url: string }) => void
|
||||
mockFetchSubscriptionUrls.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveFirst = resolve }),
|
||||
)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
|
||||
// First click starts loading
|
||||
fireEvent.click(button)
|
||||
// Second click while loading should be ignored
|
||||
fireEvent.click(button)
|
||||
|
||||
// Resolve first request
|
||||
resolveFirst({ url: 'https://first.example' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
|
||||
it('should invoke onError when billing invoices returns empty url', async () => {
|
||||
mockBillingInvoices.mockResolvedValue({ url: '' })
|
||||
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
|
||||
try {
|
||||
await cb()
|
||||
}
|
||||
catch (e) {
|
||||
opts.onError?.(e as Error)
|
||||
}
|
||||
})
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
// The onError callback should have been passed to openAsyncWindow
|
||||
const callArgs = openWindow.mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('onError')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers monthly price display (L139 !isYear branch for price)
|
||||
it('should display monthly pricing without discount', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const teamPlan = ALL_PLANS[Plan.team]
|
||||
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
|
||||
// Should NOT show crossed-out yearly price
|
||||
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Item from '../index'
|
||||
import Item from './index'
|
||||
|
||||
describe('Item', () => {
|
||||
beforeEach(() => {
|
||||
@ -9,10 +9,13 @@ 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()
|
||||
})
|
||||
@ -21,21 +24,27 @@ 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()
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Tooltip from '../tooltip'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
describe('Tooltip', () => {
|
||||
beforeEach(() => {
|
||||
@ -9,20 +9,26 @@ 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()
|
||||
})
|
||||
})
|
||||
@ -30,6 +36,7 @@ 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
|
||||
@ -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
|
||||
@ -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')
|
||||
|
||||
@ -2,21 +2,30 @@ 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'
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`list-${plan}`}>
|
||||
List for
|
||||
{plan}
|
||||
</div>
|
||||
),
|
||||
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('../../../../../base/toast', () => ({
|
||||
vi.mock('../../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
@ -26,7 +35,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>,
|
||||
@ -54,12 +63,6 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
@ -67,7 +70,14 @@ 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} />)
|
||||
@ -75,7 +85,8 @@ 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.getByTestId('list-community')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show premium extras such as cloud provider notice', () => {
|
||||
@ -86,6 +97,7 @@ describe('SelfHostedPlanItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// CTA behavior for each plan
|
||||
describe('CTA interactions', () => {
|
||||
it('should show toast when non-manager tries to proceed', () => {
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
|
||||
@ -1,20 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -1,35 +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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,26 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,12 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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,12 +20,16 @@ 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()
|
||||
})
|
||||
})
|
||||
@ -33,10 +37,13 @@ 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')
|
||||
})
|
||||
@ -45,53 +52,54 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: tooltip content varies by priority level.
|
||||
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()
|
||||
@ -99,12 +107,15 @@ 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()
|
||||
@ -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)', () => {
|
||||
@ -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,7 +16,8 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr
|
||||
))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => planUpgradeModalMock(props),
|
||||
}))
|
||||
|
||||
describe('TriggerEventsLimitModal', () => {
|
||||
@ -65,53 +66,4 @@ 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)
|
||||
})
|
||||
})
|
||||
@ -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,117 +14,146 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Typed window accessor for gtag tracking tests
|
||||
const gtagWindow = window as unknown as Record<string, Mock | undefined>
|
||||
// Mock gtag for tracking tests
|
||||
let mockGtag: Mock | undefined
|
||||
|
||||
describe('UpgradeBtn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGtag = vi.fn()
|
||||
gtagWindow.gtag = mockGtag
|
||||
;(window as any).gtag = mockGtag
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete gtagWindow.gtag
|
||||
delete (window as any).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', () => {
|
||||
render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'custom.label.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.label\.key/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()
|
||||
})
|
||||
})
|
||||
@ -132,57 +161,72 @@ 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,
|
||||
@ -190,13 +234,16 @@ 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,
|
||||
@ -204,35 +251,44 @@ 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 gtagWindow.gtag
|
||||
delete (window as any).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', {
|
||||
@ -244,95 +300,121 @@ 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 labelKey with isShort - labelKey takes precedence', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
it('should handle empty string labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
// Assert - empty labelKey is falsy, so it falls back to default label
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).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', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override plain text
|
||||
expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isShort with custom labelKey', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override isShort behavior
|
||||
expect(screen.getByText(/custom\.short\.key/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}
|
||||
@ -341,16 +423,17 @@ describe('UpgradeBtn', () => {
|
||||
isShort
|
||||
onClick={handleClick}
|
||||
loc="test-loc"
|
||||
labelKey="triggerLimitModal.description"
|
||||
labelKey={'custom.all' as any}
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByText(/triggerLimitModal\.description/i)
|
||||
const badge = screen.getByText(/custom\.all/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc: 'test-loc',
|
||||
@ -361,9 +444,11 @@ 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')
|
||||
|
||||
@ -374,38 +459,47 @@ 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()
|
||||
})
|
||||
@ -414,25 +508,31 @@ 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', {
|
||||
@ -1,67 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
@ -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(<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()
|
||||
})
|
||||
})
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,26 +66,4 @@ 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()
|
||||
})
|
||||
})
|
||||
309
web/app/components/datasets/__tests__/chunk.spec.tsx
Normal file
309
web/app/components/datasets/__tests__/chunk.spec.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import type { QA } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkContainer, ChunkLabel, QAPreview } from '../chunk'
|
||||
|
||||
vi.mock('../../base/icons/src/public/knowledge', () => ({
|
||||
SelectionMod: (props: React.ComponentProps<'svg'>) => (
|
||||
<svg data-testid="selection-mod-icon" {...props} />
|
||||
),
|
||||
}))
|
||||
|
||||
function createQA(overrides: Partial<QA> = {}): QA {
|
||||
return {
|
||||
question: 'What is Dify?',
|
||||
answer: 'Dify is an open-source LLM app development platform.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChunkLabel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the label text', () => {
|
||||
render(<ChunkLabel label="Chunk #1" characterCount={100} />)
|
||||
|
||||
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the character count with unit', () => {
|
||||
render(<ChunkLabel label="Chunk #1" characterCount={256} />)
|
||||
|
||||
expect(screen.getByText('256 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the SelectionMod icon', () => {
|
||||
render(<ChunkLabel label="Chunk" characterCount={10} />)
|
||||
|
||||
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a middle dot separator between label and count', () => {
|
||||
render(<ChunkLabel label="Chunk" characterCount={10} />)
|
||||
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display zero character count', () => {
|
||||
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
|
||||
|
||||
expect(screen.getByText('0 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display large character counts', () => {
|
||||
render(<ChunkLabel label="Large" characterCount={999999} />)
|
||||
|
||||
expect(screen.getByText('999999 characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty label', () => {
|
||||
render(<ChunkLabel label="" characterCount={50} />)
|
||||
|
||||
expect(screen.getByText('50 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with special characters in label', () => {
|
||||
render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />)
|
||||
|
||||
expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for ChunkContainer - wraps ChunkLabel with children content area
|
||||
describe('ChunkContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render ChunkLabel with correct props', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk #1" characterCount={200}>
|
||||
Content here
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
|
||||
expect(screen.getByText('200 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children in the content area', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={50}>
|
||||
<p>Paragraph content</p>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Paragraph content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the SelectionMod icon via ChunkLabel', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>
|
||||
Content
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should have space-y-2 on the outer container', () => {
|
||||
const { container } = render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('space-y-2')
|
||||
})
|
||||
|
||||
it('should render children inside a styled content div', () => {
|
||||
render(
|
||||
<ChunkContainer label="Chunk" characterCount={10}>
|
||||
<span>Test child</span>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
const contentDiv = screen.getByText('Test child').parentElement
|
||||
expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without children', () => {
|
||||
const { container } = render(
|
||||
<ChunkContainer label="Empty" characterCount={0} />,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).toBeInTheDocument()
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Multi" characterCount={100}>
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with string children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Text" characterCount={5}>
|
||||
Plain text content
|
||||
</ChunkContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plain text content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for QAPreview - displays question and answer pair
|
||||
describe('QAPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the question text', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the answer text', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Q and A labels', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Structure', () => {
|
||||
it('should render Q label as a label element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const qLabel = screen.getByText('Q')
|
||||
expect(qLabel.tagName).toBe('LABEL')
|
||||
})
|
||||
|
||||
it('should render A label as a label element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const aLabel = screen.getByText('A')
|
||||
expect(aLabel.tagName).toBe('LABEL')
|
||||
})
|
||||
|
||||
it('should render question in a p element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const questionEl = screen.getByText(qa.question)
|
||||
expect(questionEl.tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should render answer in a p element', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const answerEl = screen.getByText(qa.answer)
|
||||
expect(answerEl.tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should have the outer container with flex column layout', () => {
|
||||
const qa = createQA()
|
||||
const { container } = render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2')
|
||||
})
|
||||
|
||||
it('should apply text styling classes to question paragraph', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const questionEl = screen.getByText(qa.question)
|
||||
expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
|
||||
it('should apply text styling classes to answer paragraph', () => {
|
||||
const qa = createQA()
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const answerEl = screen.getByText(qa.answer)
|
||||
expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty question', () => {
|
||||
const qa = createQA({ question: '' })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty answer', () => {
|
||||
const qa = createQA({ answer: '' })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText(qa.question)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text', () => {
|
||||
const longText = 'x'.repeat(1000)
|
||||
const qa = createQA({ question: longText, answer: longText })
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
const elements = screen.getAllByText(longText)
|
||||
expect(elements).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render with special characters in question and answer', () => {
|
||||
const qa = createQA({
|
||||
question: 'What about <html> & "quotes"?',
|
||||
answer: 'It handles \'single\' & "double" quotes.',
|
||||
})
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText('What about <html> & "quotes"?')).toBeInTheDocument()
|
||||
expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiline text', () => {
|
||||
const qa = createQA({
|
||||
question: 'Line1\nLine2',
|
||||
answer: 'Answer1\nAnswer2',
|
||||
})
|
||||
render(<QAPreview qa={qa} />)
|
||||
|
||||
expect(screen.getByText(/Line1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Answer1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import DatasetsLoading from './loading'
|
||||
import DatasetsLoading from '../loading'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@ -1,13 +1,6 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import NoLinkedAppsPanel from './no-linked-apps-panel'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import NoLinkedAppsPanel from '../no-linked-apps-panel'
|
||||
|
||||
// Mock useDocLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@ -21,17 +14,17 @@ afterEach(() => {
|
||||
describe('NoLinkedAppsPanel', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the empty tip text', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the view doc link', () => {
|
||||
render(<NoLinkedAppsPanel />)
|
||||
expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.datasetMenus.viewDoc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link with correct href', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ApiIndex from './index'
|
||||
import ApiIndex from '../index'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@ -1,111 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('ChunkLabel', () => {
|
||||
it('should render label text', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render character count', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
|
||||
expect(screen.getByText('150 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator dot', () => {
|
||||
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with zero character count', () => {
|
||||
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
|
||||
expect(screen.getByText('0 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with large character count', () => {
|
||||
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
|
||||
expect(screen.getByText('999999 characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChunkContainer', () => {
|
||||
it('should render label and character count', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
|
||||
expect(screen.getByText('Container 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('200 characters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children content', () => {
|
||||
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<ChunkContainer label="Container" characterCount={100}>
|
||||
<div data-testid="child-div">
|
||||
<span>Nested content</span>
|
||||
</div>
|
||||
</ChunkContainer>,
|
||||
)
|
||||
expect(screen.getByTestId('child-div')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty children', () => {
|
||||
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('QAPreview', () => {
|
||||
const mockQA = {
|
||||
question: 'What is the meaning of life?',
|
||||
answer: 'The meaning of life is 42.',
|
||||
}
|
||||
|
||||
it('should render question text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render answer text', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Q label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render A label', () => {
|
||||
render(<QAPreview qa={mockQA} />)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty strings', () => {
|
||||
render(<QAPreview qa={{ question: '', answer: '' }} />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text', () => {
|
||||
const longQuestion = 'Q'.repeat(500)
|
||||
const longAnswer = 'A'.repeat(500)
|
||||
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
|
||||
expect(screen.getByText(longQuestion)).toBeInTheDocument()
|
||||
expect(screen.getByText(longAnswer)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with special characters', () => {
|
||||
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
|
||||
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
|
||||
expect(screen.getByText('& special chars!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model'
|
||||
import { ensureRerankModelSelected, isReRankModelSelected } from '../check-rerank-model'
|
||||
|
||||
// Test data factory
|
||||
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ChunkingModeLabel from './chunking-mode-label'
|
||||
import ChunkingModeLabel from '../chunking-mode-label'
|
||||
|
||||
describe('ChunkingModeLabel', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CredentialIcon } from './credential-icon'
|
||||
import { CredentialIcon } from '../credential-icon'
|
||||
|
||||
describe('CredentialIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DocumentFileIcon from './document-file-icon'
|
||||
import DocumentFileIcon from '../document-file-icon'
|
||||
|
||||
describe('DocumentFileIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -0,0 +1,49 @@
|
||||
import type { DocumentItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DocumentList from '../document-list'
|
||||
|
||||
vi.mock('../../document-file-icon', () => ({
|
||||
default: ({ name, extension }: { name?: string, extension?: string }) => (
|
||||
<span data-testid="file-icon">
|
||||
{name}
|
||||
.
|
||||
{extension}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DocumentList', () => {
|
||||
const mockList = [
|
||||
{ id: 'doc-1', name: 'report', extension: 'pdf' },
|
||||
{ id: 'doc-2', name: 'data', extension: 'csv' },
|
||||
] as DocumentItem[]
|
||||
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render all documents', () => {
|
||||
render(<DocumentList list={mockList} onChange={onChange} />)
|
||||
expect(screen.getByText('report')).toBeInTheDocument()
|
||||
expect(screen.getByText('data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file icons', () => {
|
||||
render(<DocumentList list={mockList} onChange={onChange} />)
|
||||
expect(screen.getAllByTestId('file-icon')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should call onChange with document on click', () => {
|
||||
render(<DocumentList list={mockList} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('report'))
|
||||
expect(onChange).toHaveBeenCalledWith(mockList[0])
|
||||
})
|
||||
|
||||
it('should render empty list without errors', () => {
|
||||
const { container } = render(<DocumentList list={[]} onChange={onChange} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { ChunkingMode, DataSourceType } from '@/models/datasets'
|
||||
import DocumentPicker from './index'
|
||||
import DocumentPicker from '../index'
|
||||
|
||||
// Mock portal-to-follow-elem - always render content for testing
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
@ -52,25 +52,6 @@ vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentList: mockUseDocumentList,
|
||||
}))
|
||||
|
||||
// Mock icons - mock all remixicon components used in the component tree
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <span data-testid="arrow-icon">↓</span>,
|
||||
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
|
||||
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
|
||||
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
|
||||
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
|
||||
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
|
||||
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
|
||||
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
|
||||
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
|
||||
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
|
||||
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
|
||||
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
|
||||
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
|
||||
RiSearchLine: () => <span data-testid="search-icon">🔍</span>,
|
||||
RiCloseLine: () => <span data-testid="close-icon">✕</span>,
|
||||
}))
|
||||
|
||||
// Factory function to create mock SimpleDocumentDetail
|
||||
const createMockDocument = (overrides: Partial<SimpleDocumentDetail> = {}): SimpleDocumentDetail => ({
|
||||
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
|
||||
@ -211,12 +192,6 @@ describe('DocumentPicker', () => {
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow icon', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render general mode label', () => {
|
||||
renderComponent({
|
||||
value: {
|
||||
@ -473,7 +448,7 @@ describe('DocumentPicker', () => {
|
||||
describe('Memoization Logic', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// React.memo components have a $$typeof property
|
||||
expect((DocumentPicker as any).$$typeof).toBeDefined()
|
||||
expect((DocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
|
||||
})
|
||||
|
||||
it('should compute parentModeLabel correctly with useMemo', () => {
|
||||
@ -952,7 +927,6 @@ describe('DocumentPicker', () => {
|
||||
|
||||
renderComponent({ onChange })
|
||||
|
||||
// Click on a document in the list
|
||||
fireEvent.click(screen.getByText('Document 2'))
|
||||
|
||||
// handleChange should find the document and call onChange with full document
|
||||
@ -1026,8 +1000,9 @@ describe('DocumentPicker', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// FileIcon should be rendered via DocumentFileIcon - pdf renders pdf icon
|
||||
expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument()
|
||||
// FileIcon should render an SVG icon for the file extension
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,20 +1,7 @@
|
||||
import type { DocumentItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PreviewDocumentPicker from './preview-document-picker'
|
||||
|
||||
// Override shared i18n mock for custom translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'preprocessDocument' && params?.num)
|
||||
return `${params.num} files`
|
||||
|
||||
const prefix = params?.ns ? `${params.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import PreviewDocumentPicker from '../preview-document-picker'
|
||||
|
||||
// Mock portal-to-follow-elem - always render content for testing
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
@ -45,23 +32,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowDownSLine: () => <span data-testid="arrow-icon">↓</span>,
|
||||
RiFile3Fill: () => <span data-testid="file-icon">📄</span>,
|
||||
RiFileCodeFill: () => <span data-testid="file-code-icon">📄</span>,
|
||||
RiFileExcelFill: () => <span data-testid="file-excel-icon">📄</span>,
|
||||
RiFileGifFill: () => <span data-testid="file-gif-icon">📄</span>,
|
||||
RiFileImageFill: () => <span data-testid="file-image-icon">📄</span>,
|
||||
RiFileMusicFill: () => <span data-testid="file-music-icon">📄</span>,
|
||||
RiFilePdf2Fill: () => <span data-testid="file-pdf-icon">📄</span>,
|
||||
RiFilePpt2Fill: () => <span data-testid="file-ppt-icon">📄</span>,
|
||||
RiFileTextFill: () => <span data-testid="file-text-icon">📄</span>,
|
||||
RiFileVideoFill: () => <span data-testid="file-video-icon">📄</span>,
|
||||
RiFileWordFill: () => <span data-testid="file-word-icon">📄</span>,
|
||||
RiMarkdownFill: () => <span data-testid="file-markdown-icon">📄</span>,
|
||||
}))
|
||||
|
||||
// Factory function to create mock DocumentItem
|
||||
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
|
||||
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
|
||||
@ -134,19 +104,14 @@ describe('PreviewDocumentPicker', () => {
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow icon', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file icon', () => {
|
||||
renderComponent({
|
||||
value: createMockDocumentItem({ extension: 'txt' }),
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('file-text-icon')).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pdf icon for pdf extension', () => {
|
||||
@ -155,7 +120,8 @@ describe('PreviewDocumentPicker', () => {
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('file-pdf-icon')).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -206,7 +172,8 @@ describe('PreviewDocumentPicker', () => {
|
||||
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('file-word-icon')).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -282,7 +249,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
// Tests for component memoization
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((PreviewDocumentPicker as any).$$typeof).toBeDefined()
|
||||
expect((PreviewDocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not re-render when props are the same', () => {
|
||||
@ -329,7 +296,6 @@ describe('PreviewDocumentPicker', () => {
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
|
||||
// Click on a document
|
||||
fireEvent.click(screen.getByText('Document 2'))
|
||||
|
||||
// handleChange should call onChange with the selected item
|
||||
@ -506,21 +472,16 @@ describe('PreviewDocumentPicker', () => {
|
||||
})
|
||||
|
||||
describe('extension variations', () => {
|
||||
const extensions = [
|
||||
{ ext: 'txt', icon: 'file-text-icon' },
|
||||
{ ext: 'pdf', icon: 'file-pdf-icon' },
|
||||
{ ext: 'docx', icon: 'file-word-icon' },
|
||||
{ ext: 'xlsx', icon: 'file-excel-icon' },
|
||||
{ ext: 'md', icon: 'file-markdown-icon' },
|
||||
]
|
||||
const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'md']
|
||||
|
||||
it.each(extensions)('should render correct icon for $ext extension', ({ ext, icon }) => {
|
||||
it.each(extensions)('should render icon for %s extension', (ext) => {
|
||||
renderComponent({
|
||||
value: createMockDocumentItem({ extension: ext }),
|
||||
files: [], // Use empty files to avoid duplicate icons
|
||||
})
|
||||
|
||||
expect(screen.getByTestId(icon)).toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -543,7 +504,6 @@ describe('PreviewDocumentPicker', () => {
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
|
||||
// Click on first document
|
||||
fireEvent.click(screen.getByText('Document 1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(files[0])
|
||||
@ -568,7 +528,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('3 files')).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -609,7 +569,6 @@ describe('PreviewDocumentPicker', () => {
|
||||
|
||||
renderComponent({ files, onChange })
|
||||
|
||||
// Click first document
|
||||
fireEvent.click(screen.getByText('Document 1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(files[0])
|
||||
@ -624,11 +583,9 @@ describe('PreviewDocumentPicker', () => {
|
||||
|
||||
renderComponent({ files: customFiles, onChange })
|
||||
|
||||
// Click on first custom file
|
||||
fireEvent.click(screen.getByText('Custom File 1'))
|
||||
expect(onChange).toHaveBeenCalledWith(customFiles[0])
|
||||
|
||||
// Click on second custom file
|
||||
fireEvent.click(screen.getByText('Custom File 2'))
|
||||
expect(onChange).toHaveBeenCalledWith(customFiles[1])
|
||||
})
|
||||
@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
|
||||
import AutoDisabledDocument from './auto-disabled-document'
|
||||
import AutoDisabledDocument from '../auto-disabled-document'
|
||||
|
||||
type AutoDisabledDocumentsResponse = { document_ids: string[] }
|
||||
|
||||
@ -15,7 +15,6 @@ const createMockQueryResult = (
|
||||
isLoading,
|
||||
}) as ReturnType<typeof useAutoDisabledDocuments>
|
||||
|
||||
// Mock service hooks
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockInvalidDisabledDocument = vi.fn()
|
||||
|
||||
@ -27,7 +26,6 @@ vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
@ -3,9 +3,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { retryErrorDocs } from '@/service/datasets'
|
||||
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
|
||||
import RetryButton from './index-failed'
|
||||
import RetryButton from '../index-failed'
|
||||
|
||||
// Mock service hooks
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StatusWithAction from './status-with-action'
|
||||
import StatusWithAction from '../status-with-action'
|
||||
|
||||
describe('StatusWithAction', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,9 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import EconomicalRetrievalMethodConfig from './index'
|
||||
import EconomicalRetrievalMethodConfig from '../index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../settings/option-card', () => ({
|
||||
vi.mock('../../../settings/option-card', () => ({
|
||||
default: ({ children, title, description, disabled, id }: {
|
||||
children?: React.ReactNode
|
||||
title?: string
|
||||
@ -18,7 +17,7 @@ vi.mock('../../settings/option-card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../retrieval-param-config', () => ({
|
||||
vi.mock('../../retrieval-param-config', () => ({
|
||||
default: ({ value, onChange, type }: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ImageList from './index'
|
||||
import ImageList from '../index'
|
||||
|
||||
// Track handleImageClick calls for testing
|
||||
type FileEntity = {
|
||||
@ -43,7 +43,7 @@ type ImageInfo = {
|
||||
}
|
||||
|
||||
// Mock ImagePreviewer since it uses createPortal
|
||||
vi.mock('../image-previewer', () => ({
|
||||
vi.mock('../../image-previewer', () => ({
|
||||
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
|
||||
<div data-testid="image-previewer">
|
||||
<span data-testid="preview-count">{images.length}</span>
|
||||
@ -132,7 +132,6 @@ describe('ImageList', () => {
|
||||
const images = createMockImages(15)
|
||||
render(<ImageList images={images} size="md" limit={9} />)
|
||||
|
||||
// Click More button
|
||||
const moreButton = screen.getByText(/\+6/)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
@ -182,7 +181,6 @@ describe('ImageList', () => {
|
||||
const images = createMockImages(3)
|
||||
const { rerender } = render(<ImageList images={images} size="md" />)
|
||||
|
||||
// Click first image to open preview
|
||||
const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
|
||||
fireEvent.click(firstThumb)
|
||||
|
||||
@ -197,7 +195,6 @@ describe('ImageList', () => {
|
||||
const newImages = createMockImages(2) // Only 2 images
|
||||
rerender(<ImageList images={newImages} size="md" />)
|
||||
|
||||
// Click on a thumbnail that exists
|
||||
const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
|
||||
fireEvent.click(validThumb)
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import More from './more'
|
||||
import More from '../more'
|
||||
|
||||
describe('More', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ImagePreviewer from './index'
|
||||
import ImagePreviewer from '../index'
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn()
|
||||
@ -12,7 +12,6 @@ const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
|
||||
globalThis.URL.revokeObjectURL = mockRevokeObjectURL
|
||||
globalThis.URL.createObjectURL = mockCreateObjectURL
|
||||
|
||||
// Mock Image
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
@ -294,7 +293,6 @@ describe('ImagePreviewer', () => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click prev button multiple times - should stay at first image
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const prevButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('left-8'),
|
||||
@ -325,7 +323,6 @@ describe('ImagePreviewer', () => {
|
||||
expect(screen.getByText('image3.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click next button multiple times - should stay at last image
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const nextButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('right-8'),
|
||||
@ -372,7 +369,6 @@ describe('ImagePreviewer', () => {
|
||||
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click retry button
|
||||
const retryButton = document.querySelector('button.rounded-full')
|
||||
if (retryButton) {
|
||||
await act(async () => {
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileEntity } from '../types'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
@ -6,7 +6,7 @@ import {
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
useFileStoreWithSelector,
|
||||
} from './store'
|
||||
} from '../store'
|
||||
|
||||
const createMockFile = (id: string): FileEntity => ({
|
||||
id,
|
||||
@ -1,12 +1,12 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
} from './constants'
|
||||
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from './utils'
|
||||
} from '../constants'
|
||||
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from '../utils'
|
||||
|
||||
describe('image-uploader utils', () => {
|
||||
describe('getFileType', () => {
|
||||
@ -1,13 +1,12 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { FileContextProvider } from '../store'
|
||||
import { useUpload } from './use-upload'
|
||||
import { FileContextProvider } from '../../store'
|
||||
import { useUpload } from '../use-upload'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
@ -1,9 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FileContextProvider } from '../store'
|
||||
import ImageInput from './image-input'
|
||||
import { FileContextProvider } from '../../store'
|
||||
import ImageInput from '../image-input'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageItem from './image-item'
|
||||
import ImageItem from '../image-item'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'test-id',
|
||||
@ -1,9 +1,8 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageUploaderInChunkWrapper from './index'
|
||||
import ImageUploaderInChunkWrapper from '../index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
@ -1,10 +1,9 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FileContextProvider } from '../store'
|
||||
import ImageInput from './image-input'
|
||||
import { FileContextProvider } from '../../store'
|
||||
import ImageInput from '../image-input'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageItem from './image-item'
|
||||
import ImageItem from '../image-item'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'test-id',
|
||||
@ -1,9 +1,8 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileEntity } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageUploaderInRetrievalTestingWrapper from './index'
|
||||
import ImageUploaderInRetrievalTestingWrapper from '../index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
@ -7,7 +7,7 @@ import {
|
||||
WeightedScoreEnum,
|
||||
} from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import RetrievalMethodConfig from './index'
|
||||
import RetrievalMethodConfig from '../index'
|
||||
|
||||
// Mock provider context with controllable supportRetrievalMethods
|
||||
let mockSupportRetrievalMethods: RETRIEVE_METHOD[] = [
|
||||
@ -37,7 +37,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
}))
|
||||
|
||||
// Mock child component RetrievalParamConfig to simplify testing
|
||||
vi.mock('../retrieval-param-config', () => ({
|
||||
vi.mock('../../retrieval-param-config', () => ({
|
||||
default: ({ type, value, onChange, showMultiModalTip }: {
|
||||
type: RETRIEVE_METHOD
|
||||
value: RetrievalConfig
|
||||
@ -585,7 +585,7 @@ describe('RetrievalMethodConfig', () => {
|
||||
// Verify the component is wrapped with React.memo by checking its displayName or type
|
||||
expect(RetrievalMethodConfig).toBeDefined()
|
||||
// React.memo components have a $$typeof property
|
||||
expect((RetrievalMethodConfig as any).$$typeof).toBeDefined()
|
||||
expect((RetrievalMethodConfig as unknown as { $$typeof: symbol }).$$typeof).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not re-render when props are the same', () => {
|
||||
@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from './index'
|
||||
import { retrievalIcon } from '../../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from '../index'
|
||||
|
||||
// Mock next/image
|
||||
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
|
||||
@ -24,7 +24,7 @@ vi.mock('@/app/components/base/radio-card', () => ({
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../create/icons', () => ({
|
||||
vi.mock('../../../create/icons', () => ({
|
||||
retrievalIcon: {
|
||||
vector: 'vector-icon.png',
|
||||
fullText: 'fulltext-icon.png',
|
||||
@ -2,13 +2,7 @@ import type { RetrievalConfig } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import RetrievalParamConfig from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import RetrievalParamConfig from '../index'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
@ -268,7 +262,7 @@ describe('RetrievalParamConfig', () => {
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'errorMsg.rerankModelRequired',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
})
|
||||
|
||||
@ -358,7 +352,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show multimodal tip when showMultiModalTip is false', () => {
|
||||
@ -372,7 +366,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -505,7 +499,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('weightedScore.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have RerankingModel option', () => {
|
||||
@ -517,7 +511,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show model selector when RerankingModel mode is selected', () => {
|
||||
@ -570,7 +564,7 @@ describe('RetrievalParamConfig', () => {
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
@ -589,7 +583,7 @@ describe('RetrievalParamConfig', () => {
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'modelProvider.rerankModel.key')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
@ -621,12 +615,12 @@ describe('RetrievalParamConfig', () => {
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'modelProvider.rerankModel.key')
|
||||
const rerankModelCard = radioCards.find(card => card.getAttribute('data-title') === 'common.modelProvider.rerankModel.key')
|
||||
fireEvent.click(rerankModelCard!)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'errorMsg.rerankModelRequired',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
})
|
||||
|
||||
@ -736,7 +730,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show multimodal tip for hybrid search with WeightedScore', () => {
|
||||
@ -764,7 +758,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render rerank switch for hybrid search', () => {
|
||||
@ -826,7 +820,7 @@ describe('RetrievalParamConfig', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -846,7 +840,7 @@ describe('RetrievalParamConfig', () => {
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
@ -880,7 +874,7 @@ describe('RetrievalParamConfig', () => {
|
||||
)
|
||||
|
||||
const radioCards = screen.getAllByTestId('radio-card')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'weightedScore.title')
|
||||
const weightedScoreCard = radioCards.find(card => card.getAttribute('data-title') === 'dataset.weightedScore.title')
|
||||
fireEvent.click(weightedScoreCard!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled()
|
||||
@ -1,19 +1,17 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Footer from './footer'
|
||||
import Footer from '../footer'
|
||||
|
||||
// Configurable mock for search params
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
// Mock service hook
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
@ -23,7 +21,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
let capturedActiveTab: string | undefined
|
||||
let capturedDslUrl: string | undefined
|
||||
|
||||
vi.mock('./create-options/create-from-dsl-modal', () => ({
|
||||
vi.mock('../create-options/create-from-dsl-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess, activeTab, dslUrl }: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
@ -48,9 +46,7 @@ vi.mock('./create-options/create-from-dsl-modal', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Footer Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
@ -60,9 +56,6 @@ describe('Footer', () => {
|
||||
capturedDslUrl = undefined
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Footer />)
|
||||
@ -88,9 +81,6 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal when import button is clicked', () => {
|
||||
render(<Footer />)
|
||||
@ -104,12 +94,10 @@ describe('Footer', () => {
|
||||
it('should close modal when onClose is called', () => {
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
@ -118,7 +106,6 @@ describe('Footer', () => {
|
||||
it('should call invalidDatasetList on success', () => {
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -130,9 +117,6 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<Footer />)
|
||||
@ -147,9 +131,6 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Footer />)
|
||||
@ -158,9 +139,7 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// URL Parameter Tests (Branch Coverage)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('URL Parameter Handling', () => {
|
||||
it('should set activeTab to FROM_URL when dslUrl is present', () => {
|
||||
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
|
||||
@ -193,12 +172,10 @@ describe('Footer', () => {
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
@ -210,11 +187,9 @@ describe('Footer', () => {
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Header from './header'
|
||||
import Header from '../header'
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Header />)
|
||||
@ -41,9 +36,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<Header />)
|
||||
@ -58,9 +50,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Header />)
|
||||
@ -1,35 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CreateFromPipeline from './index'
|
||||
import CreateFromPipeline from '../index'
|
||||
|
||||
// Mock child components to isolate testing
|
||||
vi.mock('./header', () => ({
|
||||
vi.mock('../header', () => ({
|
||||
default: () => <div data-testid="mock-header">Header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./list', () => ({
|
||||
vi.mock('../list', () => ({
|
||||
default: () => <div data-testid="mock-list">List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./footer', () => ({
|
||||
vi.mock('../footer', () => ({
|
||||
default: () => <div data-testid="mock-footer">Footer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../base/effect', () => ({
|
||||
vi.mock('../../../base/effect', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="mock-effect" className={className}>Effect</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// CreateFromPipeline Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CreateFromPipeline', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
@ -57,9 +52,6 @@ describe('CreateFromPipeline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
@ -86,9 +78,7 @@ describe('CreateFromPipeline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component Order Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Component Order', () => {
|
||||
it('should render components in correct order', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
@ -1,11 +1,9 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
import DSLConfirmModal from '../dsl-confirm-modal'
|
||||
|
||||
// ============================================================================
|
||||
// DSLConfirmModal Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('DSLConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
@ -17,9 +15,6 @@ describe('DSLConfirmModal', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
@ -50,9 +45,7 @@ describe('DSLConfirmModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Versions Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Versions Display', () => {
|
||||
it('should display imported version when provided', () => {
|
||||
render(
|
||||
@ -81,9 +74,6 @@ describe('DSLConfirmModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
@ -114,9 +104,7 @@ describe('DSLConfirmModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button State', () => {
|
||||
it('should enable confirm button by default', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
@ -140,9 +128,6 @@ describe('DSLConfirmModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have button container with proper styling', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
@ -1,11 +1,9 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Header from './header'
|
||||
import Header from '../header'
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
const defaultProps = {
|
||||
@ -16,9 +14,6 @@ describe('Header', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
@ -43,9 +38,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
@ -57,9 +49,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
@ -80,9 +69,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Header {...defaultProps} />)
|
||||
@ -1,13 +1,12 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
import Header from './header'
|
||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from './index'
|
||||
import Tab from './tab'
|
||||
import TabItem from './tab/item'
|
||||
import Uploader from './uploader'
|
||||
import DSLConfirmModal from '../dsl-confirm-modal'
|
||||
import Header from '../header'
|
||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
|
||||
import Tab from '../tab'
|
||||
import TabItem from '../tab/item'
|
||||
import Uploader from '../uploader'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
@ -15,7 +14,6 @@ vi.mock('next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
|
||||
@ -37,7 +35,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('use-context-selector', async () => {
|
||||
@ -48,7 +45,6 @@ vi.mock('use-context-selector', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Test data builders
|
||||
const createMockFile = (name = 'test.pipeline'): File => {
|
||||
return new File(['test content'], name, { type: 'application/octet-stream' })
|
||||
}
|
||||
@ -88,9 +84,6 @@ describe('CreateFromDSLModal', () => {
|
||||
mockHandleCheckPluginDependencies.mockReset()
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Rendering Tests
|
||||
// ============================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when show is true', () => {
|
||||
render(
|
||||
@ -172,9 +165,6 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Props Testing
|
||||
// ============================================
|
||||
describe('Props', () => {
|
||||
it('should use FROM_FILE as default activeTab', () => {
|
||||
render(
|
||||
@ -232,9 +222,6 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// State Management Tests
|
||||
// ============================================
|
||||
describe('State Management', () => {
|
||||
it('should switch between tabs', () => {
|
||||
render(
|
||||
@ -248,7 +235,6 @@ describe('CreateFromDSLModal', () => {
|
||||
// Initially file tab is active
|
||||
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
|
||||
|
||||
// Click URL tab
|
||||
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
|
||||
|
||||
// URL input should be visible
|
||||
@ -317,9 +303,7 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// API Call Tests
|
||||
// ============================================
|
||||
describe('API Calls', () => {
|
||||
it('should call importDSL with URL mode when URL tab is active', async () => {
|
||||
mockImportDSL.mockResolvedValue(createImportDSLResponse())
|
||||
@ -526,9 +510,7 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Event Handler Tests
|
||||
// ============================================
|
||||
describe('Event Handlers', () => {
|
||||
it('should call onClose when header close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
@ -638,7 +620,6 @@ describe('CreateFromDSLModal', () => {
|
||||
|
||||
const importButton = screen.getByText('app.newApp.import').closest('button')!
|
||||
|
||||
// Click multiple times rapidly
|
||||
fireEvent.click(importButton)
|
||||
fireEvent.click(importButton)
|
||||
fireEvent.click(importButton)
|
||||
@ -650,9 +631,6 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Memoization Tests
|
||||
// ============================================
|
||||
describe('Memoization', () => {
|
||||
it('should correctly compute buttonDisabled based on currentTab and file/URL', () => {
|
||||
render(
|
||||
@ -684,9 +662,6 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Edge Cases Tests
|
||||
// ============================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty URL gracefully', () => {
|
||||
render(
|
||||
@ -842,9 +817,7 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// File Import Tests (covers readFile, handleFile, file mode import)
|
||||
// ============================================
|
||||
describe('File Import', () => {
|
||||
it('should read file content when file is selected', async () => {
|
||||
mockImportDSL.mockResolvedValue(createImportDSLResponse())
|
||||
@ -877,7 +850,6 @@ describe('CreateFromDSLModal', () => {
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Click import button
|
||||
const importButton = screen.getByText('app.newApp.import').closest('button')!
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -927,9 +899,7 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// DSL Confirm Flow Tests (covers onDSLConfirm)
|
||||
// ============================================
|
||||
describe('DSL Confirm Flow', () => {
|
||||
it('should handle DSL confirm success', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
@ -978,7 +948,6 @@ describe('CreateFromDSLModal', () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
// Click confirm button in error modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
|
||||
})
|
||||
@ -1027,7 +996,6 @@ describe('CreateFromDSLModal', () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
// Click confirm - should return early since importId is empty
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('app.newApp.Confirm')).toBeInTheDocument()
|
||||
})
|
||||
@ -1163,7 +1131,6 @@ describe('CreateFromDSLModal', () => {
|
||||
// There are two Cancel buttons now (one in main modal footer, one in error modal)
|
||||
// Find the Cancel button in the error modal context
|
||||
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
|
||||
// Click the last Cancel button (the one in the error modal)
|
||||
fireEvent.click(cancelButtons[cancelButtons.length - 1])
|
||||
|
||||
vi.useRealTimers()
|
||||
@ -1171,9 +1138,7 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Header Component Tests
|
||||
// ============================================
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1206,9 +1171,7 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Tab Component Tests
|
||||
// ============================================
|
||||
describe('Tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1261,9 +1224,7 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Tab Item Component Tests
|
||||
// ============================================
|
||||
describe('TabItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1353,9 +1314,7 @@ describe('TabItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Uploader Component Tests
|
||||
// ============================================
|
||||
describe('Uploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1679,7 +1638,6 @@ describe('Uploader', () => {
|
||||
// After click, oncancel should be set
|
||||
})
|
||||
|
||||
// Click browse link to trigger selectHandle
|
||||
const browseLink = screen.getByText('app.dslUploader.browse')
|
||||
fireEvent.click(browseLink)
|
||||
|
||||
@ -1755,9 +1713,7 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// DSLConfirmModal Component Tests
|
||||
// ============================================
|
||||
describe('DSLConfirmModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1923,9 +1879,6 @@ describe('DSLConfirmModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Integration Tests
|
||||
// ============================================
|
||||
describe('CreateFromDSLModal Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -1958,7 +1911,6 @@ describe('CreateFromDSLModal Integration', () => {
|
||||
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/pipeline.yaml' } })
|
||||
|
||||
// Click import
|
||||
const importButton = screen.getByText('app.newApp.import').closest('button')!
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -1999,7 +1951,6 @@ describe('CreateFromDSLModal Integration', () => {
|
||||
const input = screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/old-pipeline.yaml' } })
|
||||
|
||||
// Click import
|
||||
const importButton = screen.getByText('app.newApp.import').closest('button')!
|
||||
fireEvent.click(importButton)
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Uploader from './uploader'
|
||||
import Uploader from '../uploader'
|
||||
|
||||
// Mock ToastContext
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: {
|
||||
@ -17,17 +16,11 @@ vi.mock('use-context-selector', () => ({
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockFile = (name = 'test.pipeline', _size = 1024): File => {
|
||||
return new File(['test content'], name, { type: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Uploader Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Uploader', () => {
|
||||
const defaultProps = {
|
||||
@ -39,9 +32,7 @@ describe('Uploader', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests - No File
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering - No File', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
@ -78,9 +69,7 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests - With File
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering - With File', () => {
|
||||
it('should render file name when file is provided', () => {
|
||||
const file = createMockFile('my-pipeline.pipeline')
|
||||
@ -109,9 +98,6 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should open file dialog when browse is clicked', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
@ -151,9 +137,7 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Custom className Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
|
||||
@ -168,9 +152,6 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} />)
|
||||
@ -192,9 +173,6 @@ describe('Uploader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Uploader {...defaultProps} />)
|
||||
@ -2,9 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CreateFromDSLModalTab, useDSLImport } from './use-dsl-import'
|
||||
import { CreateFromDSLModalTab, useDSLImport } from '../use-dsl-import'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
@ -12,7 +11,6 @@ vi.mock('next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
|
||||
@ -34,7 +32,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('use-context-selector', async () => {
|
||||
@ -45,7 +42,6 @@ vi.mock('use-context-selector', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Test data builders
|
||||
const createImportDSLResponse = (overrides = {}) => ({
|
||||
id: 'import-123',
|
||||
status: 'completed' as const,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user