From 970493fa856f7eef2eb4dbfcea591e59fa77f2eb Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 5 Mar 2026 08:41:17 +0800 Subject: [PATCH] test(web): update tests for credential panel refactoring and new ModelAuthDropdown components Rewrite credential-panel.spec.tsx to match the new discriminated union state model and variant-driven rendering. Add new test files for useCredentialPanelState hook, SystemQuotaCard Label enhancement, and all ModelAuthDropdown sub-components. --- .../credential-panel.spec.tsx | 257 +++++++++++------- .../api-key-section.spec.tsx | 142 ++++++++++ .../credits-exhausted-alert.spec.tsx | 63 +++++ .../dropdown-content.spec.tsx | 199 ++++++++++++++ .../model-auth-dropdown/index.spec.tsx | 99 +++++++ .../usage-priority-section.spec.tsx | 66 +++++ .../system-quota-card.spec.tsx | 89 ++++++ .../use-credential-panel-state.spec.ts | 203 ++++++++++++++ 8 files changed, 1025 insertions(+), 93 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx index 28f6094ded..219f0e8a4e 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx @@ -2,74 +2,57 @@ import type { ModelProvider } from '../declarations' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { changeModelProviderPriority } from '@/service/common' -import { ConfigurationMethodEnum } from '../declarations' +import { + ConfigurationMethodEnum, + CustomConfigurationStatusEnum, + PreferredProviderTypeEnum, +} from '../declarations' import CredentialPanel from './credential-panel' const mockEventEmitter = { emit: vi.fn() } const mockNotify = vi.fn() const mockUpdateModelList = vi.fn() const mockUpdateModelProviders = vi.fn() -const mockCredentialStatus = { - hasCredential: true, - authorized: true, - authRemoved: false, - current_credential_name: 'test-credential', - notAllowedToUse: false, -} +const mockTrialCredits = { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined } vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() - return { - ...actual, - IS_CLOUD_EDITION: true, - } + return { ...actual, IS_CLOUD_EDITION: true } }) vi.mock('@/app/components/base/toast', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), + useToastContext: () => ({ notify: mockNotify }), })) vi.mock('@/context/event-emitter', () => ({ - useEventEmitterContextContext: () => ({ - eventEmitter: mockEventEmitter, - }), + useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) vi.mock('@/service/common', () => ({ changeModelProviderPriority: vi.fn(), })) -vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ - ConfigProvider: () =>
, -})) - -vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({ - useCredentialStatus: () => mockCredentialStatus, -})) - vi.mock('../hooks', () => ({ useUpdateModelList: () => mockUpdateModelList, useUpdateModelProviders: () => mockUpdateModelProviders, })) -vi.mock('./priority-selector', () => ({ - default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => ( - +vi.mock('./use-trial-credits', () => ({ + useTrialCredits: () => mockTrialCredits, +})) + +vi.mock('./model-auth-dropdown', () => ({ + default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => ( +
+ +
), })) -vi.mock('./priority-use-tip', () => ({ - default: () =>
Priority Tip
, -})) - vi.mock('@/app/components/header/indicator', () => ({ - default: ({ color }: { color: string }) =>
{color}
, + default: ({ color }: { color: string }) =>
, })) const createTestQueryClient = () => new QueryClient({ @@ -78,6 +61,22 @@ const createTestQueryClient = () => new QueryClient({ }, }) +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'test-provider', + provider_credential_schema: { credential_form_schemas: [] }, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'test-credential', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + supported_model_types: ['llm'], + ...overrides, +} as unknown as ModelProvider) + const renderWithQueryClient = (provider: ModelProvider) => { const queryClient = createTestQueryClient() return render( @@ -88,74 +87,146 @@ const renderWithQueryClient = (provider: ModelProvider) => { } describe('CredentialPanel', () => { - const mockProvider: ModelProvider = { - provider: 'test-provider', - provider_credential_schema: true, - custom_configuration: { status: 'active' }, - system_configuration: { enabled: true }, - preferred_provider_type: 'system', - configurate_methods: [ConfigurationMethodEnum.predefinedModel], - supported_model_types: ['gpt-4'], - } as unknown as ModelProvider - beforeEach(() => { vi.clearAllMocks() - Object.assign(mockCredentialStatus, { - hasCredential: true, - authorized: true, - authRemoved: false, - current_credential_name: 'test-credential', - notAllowedToUse: false, + Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false }) + }) + + // Text label variants + describe('Text label variants', () => { + it('should show "AI credits in use" for credits-active variant', () => { + renderWithQueryClient(createProvider()) + + expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument() + }) + + it('should show "Credits exhausted" for credits-exhausted variant', () => { + mockTrialCredits.isExhausted = true + mockTrialCredits.credits = 0 + + renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + + expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument() + }) + + it('should show "No available usage" for no-usage variant', () => { + mockTrialCredits.isExhausted = true + + renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + })) + + expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument() + }) + + it('should show "API key required" for api-required-add variant', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + + expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() }) }) - it('should show credential name and configuration actions', () => { - renderWithQueryClient(mockProvider) + // Status label variants (dot + credential name) + describe('Status label variants', () => { + it('should show green indicator and credential name for api-fallback', () => { + mockTrialCredits.isExhausted = true - expect(screen.getByText('test-credential')).toBeInTheDocument() - expect(screen.getByTestId('config-provider')).toBeInTheDocument() - expect(screen.getByTestId('priority-selector')).toBeInTheDocument() - }) + renderWithQueryClient(createProvider()) - it('should show unauthorized status label when credential is missing', () => { - mockCredentialStatus.hasCredential = false - renderWithQueryClient(mockProvider) + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') + expect(screen.getByText('test-credential')).toBeInTheDocument() + }) - expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument() - }) + it('should show green indicator for api-active', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + })) - it('should show removed credential label and priority tip for custom preference', () => { - mockCredentialStatus.authorized = false - mockCredentialStatus.authRemoved = true - renderWithQueryClient({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider) + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') + }) - expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument() - expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument() - }) + it('should show red indicator and "Unavailable" for api-unavailable', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + })) - it('should change priority and refresh related data after success', async () => { - const mockChangePriority = changeModelProviderPriority as ReturnType - mockChangePriority.mockResolvedValue({ result: 'success' }) - renderWithQueryClient(mockProvider) - - fireEvent.click(screen.getByTestId('priority-selector')) - - await waitFor(() => { - expect(mockChangePriority).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalled() - expect(mockUpdateModelProviders).toHaveBeenCalled() - expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4') - expect(mockEventEmitter.emit).toHaveBeenCalled() + expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red') + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() }) }) - it('should render standalone priority selector without provider schema', () => { - const providerNoSchema = { - ...mockProvider, - provider_credential_schema: null, - } as unknown as ModelProvider - renderWithQueryClient(providerNoSchema) - expect(screen.getByTestId('priority-selector')).toBeInTheDocument() - expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument() + // Destructive styling + describe('Destructive styling', () => { + it('should apply destructive container for credits-exhausted', () => { + mockTrialCredits.isExhausted = true + + const { container } = renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + + const card = container.querySelector('[class*="border-state-destructive"]') + expect(card).toBeTruthy() + }) + + it('should apply default container for credits-active', () => { + const { container } = renderWithQueryClient(createProvider()) + + const card = container.querySelector('[class*="bg-white"]') + expect(card).toBeTruthy() + }) + }) + + // Priority change + describe('Priority change', () => { + it('should change priority and refresh data after success', async () => { + const mockChangePriority = changeModelProviderPriority as ReturnType + mockChangePriority.mockResolvedValue({ result: 'success' }) + + renderWithQueryClient(createProvider()) + + fireEvent.click(screen.getByTestId('change-priority-btn')) + + await waitFor(() => { + expect(mockChangePriority).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalled() + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledWith('llm') + expect(mockEventEmitter.emit).toHaveBeenCalled() + }) + }) + }) + + // ModelAuthDropdown integration + describe('ModelAuthDropdown integration', () => { + it('should pass state variant to ModelAuthDropdown', () => { + renderWithQueryClient(createProvider()) + + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active') + }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.spec.tsx new file mode 100644 index 0000000000..95724df029 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.spec.tsx @@ -0,0 +1,142 @@ +import type { Credential, ModelProvider } from '../../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations' +import ApiKeySection from './api-key-section' + +const createCredential = (overrides: Partial = {}): Credential => ({ + credential_id: 'cred-1', + credential_name: 'Test API Key', + ...overrides, +}) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'test-provider', + allow_custom_token: true, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + available_credentials: [], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + ...overrides, +} as unknown as ModelProvider) + +describe('ApiKeySection', () => { + const handlers = { + onItemClick: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + onAdd: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Empty state + describe('Empty state (no credentials)', () => { + it('should show empty state message', () => { + render( + , + ) + + expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument() + expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument() + }) + + it('should show Add API Key button', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() + }) + + it('should call onAdd when Add API Key is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /addApiKey/ })) + + expect(handlers.onAdd).toHaveBeenCalledTimes(1) + }) + + it('should hide Add API Key button when allow_custom_token is false', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument() + }) + }) + + // With credentials + describe('With credentials', () => { + const credentials = [ + createCredential({ credential_id: 'cred-1', credential_name: 'Key Alpha' }), + createCredential({ credential_id: 'cred-2', credential_name: 'Key Beta' }), + ] + + it('should render credential list with header', () => { + render( + , + ) + + expect(screen.getByText(/apiKeys/)).toBeInTheDocument() + expect(screen.getByText('Key Alpha')).toBeInTheDocument() + expect(screen.getByText('Key Beta')).toBeInTheDocument() + }) + + it('should show Add API Key button in footer', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() + }) + + it('should hide Add API Key when allow_custom_token is false', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx new file mode 100644 index 0000000000..8447f69cd2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import CreditsExhaustedAlert from './credits-exhausted-alert' + +const mockTrialCredits = { credits: 0, isExhausted: true, isLoading: false, nextCreditResetDate: undefined } + +vi.mock('../use-trial-credits', () => ({ + useTrialCredits: () => mockTrialCredits, +})) + +describe('CreditsExhaustedAlert', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mockTrialCredits, { credits: 0 }) + }) + + // Without API key fallback + describe('Without API key fallback', () => { + it('should show exhausted message', () => { + render() + + expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument() + }) + + it('should show description with upgrade link', () => { + render() + + expect(screen.getByText(/creditsExhaustedDescription/)).toBeInTheDocument() + }) + }) + + // With API key fallback + describe('With API key fallback', () => { + it('should show fallback message', () => { + render() + + expect(screen.getByText(/creditsExhaustedFallback(?!Description)/)).toBeInTheDocument() + }) + + it('should show fallback description', () => { + render() + + expect(screen.getByText(/creditsExhaustedFallbackDescription/)).toBeInTheDocument() + }) + }) + + // Usage display + describe('Usage display', () => { + it('should show usage label', () => { + render() + + expect(screen.getByText(/usageLabel/)).toBeInTheDocument() + }) + + it('should show usage amounts', () => { + mockTrialCredits.credits = 200 + + render() + + expect(screen.getByText(/9,800/)).toBeInTheDocument() + expect(screen.getByText(/10,000/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx new file mode 100644 index 0000000000..43a27a6849 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/dropdown-content.spec.tsx @@ -0,0 +1,199 @@ +import type { CredentialPanelState } from '../use-credential-panel-state' +import type { ModelProvider } from '../../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations' +import DropdownContent from './dropdown-content' + +const mockHandleOpenModal = vi.fn() +const mockHandleActiveCredential = vi.fn() +const mockOpenConfirmDelete = vi.fn() +const mockCloseConfirmDelete = vi.fn() +const mockHandleConfirmDelete = vi.fn() +let mockDeleteCredentialId: string | null = null + +vi.mock('../use-trial-credits', () => ({ + useTrialCredits: () => ({ credits: 0, isExhausted: true, isLoading: false }), +})) + +vi.mock('../../model-auth/hooks', () => ({ + useAuth: () => ({ + openConfirmDelete: mockOpenConfirmDelete, + closeConfirmDelete: mockCloseConfirmDelete, + doingAction: false, + handleActiveCredential: mockHandleActiveCredential, + handleConfirmDelete: mockHandleConfirmDelete, + deleteCredentialId: mockDeleteCredentialId, + handleOpenModal: mockHandleOpenModal, + }), +})) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'test', + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'My Key', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'My Key' }, + { credential_id: 'cred-2', credential_name: 'Other Key' }, + ], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: ['predefined-model'], + supported_model_types: ['llm'], + ...overrides, +} as unknown as ModelProvider) + +const createState = (overrides: Partial = {}): CredentialPanelState => ({ + variant: 'api-active', + priority: 'apiKey', + supportsCredits: true, + showPrioritySwitcher: true, + hasCredentials: true, + isCreditsExhausted: false, + credentialName: 'My Key', + credits: 100, + ...overrides, +}) + +describe('DropdownContent', () => { + const onChangePriority = vi.fn() + const onClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockDeleteCredentialId = null + }) + + // Conditional sections rendering + describe('Conditional sections', () => { + it('should show UsagePrioritySection when showPrioritySwitcher is true', () => { + render( + , + ) + + expect(screen.getByText(/usagePriority/)).toBeInTheDocument() + }) + + it('should hide UsagePrioritySection when showPrioritySwitcher is false', () => { + render( + , + ) + + expect(screen.queryByText(/usagePriority/)).not.toBeInTheDocument() + }) + + it('should show CreditsExhaustedAlert when credits exhausted and supports credits', () => { + render( + , + ) + + expect(screen.getAllByText(/creditsExhausted/).length).toBeGreaterThan(0) + }) + + it('should hide CreditsExhaustedAlert when credits not exhausted', () => { + render( + , + ) + + expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument() + }) + }) + + // API key section + describe('API key section', () => { + it('should render credential items', () => { + render( + , + ) + + expect(screen.getByText('My Key')).toBeInTheDocument() + expect(screen.getByText('Other Key')).toBeInTheDocument() + }) + + it('should show empty state when no credentials', () => { + render( + , + ) + + expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument() + }) + }) + + // Add API Key action + describe('Add API Key', () => { + it('should call handleOpenModal and onClose when adding API key', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /addApiKey/ })) + + expect(mockHandleOpenModal).toHaveBeenCalledWith() + expect(onClose).toHaveBeenCalled() + }) + }) + + // Width constraint + describe('Layout', () => { + it('should have 320px width container', () => { + const { container } = render( + , + ) + + const widthContainer = container.querySelector('.w-\\[320px\\]') + expect(widthContainer).toBeTruthy() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx new file mode 100644 index 0000000000..cf132bd1c6 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/index.spec.tsx @@ -0,0 +1,99 @@ +import type { CredentialPanelState } from '../use-credential-panel-state' +import type { ModelProvider } from '../../declarations' +import { render, screen } from '@testing-library/react' +import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations' +import ModelAuthDropdown from './index' + +vi.mock('../../model-auth/hooks', () => ({ + useAuth: () => ({ + openConfirmDelete: vi.fn(), + closeConfirmDelete: vi.fn(), + doingAction: false, + handleActiveCredential: vi.fn(), + handleConfirmDelete: vi.fn(), + deleteCredentialId: null, + handleOpenModal: vi.fn(), + }), +})) + +const createProvider = (): ModelProvider => ({ + provider: 'test', + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + available_credentials: [], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, +} as unknown as ModelProvider) + +const createState = (overrides: Partial = {}): CredentialPanelState => ({ + variant: 'credits-active', + priority: 'credits', + supportsCredits: true, + showPrioritySwitcher: false, + hasCredentials: false, + isCreditsExhausted: false, + credentialName: undefined, + credits: 100, + ...overrides, +}) + +describe('ModelAuthDropdown', () => { + const onChangePriority = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Button text based on variant + describe('Button configuration', () => { + it('should show "Add API Key" when no credentials and non-accent variant', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() + }) + + it('should show "Configure" when has credentials and non-accent variant', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /config/ })).toBeInTheDocument() + }) + + it('should show "Add API Key" for api-required-add variant with accent style', () => { + render( + , + ) + + const button = screen.getByRole('button', { name: /addApiKey/ }) + expect(button).toBeInTheDocument() + }) + + it('should show "Configure" for api-required-configure variant with accent style', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /config/ })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.spec.tsx new file mode 100644 index 0000000000..9b5c4689e9 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.spec.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { PreferredProviderTypeEnum } from '../../declarations' +import UsagePrioritySection from './usage-priority-section' + +describe('UsagePrioritySection', () => { + const onSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering + describe('Rendering', () => { + it('should render title and both option buttons', () => { + render() + + expect(screen.getByText(/usagePriority/)).toBeInTheDocument() + expect(screen.getAllByRole('button')).toHaveLength(2) + }) + }) + + // Selection state + describe('Selection state', () => { + it('should highlight AI credits option when value is credits', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons[0].className).toContain('border-components-option-card-option-selected-border') + expect(buttons[1].className).not.toContain('border-components-option-card-option-selected-border') + }) + + it('should highlight API key option when value is apiKey', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons[0].className).not.toContain('border-components-option-card-option-selected-border') + expect(buttons[1].className).toContain('border-components-option-card-option-selected-border') + }) + + it('should highlight API key option when value is apiKeyOnly', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons[1].className).toContain('border-components-option-card-option-selected-border') + }) + }) + + // User interactions + describe('User interactions', () => { + it('should call onSelect with system when clicking AI credits option', () => { + render() + + fireEvent.click(screen.getAllByRole('button')[0]) + + expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system) + }) + + it('should call onSelect with custom when clicking API key option', () => { + render() + + fireEvent.click(screen.getAllByRole('button')[1]) + + expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom) + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.spec.tsx new file mode 100644 index 0000000000..9f0b963a7a --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/system-quota-card.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import SystemQuotaCard from './system-quota-card' + +describe('SystemQuotaCard', () => { + // Renders container with children + describe('Rendering', () => { + it('should render children', () => { + render( + + content + , + ) + + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('should apply default variant styles', () => { + const { container } = render( + + test + , + ) + + const card = container.firstElementChild! + expect(card.className).toContain('bg-white') + }) + + it('should apply destructive variant styles', () => { + const { container } = render( + + test + , + ) + + const card = container.firstElementChild! + expect(card.className).toContain('border-state-destructive-border') + }) + }) + + // Label sub-component + describe('Label', () => { + it('should apply default variant text color when no className provided', () => { + render( + + Default label + , + ) + + expect(screen.getByText('Default label').className).toContain('text-text-secondary') + }) + + it('should apply destructive variant text color when no className provided', () => { + render( + + Error label + , + ) + + expect(screen.getByText('Error label').className).toContain('text-text-destructive') + }) + + it('should override variant color with custom className', () => { + render( + + Custom label + , + ) + + const label = screen.getByText('Custom label') + expect(label.className).toContain('gap-1') + expect(label.className).not.toContain('text-text-destructive') + }) + }) + + // Actions sub-component + describe('Actions', () => { + it('should render action children', () => { + render( + + + + + , + ) + + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts new file mode 100644 index 0000000000..453fdc6c3a --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.spec.ts @@ -0,0 +1,203 @@ +import type { ModelProvider } from '../declarations' +import { renderHook } from '@testing-library/react' +import { + ConfigurationMethodEnum, + CustomConfigurationStatusEnum, + PreferredProviderTypeEnum, +} from '../declarations' +import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state' + +const mockTrialCredits = { credits: 100, isExhausted: false, isLoading: false, nextCreditResetDate: undefined } + +vi.mock('./use-trial-credits', () => ({ + useTrialCredits: () => mockTrialCredits, +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, IS_CLOUD_EDITION: true } +}) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ + provider: 'test-provider', + provider_credential_schema: { credential_form_schemas: [] }, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: 'cred-1', + current_credential_name: 'My Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }], + }, + system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + supported_model_types: ['llm'], + ...overrides, +} as unknown as ModelProvider) + +describe('useCredentialPanelState', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mockTrialCredits, { credits: 100, isExhausted: false, isLoading: false }) + }) + + // Credits priority variants + describe('Credits priority variants', () => { + it('should return credits-active when credits available', () => { + const { result } = renderHook(() => useCredentialPanelState(createProvider())) + + expect(result.current.variant).toBe('credits-active') + expect(result.current.priority).toBe('credits') + expect(result.current.supportsCredits).toBe(true) + }) + + it('should return api-fallback when credits exhausted but API key authorized', () => { + mockTrialCredits.isExhausted = true + mockTrialCredits.credits = 0 + + const { result } = renderHook(() => useCredentialPanelState(createProvider())) + + expect(result.current.variant).toBe('api-fallback') + }) + + it('should return no-usage when credits exhausted and API key unauthorized', () => { + mockTrialCredits.isExhausted = true + const provider = createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('no-usage') + }) + + it('should return credits-exhausted when credits exhausted and no credentials', () => { + mockTrialCredits.isExhausted = true + const provider = createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('credits-exhausted') + }) + }) + + // API key priority variants + describe('API key priority variants', () => { + it('should return api-active when API key authorized', () => { + const provider = createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('api-active') + expect(result.current.priority).toBe('apiKey') + }) + + it('should return api-unavailable when API key unauthorized', () => { + const provider = createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('api-unavailable') + }) + + it('should return api-required-add when no credentials exist', () => { + const provider = createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.variant).toBe('api-required-add') + }) + }) + + // apiKeyOnly priority + describe('apiKeyOnly priority (non-cloud / system disabled)', () => { + it('should return apiKeyOnly when system config disabled', () => { + const provider = createProvider({ + system_configuration: { enabled: false, current_quota_type: 'trial', quota_configurations: [] }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.priority).toBe('apiKeyOnly') + expect(result.current.supportsCredits).toBe(false) + }) + }) + + // Derived metadata + describe('Derived metadata', () => { + it('should show priority switcher when credits supported and custom config active', () => { + const provider = createProvider() + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.showPrioritySwitcher).toBe(true) + }) + + it('should hide priority switcher when custom config not active', () => { + const provider = createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + }) + + const { result } = renderHook(() => useCredentialPanelState(provider)) + + expect(result.current.showPrioritySwitcher).toBe(false) + }) + + it('should expose credential name from provider', () => { + const { result } = renderHook(() => useCredentialPanelState(createProvider())) + + expect(result.current.credentialName).toBe('My Key') + }) + + it('should expose credits amount', () => { + mockTrialCredits.credits = 500 + + const { result } = renderHook(() => useCredentialPanelState(createProvider())) + + expect(result.current.credits).toBe(500) + }) + }) +}) + +describe('isDestructiveVariant', () => { + it.each([ + ['credits-exhausted', true], + ['no-usage', true], + ['api-unavailable', true], + ['credits-active', false], + ['api-fallback', false], + ['api-active', false], + ['api-required-add', false], + ['api-required-configure', false], + ] as const)('should return %s for variant %s', (variant, expected) => { + expect(isDestructiveVariant(variant)).toBe(expected) + }) +})