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 6044d24f1d..5e01edd407 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 @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ConfigurationMethodEnum, + CurrentSystemQuotaTypeEnum, CustomConfigurationStatusEnum, PreferredProviderTypeEnum, } from '../declarations' @@ -121,7 +122,7 @@ describe('CredentialPanel', () => { expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument() }) - it('should show "Credits exhausted" for credits-exhausted variant', () => { + it('should show "Credits exhausted" for credits-exhausted variant (no credentials)', () => { mockTrialCredits.isExhausted = true mockTrialCredits.credits = 0 renderWithQueryClient(createProvider({ @@ -133,7 +134,7 @@ describe('CredentialPanel', () => { expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument() }) - it('should show "No available usage" for no-usage variant', () => { + it('should show "No available usage" for no-usage variant (exhausted + credential unauthorized)', () => { mockTrialCredits.isExhausted = true renderWithQueryClient(createProvider({ custom_configuration: { @@ -146,7 +147,7 @@ describe('CredentialPanel', () => { expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument() }) - it('should show "API key required" for api-required-add variant', () => { + it('should show "API key required" for api-required-add variant (custom priority, no credentials)', () => { renderWithQueryClient(createProvider({ preferred_provider_type: PreferredProviderTypeEnum.custom, custom_configuration: { @@ -156,21 +157,48 @@ describe('CredentialPanel', () => { })) expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() }) + + it('should show "API key required" for api-required-configure variant (custom priority, credential exists but name missing)', () => { + 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' }], + }, + })) + expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() + }) }) describe('Status label variants', () => { - it('should show green indicator and credential name for api-fallback', () => { + it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => { mockTrialCredits.isExhausted = true renderWithQueryClient(createProvider()) expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') expect(screen.getByText('test-credential')).toBeInTheDocument() }) - it('should show green indicator for api-active', () => { + it('should show warning icon for api-fallback variant', () => { + mockTrialCredits.isExhausted = true + const { container } = renderWithQueryClient(createProvider()) + expect(container.querySelector('.i-ri-error-warning-fill')).toBeTruthy() + }) + + it('should show green indicator for api-active (custom priority + authorized)', () => { renderWithQueryClient(createProvider({ preferred_provider_type: PreferredProviderTypeEnum.custom, })) expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green') + expect(screen.getByText('test-credential')).toBeInTheDocument() + }) + + it('should NOT show warning icon for api-active variant', () => { + const { container } = renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + })) + expect(container.querySelector('.i-ri-error-warning-fill')).toBeNull() }) it('should show red indicator and "Unavailable" for api-unavailable', () => { @@ -185,6 +213,7 @@ describe('CredentialPanel', () => { })) expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red') expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + expect(screen.getByText('Bad Key')).toBeInTheDocument() }) }) @@ -200,14 +229,71 @@ describe('CredentialPanel', () => { expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() }) + it('should apply destructive container for no-usage variant', () => { + mockTrialCredits.isExhausted = true + const { container } = renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: undefined, + available_credentials: [{ credential_id: 'cred-1' }], + }, + })) + expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() + }) + + it('should apply destructive container for api-unavailable variant', () => { + const { container } = renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + })) + expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy() + }) + it('should apply default container for credits-active', () => { const { container } = renderWithQueryClient(createProvider()) expect(container.querySelector('[class*="bg-white"]')).toBeTruthy() }) + + it('should apply default container for api-active', () => { + const { container } = renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + })) + expect(container.querySelector('[class*="bg-white"]')).toBeTruthy() + }) + + it('should apply default container for api-fallback', () => { + mockTrialCredits.isExhausted = true + const { container } = renderWithQueryClient(createProvider()) + expect(container.querySelector('[class*="bg-white"]')).toBeTruthy() + }) + }) + + describe('Text color', () => { + it('should use destructive text color for credits-exhausted label', () => { + mockTrialCredits.isExhausted = true + const { container } = renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + expect(container.querySelector('.text-text-destructive')).toBeTruthy() + }) + + it('should use secondary text color for credits-active label', () => { + const { container } = renderWithQueryClient(createProvider()) + expect(container.querySelector('.text-text-secondary')).toBeTruthy() + }) }) describe('Priority change', () => { - it('should call mutation and trigger side effects on success', async () => { + it('should call mutation with correct params on priority change', async () => { renderWithQueryClient(createProvider()) await act(async () => { @@ -220,6 +306,14 @@ describe('CredentialPanel', () => { body: { preferred_provider_type: 'custom' }, }) }) + }) + + it('should show success toast and refresh data after successful mutation', async () => { + renderWithQueryClient(createProvider()) + + await act(async () => { + fireEvent.click(screen.getByTestId('change-priority-btn')) + }) await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith( @@ -233,9 +327,92 @@ describe('CredentialPanel', () => { }) describe('ModelAuthDropdown integration', () => { - it('should pass state variant to ModelAuthDropdown', () => { + it('should pass credits-active variant to dropdown when credits available', () => { renderWithQueryClient(createProvider()) expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active') }) + + it('should pass api-fallback variant to dropdown when exhausted with valid key', () => { + mockTrialCredits.isExhausted = true + renderWithQueryClient(createProvider()) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback') + }) + + it('should pass credits-exhausted variant when exhausted with no credentials', () => { + mockTrialCredits.isExhausted = true + renderWithQueryClient(createProvider({ + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted') + }) + + it('should pass api-active variant for custom priority with authorized key', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active') + }) + + it('should pass api-required-add variant for custom priority with no credentials', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add') + }) + + it('should pass api-unavailable variant for custom priority with named but unauthorized key', () => { + renderWithQueryClient(createProvider({ + preferred_provider_type: PreferredProviderTypeEnum.custom, + custom_configuration: { + status: CustomConfigurationStatusEnum.active, + current_credential_id: undefined, + current_credential_name: 'Bad Key', + available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }], + }, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-unavailable') + }) + + it('should pass no-usage variant when exhausted + credential but unauthorized', () => { + 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.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage') + }) + }) + + describe('apiKeyOnly priority (system disabled)', () => { + it('should derive api-required-add when system config disabled and no credentials', () => { + renderWithQueryClient(createProvider({ + system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + preferred_provider_type: PreferredProviderTypeEnum.system, + custom_configuration: { + status: CustomConfigurationStatusEnum.noConfigure, + available_credentials: [], + }, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add') + expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument() + }) + + it('should derive api-active when system config disabled but has authorized key', () => { + renderWithQueryClient(createProvider({ + system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] }, + })) + expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active') + }) }) }) 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 index 80922f0c9f..b2c33d2f38 100644 --- 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 @@ -27,6 +27,22 @@ vi.mock('../../model-auth/hooks', () => ({ }), })) +vi.mock('../../model-auth/authorized/credential-item', () => ({ + default: ({ credential, onItemClick, onEdit, onDelete }: { + credential: { credential_id: string, credential_name: string } + onItemClick?: (c: unknown) => void + onEdit?: (c: unknown) => void + onDelete?: (c: unknown) => void + }) => ( +
+ {credential.credential_name} + + + +
+ ), +})) + const createProvider = (overrides: Partial = {}): ModelProvider => ({ provider: 'test', custom_configuration: { @@ -66,9 +82,8 @@ describe('DropdownContent', () => { mockDeleteCredentialId = null }) - // Conditional sections rendering - describe('Conditional sections', () => { - it('should show UsagePrioritySection when showPrioritySwitcher is true', () => { + describe('UsagePrioritySection visibility', () => { + it('should show when showPrioritySwitcher is true', () => { render( { onClose={onClose} />, ) - expect(screen.getByText(/usagePriority/)).toBeInTheDocument() }) - it('should hide UsagePrioritySection when showPrioritySwitcher is false', () => { + it('should hide when showPrioritySwitcher is false', () => { render( { onClose={onClose} />, ) - expect(screen.queryByText(/usagePriority/)).not.toBeInTheDocument() }) + }) - it('should show CreditsExhaustedAlert when credits exhausted and supports credits', () => { + describe('CreditsExhaustedAlert', () => { + it('should show when credits exhausted and supports credits', () => { render( { onClose={onClose} />, ) - expect(screen.getAllByText(/creditsExhausted/).length).toBeGreaterThan(0) }) - it('should hide CreditsExhaustedAlert when credits not exhausted', () => { + it('should hide when credits not exhausted', () => { render( { onClose={onClose} />, ) - expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument() }) + + it('should hide when credits exhausted but supportsCredits is false', () => { + render( + , + ) + expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument() + }) + + it('should show fallback message when api-fallback variant with exhausted credits', () => { + render( + , + ) + expect(screen.getAllByText(/creditsExhaustedFallback/).length).toBeGreaterThan(0) + }) + + it('should show non-fallback message when credits-exhausted variant', () => { + render( + , + ) + expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument() + }) + }) + + describe('CreditsFallbackAlert', () => { + it('should show when priority is apiKey, supports credits, not exhausted, and variant is not api-active', () => { + render( + , + ) + expect(screen.getByText(/noApiKeysFallback/)).toBeInTheDocument() + }) + + it('should show unavailable message when priority is apiKey with credentials but not api-active', () => { + render( + , + ) + expect(screen.getAllByText(/apiKeyUnavailableFallback/).length).toBeGreaterThan(0) + }) + + it('should NOT show when variant is api-active', () => { + render( + , + ) + expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument() + expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument() + }) + + it('should NOT show when priority is credits', () => { + render( + , + ) + expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument() + expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument() + }) }) - // API key section describe('API key section', () => { - it('should render credential items', () => { + it('should render all credential items', () => { render( { onClose={onClose} />, ) - expect(screen.getByText('My Key')).toBeInTheDocument() expect(screen.getByText('Other Key')).toBeInTheDocument() }) @@ -157,14 +296,69 @@ describe('DropdownContent', () => { onClose={onClose} />, ) - expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument() + expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument() + }) + + it('should call handleActiveCredential and close on credential item click', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('click-cred-1')) + + expect(mockHandleActiveCredential).toHaveBeenCalledWith( + expect.objectContaining({ credential_id: 'cred-1' }), + ) + expect(onClose).toHaveBeenCalled() + }) + + it('should call handleOpenModal and close on edit credential', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('edit-cred-2')) + + expect(mockHandleOpenModal).toHaveBeenCalledWith( + expect.objectContaining({ credential_id: 'cred-2' }), + ) + expect(onClose).toHaveBeenCalled() + }) + + it('should call openConfirmDelete on delete credential', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('delete-cred-2')) + + expect(mockOpenConfirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ credential_id: 'cred-2' }), + ) }) }) - // Add API Key action describe('Add API Key', () => { - it('should call handleOpenModal and onClose when adding API key', () => { + it('should call handleOpenModal with no args and close on add', () => { render( { }) }) - // Width constraint + describe('AlertDialog for delete confirmation', () => { + it('should show confirm dialog when deleteCredentialId is set', () => { + mockDeleteCredentialId = 'cred-1' + render( + , + ) + expect(screen.getByText(/confirmDelete/)).toBeInTheDocument() + }) + + it('should not show confirm dialog when deleteCredentialId is null', () => { + mockDeleteCredentialId = null + render( + , + ) + expect(screen.queryByText(/confirmDelete/)).not.toBeInTheDocument() + }) + }) + describe('Layout', () => { it('should have 320px width container', () => { const { container } = render( @@ -199,9 +422,7 @@ describe('DropdownContent', () => { onClose={onClose} />, ) - - const widthContainer = container.querySelector('.w-\\[320px\\]') - expect(widthContainer).toBeTruthy() + expect(container.querySelector('.w-\\[320px\\]')).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 index 07affed653..f56c737636 100644 --- 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 @@ -1,6 +1,6 @@ import type { ModelProvider } from '../../declarations' import type { CredentialPanelState } from '../use-credential-panel-state' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations' import ModelAuthDropdown from './index' @@ -16,7 +16,11 @@ vi.mock('../../model-auth/hooks', () => ({ }), })) -const createProvider = (): ModelProvider => ({ +vi.mock('../use-trial-credits', () => ({ + useTrialCredits: () => ({ credits: 0, isExhausted: true, isLoading: false }), +})) + +const createProvider = (overrides: Partial = {}): ModelProvider => ({ provider: 'test', custom_configuration: { status: CustomConfigurationStatusEnum.active, @@ -24,6 +28,7 @@ const createProvider = (): ModelProvider => ({ }, system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] }, preferred_provider_type: PreferredProviderTypeEnum.system, + ...overrides, } as unknown as ModelProvider) const createState = (overrides: Partial = {}): CredentialPanelState => ({ @@ -45,9 +50,8 @@ describe('ModelAuthDropdown', () => { vi.clearAllMocks() }) - // Button text based on variant - describe('Button configuration', () => { - it('should show "Add API Key" when no credentials and non-accent variant', () => { + describe('Button text', () => { + it('should show "Add API Key" when no credentials for credits-active', () => { render( { onChangePriority={onChangePriority} />, ) - expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() }) - it('should show "Configure" when has credentials and non-accent variant', () => { + it('should show "Configure" when has credentials for api-active', () => { render( { onChangePriority={onChangePriority} />, ) - - expect(screen.getByRole('button', { name: /config/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument() }) - it('should show "Add API Key" for api-required-add variant with accent style', () => { + it('should show "Add API Key" for api-required-add variant', () => { render( { onChangePriority={onChangePriority} />, ) - - const button = screen.getByRole('button', { name: /addApiKey/ }) - expect(button).toBeInTheDocument() + expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() }) - it('should show "Configure" for api-required-configure variant with accent style', () => { + it('should show "Configure" for api-required-configure variant', () => { render( { onChangePriority={onChangePriority} />, ) + expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument() + }) - expect(screen.getByRole('button', { name: /config/ })).toBeInTheDocument() + it('should show "Configure" for credits-active when has credentials', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument() + }) + + it('should show "Add API Key" for credits-exhausted (no credentials)', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument() + }) + + it('should show "Configure" for api-unavailable (has credentials)', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument() + }) + + it('should show "Configure" for api-fallback (has credentials)', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument() + }) + }) + + describe('Button variant styling', () => { + it('should use secondary-accent for api-required-add', () => { + const { container } = render( + , + ) + const button = container.querySelector('button') + expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/) + }) + + it('should use secondary-accent for api-required-configure', () => { + const { container } = render( + , + ) + const button = container.querySelector('button') + expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/) + }) + }) + + describe('Popover behavior', () => { + it('should open popover on button click and show dropdown content', async () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /config/i })) + + await waitFor(() => { + expect(screen.getByText('Key 1')).toBeInTheDocument() + }) }) }) })