mirror of
https://github.com/langgenius/dify.git
synced 2026-02-27 21:17:13 +08:00
test: add comprehensive tests for develop components including ApiServer, Code, and SecretKey functionalities
This commit is contained in:
220
web/app/components/develop/ApiServer.spec.tsx
Normal file
220
web/app/components/develop/ApiServer.spec.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import ApiServer from './ApiServer'
|
||||
|
||||
// Mock the secret-key-modal since it involves complex API interactions
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (
|
||||
isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>Close Modal</button></div> : null
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ApiServer', () => {
|
||||
const defaultProps = {
|
||||
apiBaseUrl: 'https://api.example.com',
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the API server label', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the API base URL', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the OK status badge', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the API key button', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CopyFeedback component', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
// CopyFeedback renders a button for copying
|
||||
const copyButtons = screen.getAllByRole('button')
|
||||
expect(copyButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with different API URLs', () => {
|
||||
it('should render localhost URL', () => {
|
||||
render(<ApiServer apiBaseUrl="http://localhost:3000/api" />)
|
||||
expect(screen.getByText('http://localhost:3000/api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render production URL', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" />)
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render URL with path', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.example.com/v1/chat" />)
|
||||
expect(screen.getByText('https://api.example.com/v1/chat')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with appId prop', () => {
|
||||
it('should render without appId', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.example.com" />)
|
||||
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with appId', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.example.com" appId="app-123" />)
|
||||
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretKeyButton interaction', () => {
|
||||
it('should open modal when API key button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiServer {...defaultProps} appId="app-123" />)
|
||||
|
||||
const apiKeyButton = screen.getByText('appApi.apiKey')
|
||||
await act(async () => {
|
||||
await user.click(apiKeyButton)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiServer {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Open modal
|
||||
const apiKeyButton = screen.getByText('appApi.apiKey')
|
||||
await act(async () => {
|
||||
await user.click(apiKeyButton)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByText('Close Modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have flex layout with wrap', () => {
|
||||
const { container } = render(<ApiServer {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('flex')
|
||||
expect(wrapper.className).toContain('flex-wrap')
|
||||
})
|
||||
|
||||
it('should have items-center alignment', () => {
|
||||
const { container } = render(<ApiServer {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('items-center')
|
||||
})
|
||||
|
||||
it('should have gap-y-2 for vertical spacing', () => {
|
||||
const { container } = render(<ApiServer {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('gap-y-2')
|
||||
})
|
||||
|
||||
it('should apply green styling to OK badge', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const okBadge = screen.getByText('appApi.ok')
|
||||
expect(okBadge.className).toContain('bg-[#ECFDF3]')
|
||||
expect(okBadge.className).toContain('text-[#039855]')
|
||||
})
|
||||
|
||||
it('should have border styling on URL container', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const urlText = screen.getByText('https://api.example.com')
|
||||
const urlContainer = urlText.closest('div[class*="rounded-lg"]')
|
||||
expect(urlContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API server label', () => {
|
||||
it('should have correct styling for label', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const label = screen.getByText('appApi.apiServer')
|
||||
expect(label.className).toContain('rounded-md')
|
||||
expect(label.className).toContain('border')
|
||||
})
|
||||
|
||||
it('should have tertiary text color on label', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const label = screen.getByText('appApi.apiServer')
|
||||
expect(label.className).toContain('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL display', () => {
|
||||
it('should have truncate class for long URLs', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const urlText = screen.getByText('https://api.example.com')
|
||||
expect(urlText.className).toContain('truncate')
|
||||
})
|
||||
|
||||
it('should have font-medium class on URL', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const urlText = screen.getByText('https://api.example.com')
|
||||
expect(urlText.className).toContain('font-medium')
|
||||
})
|
||||
|
||||
it('should have secondary text color on URL', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const urlText = screen.getByText('https://api.example.com')
|
||||
expect(urlText.className).toContain('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('divider', () => {
|
||||
it('should render vertical divider between URL and copy button', () => {
|
||||
const { container } = render(<ApiServer {...defaultProps} />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct divider dimensions', () => {
|
||||
const { container } = render(<ApiServer {...defaultProps} />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider?.className).toContain('h-[14px]')
|
||||
expect(divider?.className).toContain('w-[1px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretKeyButton styling', () => {
|
||||
it('should have shrink-0 class to prevent shrinking', () => {
|
||||
render(<ApiServer {...defaultProps} appId="app-123" />)
|
||||
// The SecretKeyButton wraps a Button component
|
||||
const button = screen.getByRole('button', { name: /apiKey/i })
|
||||
// Check parent container has shrink-0
|
||||
const buttonContainer = button.closest('.shrink-0')
|
||||
expect(buttonContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have accessible button for API key', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /apiKey/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have multiple buttons (copy + API key)', () => {
|
||||
render(<ApiServer {...defaultProps} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
590
web/app/components/develop/code.spec.tsx
Normal file
590
web/app/components/develop/code.spec.tsx
Normal file
@ -0,0 +1,590 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Code, CodeGroup, Embed, Pre } from './code'
|
||||
|
||||
// Mock the clipboard utility
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('code.tsx components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Code', () => {
|
||||
it('should render children', () => {
|
||||
render(<Code>const x = 1</Code>)
|
||||
expect(screen.getByText('const x = 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as code element', () => {
|
||||
render(<Code>code snippet</Code>)
|
||||
const codeElement = screen.getByText('code snippet')
|
||||
expect(codeElement.tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('should pass through additional props', () => {
|
||||
render(<Code data-testid="custom-code" className="custom-class">snippet</Code>)
|
||||
const codeElement = screen.getByTestId('custom-code')
|
||||
expect(codeElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Code>
|
||||
<span>part1</span>
|
||||
<span>part2</span>
|
||||
</Code>,
|
||||
)
|
||||
expect(screen.getByText('part1')).toBeInTheDocument()
|
||||
expect(screen.getByText('part2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embed', () => {
|
||||
it('should render value prop', () => {
|
||||
render(<Embed value="embedded content">ignored children</Embed>)
|
||||
expect(screen.getByText('embedded content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as span element', () => {
|
||||
render(<Embed value="test value">children</Embed>)
|
||||
const span = screen.getByText('test value')
|
||||
expect(span.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('should pass through additional props', () => {
|
||||
render(<Embed value="content" data-testid="embed-test" className="embed-class">children</Embed>)
|
||||
const embed = screen.getByTestId('embed-test')
|
||||
expect(embed).toHaveClass('embed-class')
|
||||
})
|
||||
|
||||
it('should not render children, only value', () => {
|
||||
render(<Embed value="shown">hidden children</Embed>)
|
||||
expect(screen.getByText('shown')).toBeInTheDocument()
|
||||
expect(screen.queryByText('hidden children')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodeGroup', () => {
|
||||
describe('with string targetCode', () => {
|
||||
it('should render code from targetCode string', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="const hello = 'world'">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('const hello = \'world\'')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have shadow and rounded styles', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code here">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeGroup = container.querySelector('.shadow-md')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
expect(codeGroup).toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should have bg-zinc-900 background', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeGroup = container.querySelector('.bg-zinc-900')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with array targetCode', () => {
|
||||
it('should render single code example without tabs', () => {
|
||||
const examples = [{ code: 'single example' }]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('single example')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple code examples with tabs', () => {
|
||||
const examples = [
|
||||
{ title: 'JavaScript', code: 'console.log("js")' },
|
||||
{ title: 'Python', code: 'print("py")' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByRole('tab', { name: 'JavaScript' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Python' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show first tab content by default', () => {
|
||||
const examples = [
|
||||
{ title: 'Tab1', code: 'first content' },
|
||||
{ title: 'Tab2', code: 'second content' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('first content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch tabs on click', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const examples = [
|
||||
{ title: 'Tab1', code: 'first content' },
|
||||
{ title: 'Tab2', code: 'second content' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
|
||||
const tab2 = screen.getByRole('tab', { name: 'Tab2' })
|
||||
await act(async () => {
|
||||
await user.click(tab2)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('second content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should use "Code" as default title when title not provided', () => {
|
||||
const examples = [
|
||||
{ code: 'example 1' },
|
||||
{ code: 'example 2' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeTabs = screen.getAllByRole('tab', { name: 'Code' })
|
||||
expect(codeTabs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with title prop', () => {
|
||||
it('should render title in header', () => {
|
||||
render(
|
||||
<CodeGroup title="API Example" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('API Example')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title in h3 element', () => {
|
||||
render(
|
||||
<CodeGroup title="Example Title" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const h3 = screen.getByRole('heading', { level: 3 })
|
||||
expect(h3).toHaveTextContent('Example Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('with tag and label props', () => {
|
||||
it('should render tag in code panel header', () => {
|
||||
render(
|
||||
<CodeGroup tag="GET" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('GET')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label in code panel header', () => {
|
||||
render(
|
||||
<CodeGroup label="/api/users" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('/api/users')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both tag and label with separator', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup tag="POST" label="/api/create" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('POST')).toBeInTheDocument()
|
||||
expect(screen.getByText('/api/create')).toBeInTheDocument()
|
||||
// Separator should be present
|
||||
const separator = container.querySelector('.rounded-full.bg-zinc-500')
|
||||
expect(separator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CopyButton functionality', () => {
|
||||
it('should render copy button', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="copyable code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const copyButton = screen.getByRole('button')
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Copy" text initially', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Copied!" after clicking copy button', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const { writeTextToClipboard } = await import('@/utils/clipboard')
|
||||
|
||||
render(
|
||||
<CodeGroup targetCode="code to copy">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(copyButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextToClipboard).toHaveBeenCalledWith('code to copy')
|
||||
})
|
||||
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset copy state after timeout', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(copyButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Advance time past the timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('without targetCode (using children)', () => {
|
||||
it('should render children when no targetCode provided', () => {
|
||||
render(
|
||||
<CodeGroup>
|
||||
<pre><code>child code content</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('child code content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have not-prose class to prevent prose styling', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeGroup = container.querySelector('.not-prose')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have my-6 margin', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeGroup = container.querySelector('.my-6')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have overflow-hidden', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const codeGroup = container.querySelector('.overflow-hidden')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre', () => {
|
||||
describe('when outside CodeGroup context', () => {
|
||||
it('should wrap children in CodeGroup', () => {
|
||||
const { container } = render(
|
||||
<Pre>
|
||||
<pre><code>code content</code></pre>
|
||||
</Pre>,
|
||||
)
|
||||
// Should render within a CodeGroup structure
|
||||
const codeGroup = container.querySelector('.bg-zinc-900')
|
||||
expect(codeGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass props to CodeGroup', () => {
|
||||
render(
|
||||
<Pre title="Pre Title">
|
||||
<pre><code>code</code></pre>
|
||||
</Pre>,
|
||||
)
|
||||
expect(screen.getByText('Pre Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when inside CodeGroup context (isGrouped)', () => {
|
||||
it('should return children directly without wrapping', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="outer code">
|
||||
<Pre>
|
||||
<code>inner code</code>
|
||||
</Pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// The outer code should be rendered (from targetCode)
|
||||
expect(screen.getByText('outer code')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodePanelHeader (via CodeGroup)', () => {
|
||||
it('should not render when neither tag nor label provided', () => {
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const headerDivider = container.querySelector('.border-b-white\\/7\\.5')
|
||||
expect(headerDivider).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when only tag is provided', () => {
|
||||
render(
|
||||
<CodeGroup tag="GET" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('GET')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when only label is provided', () => {
|
||||
render(
|
||||
<CodeGroup label="/api/endpoint" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('/api/endpoint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with font-mono styling', () => {
|
||||
render(
|
||||
<CodeGroup label="/api/test" targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const label = screen.getByText('/api/test')
|
||||
expect(label.className).toContain('font-mono')
|
||||
expect(label.className).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodeGroupHeader (via CodeGroup with multiple tabs)', () => {
|
||||
it('should render tab list for multiple examples', () => {
|
||||
const examples = [
|
||||
{ title: 'cURL', code: 'curl example' },
|
||||
{ title: 'Node.js', code: 'node example' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should style active tab differently', () => {
|
||||
const examples = [
|
||||
{ title: 'Active', code: 'active code' },
|
||||
{ title: 'Inactive', code: 'inactive code' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const activeTab = screen.getByRole('tab', { name: 'Active' })
|
||||
expect(activeTab.className).toContain('border-emerald-500')
|
||||
expect(activeTab.className).toContain('text-emerald-400')
|
||||
})
|
||||
|
||||
it('should have header background styling', () => {
|
||||
const examples = [
|
||||
{ title: 'Tab1', code: 'code1' },
|
||||
{ title: 'Tab2', code: 'code2' },
|
||||
]
|
||||
const { container } = render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const header = container.querySelector('.bg-zinc-800')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodePanel (via CodeGroup)', () => {
|
||||
it('should render code in pre element', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="pre content">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const preElement = screen.getByText('pre content').closest('pre')
|
||||
expect(preElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have text-white class on pre', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="white text">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const preElement = screen.getByText('white text').closest('pre')
|
||||
expect(preElement?.className).toContain('text-white')
|
||||
})
|
||||
|
||||
it('should have text-xs class on pre', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="small text">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const preElement = screen.getByText('small text').closest('pre')
|
||||
expect(preElement?.className).toContain('text-xs')
|
||||
})
|
||||
|
||||
it('should have overflow-x-auto on pre', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="scrollable">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const preElement = screen.getByText('scrollable').closest('pre')
|
||||
expect(preElement?.className).toContain('overflow-x-auto')
|
||||
})
|
||||
|
||||
it('should have p-4 padding on pre', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="padded">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const preElement = screen.getByText('padded').closest('pre')
|
||||
expect(preElement?.className).toContain('p-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClipboardIcon (via CopyButton in CodeGroup)', () => {
|
||||
it('should render clipboard icon in copy button', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="code">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
const copyButton = screen.getByRole('button')
|
||||
const svg = copyButton.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 20 20')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string targetCode', () => {
|
||||
render(
|
||||
<CodeGroup targetCode="">
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// Should render copy button even with empty code
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle targetCode with special characters', () => {
|
||||
const specialCode = '<div class="test">&</div>'
|
||||
render(
|
||||
<CodeGroup targetCode={specialCode}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText(specialCode)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiline targetCode', () => {
|
||||
const multilineCode = `line1
|
||||
line2
|
||||
line3`
|
||||
render(
|
||||
<CodeGroup targetCode={multilineCode}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
// Multiline code should be rendered - use a partial match
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/line2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/line3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle examples with tag property', () => {
|
||||
const examples = [
|
||||
{ title: 'Example', tag: 'v1', code: 'versioned code' },
|
||||
]
|
||||
render(
|
||||
<CodeGroup targetCode={examples}>
|
||||
<pre><code>fallback</code></pre>
|
||||
</CodeGroup>,
|
||||
)
|
||||
expect(screen.getByText('versioned code')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
339
web/app/components/develop/index.spec.tsx
Normal file
339
web/app/components/develop/index.spec.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import DevelopMain from './index'
|
||||
|
||||
// Mock the app store with a factory function to control state
|
||||
const mockAppDetailValue: { current: unknown } = { current: undefined }
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: unknown) => unknown) => {
|
||||
const state = { appDetail: mockAppDetailValue.current }
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the Doc component since it has complex dependencies
|
||||
vi.mock('@/app/components/develop/doc', () => ({
|
||||
default: ({ appDetail }: { appDetail: { name?: string } | null }) => (
|
||||
<div data-testid="doc-component">
|
||||
Doc Component -
|
||||
{appDetail?.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock the ApiServer component
|
||||
vi.mock('@/app/components/develop/ApiServer', () => ({
|
||||
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string, appId: string }) => (
|
||||
<div data-testid="api-server">
|
||||
API Server -
|
||||
{apiBaseUrl}
|
||||
{' '}
|
||||
-
|
||||
{appId}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DevelopMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppDetailValue.current = undefined
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading when appDetail is undefined', () => {
|
||||
mockAppDetailValue.current = undefined
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
|
||||
// Loading component renders with role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading when appDetail is null', () => {
|
||||
mockAppDetailValue.current = null
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have centered loading container', () => {
|
||||
mockAppDetailValue.current = undefined
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
|
||||
const loadingContainer = container.querySelector('.flex.h-full.items-center.justify-center')
|
||||
expect(loadingContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct background on loading state', () => {
|
||||
mockAppDetailValue.current = undefined
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
|
||||
const loadingContainer = container.querySelector('.bg-background-default')
|
||||
expect(loadingContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with appDetail loaded', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should render ApiServer component', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('api-server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass api_base_url to ApiServer', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('api-server')).toHaveTextContent('https://api.example.com/v1')
|
||||
})
|
||||
|
||||
it('should pass appId to ApiServer', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('api-server')).toHaveTextContent('app-123')
|
||||
})
|
||||
|
||||
it('should render Doc component', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('doc-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass appDetail to Doc component', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('doc-component')).toHaveTextContent('Test Application')
|
||||
})
|
||||
|
||||
it('should not show loading when appDetail exists', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout structure', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should have flex column layout', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.className).toContain('flex')
|
||||
expect(mainContainer.className).toContain('flex-col')
|
||||
})
|
||||
|
||||
it('should have relative positioning', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.className).toContain('relative')
|
||||
})
|
||||
|
||||
it('should have full height', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.className).toContain('h-full')
|
||||
})
|
||||
|
||||
it('should have overflow-hidden', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer.className).toContain('overflow-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('header section', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should have header with border', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.border-b')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have shrink-0 on header to prevent shrinking', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.shrink-0')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have horizontal padding on header', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.px-6')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have vertical padding on header', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.py-2')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have items centered in header', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.items-center')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have justify-between in header', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.justify-between')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('content section', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should have grow class for content area', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const content = container.querySelector('.grow')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have overflow-auto for content scrolling', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const content = container.querySelector('.overflow-auto')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have horizontal padding on content', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const content = container.querySelector('.px-4')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have vertical padding on content', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const content = container.querySelector('.py-4')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have responsive padding', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const content = container.querySelector('[class*="sm:px-10"]')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with different appIds', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-456',
|
||||
name: 'Another App',
|
||||
api_base_url: 'https://another-api.com',
|
||||
mode: 'completion',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should pass different appId to ApiServer', () => {
|
||||
render(<DevelopMain appId="app-456" />)
|
||||
expect(screen.getByTestId('api-server')).toHaveTextContent('app-456')
|
||||
})
|
||||
|
||||
it('should handle app with different api_base_url', () => {
|
||||
render(<DevelopMain appId="app-456" />)
|
||||
expect(screen.getByTestId('api-server')).toHaveTextContent('https://another-api.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state handling', () => {
|
||||
it('should handle appDetail with minimal properties', () => {
|
||||
mockAppDetailValue.current = {
|
||||
api_base_url: 'https://api.test.com',
|
||||
}
|
||||
render(<DevelopMain appId="app-minimal" />)
|
||||
expect(screen.getByTestId('api-server')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle appDetail with empty api_base_url', () => {
|
||||
mockAppDetailValue.current = {
|
||||
api_base_url: '',
|
||||
name: 'Empty URL App',
|
||||
}
|
||||
render(<DevelopMain appId="app-empty-url" />)
|
||||
expect(screen.getByTestId('api-server')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title element', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should have title div with correct styling', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const title = container.querySelector('.text-lg.font-medium.text-text-primary')
|
||||
expect(title).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty title div', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const title = container.querySelector('.text-lg.font-medium.text-text-primary')
|
||||
expect(title?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('border styling', () => {
|
||||
const mockAppDetail = {
|
||||
id: 'app-123',
|
||||
name: 'Test Application',
|
||||
api_base_url: 'https://api.example.com',
|
||||
mode: 'chat',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppDetailValue.current = mockAppDetail
|
||||
})
|
||||
|
||||
it('should have solid border style', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.border-solid')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have divider regular color on border', () => {
|
||||
const { container } = render(<DevelopMain appId="app-123" />)
|
||||
const header = container.querySelector('.border-b-divider-regular')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
655
web/app/components/develop/md.spec.tsx
Normal file
655
web/app/components/develop/md.spec.tsx
Normal file
@ -0,0 +1,655 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from './md'
|
||||
|
||||
describe('md.tsx components', () => {
|
||||
describe('Heading', () => {
|
||||
const defaultProps = {
|
||||
url: '/api/messages',
|
||||
method: 'GET' as const,
|
||||
title: 'Get Messages',
|
||||
name: '#get-messages',
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the method badge', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
expect(screen.getByText('GET')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the url', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
expect(screen.getByText('/api/messages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the title as a link', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const link = screen.getByRole('link', { name: 'Get Messages' })
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '#get-messages')
|
||||
})
|
||||
|
||||
it('should render an anchor span with correct id', () => {
|
||||
const { container } = render(<Heading {...defaultProps} />)
|
||||
const anchor = container.querySelector('#get-messages')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should strip # prefix from name for id', () => {
|
||||
const { container } = render(<Heading {...defaultProps} name="#with-hash" />)
|
||||
const anchor = container.querySelector('#with-hash')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('method styling', () => {
|
||||
it('should apply emerald styles for GET method', () => {
|
||||
render(<Heading {...defaultProps} method="GET" />)
|
||||
const badge = screen.getByText('GET')
|
||||
expect(badge.className).toContain('text-emerald')
|
||||
expect(badge.className).toContain('bg-emerald-400/10')
|
||||
expect(badge.className).toContain('ring-emerald-300')
|
||||
})
|
||||
|
||||
it('should apply sky styles for POST method', () => {
|
||||
render(<Heading {...defaultProps} method="POST" />)
|
||||
const badge = screen.getByText('POST')
|
||||
expect(badge.className).toContain('text-sky')
|
||||
expect(badge.className).toContain('bg-sky-400/10')
|
||||
expect(badge.className).toContain('ring-sky-300')
|
||||
})
|
||||
|
||||
it('should apply amber styles for PUT method', () => {
|
||||
render(<Heading {...defaultProps} method="PUT" />)
|
||||
const badge = screen.getByText('PUT')
|
||||
expect(badge.className).toContain('text-amber')
|
||||
expect(badge.className).toContain('bg-amber-400/10')
|
||||
expect(badge.className).toContain('ring-amber-300')
|
||||
})
|
||||
|
||||
it('should apply rose styles for DELETE method', () => {
|
||||
render(<Heading {...defaultProps} method="DELETE" />)
|
||||
const badge = screen.getByText('DELETE')
|
||||
expect(badge.className).toContain('text-red')
|
||||
expect(badge.className).toContain('bg-rose')
|
||||
expect(badge.className).toContain('ring-rose')
|
||||
})
|
||||
|
||||
it('should apply violet styles for PATCH method', () => {
|
||||
render(<Heading {...defaultProps} method="PATCH" />)
|
||||
const badge = screen.getByText('PATCH')
|
||||
expect(badge.className).toContain('text-violet')
|
||||
expect(badge.className).toContain('bg-violet-400/10')
|
||||
expect(badge.className).toContain('ring-violet-300')
|
||||
})
|
||||
})
|
||||
|
||||
describe('badge base styles', () => {
|
||||
it('should have rounded-lg class', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const badge = screen.getByText('GET')
|
||||
expect(badge.className).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('should have font-mono class', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const badge = screen.getByText('GET')
|
||||
expect(badge.className).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('should have font-semibold class', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const badge = screen.getByText('GET')
|
||||
expect(badge.className).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('should have ring-1 and ring-inset classes', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const badge = screen.getByText('GET')
|
||||
expect(badge.className).toContain('ring-1')
|
||||
expect(badge.className).toContain('ring-inset')
|
||||
})
|
||||
})
|
||||
|
||||
describe('url styles', () => {
|
||||
it('should have font-mono class on url', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const url = screen.getByText('/api/messages')
|
||||
expect(url.className).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('should have text-xs class on url', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const url = screen.getByText('/api/messages')
|
||||
expect(url.className).toContain('text-xs')
|
||||
})
|
||||
|
||||
it('should have zinc text color on url', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const url = screen.getByText('/api/messages')
|
||||
expect(url.className).toContain('text-zinc-400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('h2 element', () => {
|
||||
it('should render title inside h2', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
expect(h2).toBeInTheDocument()
|
||||
expect(h2).toHaveTextContent('Get Messages')
|
||||
})
|
||||
|
||||
it('should have scroll-mt-32 class on h2', () => {
|
||||
render(<Heading {...defaultProps} />)
|
||||
const h2 = screen.getByRole('heading', { level: 2 })
|
||||
expect(h2.className).toContain('scroll-mt-32')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Row', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Row anchor={false}>
|
||||
<div>Child 1</div>
|
||||
<div>Child 2</div>
|
||||
</Row>,
|
||||
)
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have grid layout', () => {
|
||||
const { container } = render(
|
||||
<Row anchor={false}>
|
||||
<div>Content</div>
|
||||
</Row>,
|
||||
)
|
||||
const row = container.firstChild as HTMLElement
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-1')
|
||||
})
|
||||
|
||||
it('should have gap classes', () => {
|
||||
const { container } = render(
|
||||
<Row anchor={false}>
|
||||
<div>Content</div>
|
||||
</Row>,
|
||||
)
|
||||
const row = container.firstChild as HTMLElement
|
||||
expect(row.className).toContain('gap-x-16')
|
||||
expect(row.className).toContain('gap-y-10')
|
||||
})
|
||||
|
||||
it('should have xl responsive classes', () => {
|
||||
const { container } = render(
|
||||
<Row anchor={false}>
|
||||
<div>Content</div>
|
||||
</Row>,
|
||||
)
|
||||
const row = container.firstChild as HTMLElement
|
||||
expect(row.className).toContain('xl:grid-cols-2')
|
||||
expect(row.className).toContain('xl:!max-w-none')
|
||||
})
|
||||
|
||||
it('should have items-start class', () => {
|
||||
const { container } = render(
|
||||
<Row anchor={false}>
|
||||
<div>Content</div>
|
||||
</Row>,
|
||||
)
|
||||
const row = container.firstChild as HTMLElement
|
||||
expect(row.className).toContain('items-start')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Col', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Col anchor={false} sticky={false}>
|
||||
<div>Column Content</div>
|
||||
</Col>,
|
||||
)
|
||||
expect(screen.getByText('Column Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have first/last child margin classes', () => {
|
||||
const { container } = render(
|
||||
<Col anchor={false} sticky={false}>
|
||||
<div>Content</div>
|
||||
</Col>,
|
||||
)
|
||||
const col = container.firstChild as HTMLElement
|
||||
expect(col.className).toContain('[&>:first-child]:mt-0')
|
||||
expect(col.className).toContain('[&>:last-child]:mb-0')
|
||||
})
|
||||
|
||||
it('should apply sticky classes when sticky is true', () => {
|
||||
const { container } = render(
|
||||
<Col anchor={false} sticky={true}>
|
||||
<div>Sticky Content</div>
|
||||
</Col>,
|
||||
)
|
||||
const col = container.firstChild as HTMLElement
|
||||
expect(col.className).toContain('xl:sticky')
|
||||
expect(col.className).toContain('xl:top-24')
|
||||
})
|
||||
|
||||
it('should not apply sticky classes when sticky is false', () => {
|
||||
const { container } = render(
|
||||
<Col anchor={false} sticky={false}>
|
||||
<div>Non-sticky Content</div>
|
||||
</Col>,
|
||||
)
|
||||
const col = container.firstChild as HTMLElement
|
||||
expect(col.className).not.toContain('xl:sticky')
|
||||
expect(col.className).not.toContain('xl:top-24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Properties', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property 1</li>
|
||||
<li>Property 2</li>
|
||||
</Properties>,
|
||||
)
|
||||
expect(screen.getByText('Property 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Property 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as ul with role list', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const list = screen.getByRole('list')
|
||||
expect(list).toBeInTheDocument()
|
||||
expect(list.tagName).toBe('UL')
|
||||
})
|
||||
|
||||
it('should have my-6 margin class', () => {
|
||||
const { container } = render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('my-6')
|
||||
})
|
||||
|
||||
it('should have list-none class on ul', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const list = screen.getByRole('list')
|
||||
expect(list.className).toContain('list-none')
|
||||
})
|
||||
|
||||
it('should have m-0 and p-0 classes on ul', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const list = screen.getByRole('list')
|
||||
expect(list.className).toContain('m-0')
|
||||
expect(list.className).toContain('p-0')
|
||||
})
|
||||
|
||||
it('should have divide-y class on ul', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const list = screen.getByRole('list')
|
||||
expect(list.className).toContain('divide-y')
|
||||
})
|
||||
|
||||
it('should have max-w constraint class', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<li>Property</li>
|
||||
</Properties>,
|
||||
)
|
||||
const list = screen.getByRole('list')
|
||||
expect(list.className).toContain('max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Property', () => {
|
||||
const defaultProps = {
|
||||
name: 'user_id',
|
||||
type: 'string',
|
||||
anchor: false,
|
||||
}
|
||||
|
||||
it('should render name in code element', () => {
|
||||
render(
|
||||
<Property {...defaultProps}>
|
||||
User identifier
|
||||
</Property>,
|
||||
)
|
||||
const code = screen.getByText('user_id')
|
||||
expect(code.tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('should render type', () => {
|
||||
render(
|
||||
<Property {...defaultProps}>
|
||||
User identifier
|
||||
</Property>,
|
||||
)
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children as description', () => {
|
||||
render(
|
||||
<Property {...defaultProps}>
|
||||
User identifier
|
||||
</Property>,
|
||||
)
|
||||
expect(screen.getByText('User identifier')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as li element', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
expect(container.querySelector('li')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have m-0 class on li', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('m-0')
|
||||
})
|
||||
|
||||
it('should have padding classes on li', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('px-0')
|
||||
expect(li.className).toContain('py-4')
|
||||
})
|
||||
|
||||
it('should have first:pt-0 and last:pb-0 classes', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('first:pt-0')
|
||||
expect(li.className).toContain('last:pb-0')
|
||||
})
|
||||
|
||||
it('should render dl element with proper structure', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
expect(container.querySelector('dl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have sr-only dt elements for accessibility', () => {
|
||||
const { container } = render(
|
||||
<Property {...defaultProps}>
|
||||
User identifier
|
||||
</Property>,
|
||||
)
|
||||
const dtElements = container.querySelectorAll('dt')
|
||||
expect(dtElements.length).toBe(3)
|
||||
dtElements.forEach((dt) => {
|
||||
expect(dt.className).toContain('sr-only')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have font-mono class on type', () => {
|
||||
render(
|
||||
<Property {...defaultProps}>
|
||||
Description
|
||||
</Property>,
|
||||
)
|
||||
const typeElement = screen.getByText('string')
|
||||
expect(typeElement.className).toContain('font-mono')
|
||||
expect(typeElement.className).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubProperty', () => {
|
||||
const defaultProps = {
|
||||
name: 'sub_field',
|
||||
type: 'number',
|
||||
anchor: false,
|
||||
}
|
||||
|
||||
it('should render name in code element', () => {
|
||||
render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Sub field description
|
||||
</SubProperty>,
|
||||
)
|
||||
const code = screen.getByText('sub_field')
|
||||
expect(code.tagName).toBe('CODE')
|
||||
})
|
||||
|
||||
it('should render type', () => {
|
||||
render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Sub field description
|
||||
</SubProperty>,
|
||||
)
|
||||
expect(screen.getByText('number')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children as description', () => {
|
||||
render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Sub field description
|
||||
</SubProperty>,
|
||||
)
|
||||
expect(screen.getByText('Sub field description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as li element', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
expect(container.querySelector('li')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have m-0 class on li', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('m-0')
|
||||
})
|
||||
|
||||
it('should have different padding than Property (py-1 vs py-4)', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('px-0')
|
||||
expect(li.className).toContain('py-1')
|
||||
})
|
||||
|
||||
it('should have last:pb-0 class', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('last:pb-0')
|
||||
})
|
||||
|
||||
it('should render dl element with proper structure', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
expect(container.querySelector('dl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have sr-only dt elements for accessibility', () => {
|
||||
const { container } = render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Sub field description
|
||||
</SubProperty>,
|
||||
)
|
||||
const dtElements = container.querySelectorAll('dt')
|
||||
expect(dtElements.length).toBe(3)
|
||||
dtElements.forEach((dt) => {
|
||||
expect(dt.className).toContain('sr-only')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have font-mono and text-xs on type', () => {
|
||||
render(
|
||||
<SubProperty {...defaultProps}>
|
||||
Description
|
||||
</SubProperty>,
|
||||
)
|
||||
const typeElement = screen.getByText('number')
|
||||
expect(typeElement.className).toContain('font-mono')
|
||||
expect(typeElement.className).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PropertyInstruction', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<PropertyInstruction>
|
||||
This is an instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
expect(screen.getByText('This is an instruction')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as li element', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction text
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
expect(container.querySelector('li')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have m-0 class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('m-0')
|
||||
})
|
||||
|
||||
it('should have padding classes', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('px-0')
|
||||
expect(li.className).toContain('py-4')
|
||||
})
|
||||
|
||||
it('should have italic class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('italic')
|
||||
})
|
||||
|
||||
it('should have first:pt-0 class', () => {
|
||||
const { container } = render(
|
||||
<PropertyInstruction>
|
||||
Instruction
|
||||
</PropertyInstruction>,
|
||||
)
|
||||
const li = container.querySelector('li')!
|
||||
expect(li.className).toContain('first:pt-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should render Property inside Properties', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<Property name="id" type="string" anchor={false}>
|
||||
Unique identifier
|
||||
</Property>
|
||||
<Property name="name" type="string" anchor={false}>
|
||||
Display name
|
||||
</Property>
|
||||
</Properties>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('id')).toBeInTheDocument()
|
||||
expect(screen.getByText('name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unique identifier')).toBeInTheDocument()
|
||||
expect(screen.getByText('Display name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Col inside Row', () => {
|
||||
render(
|
||||
<Row anchor={false}>
|
||||
<Col anchor={false} sticky={false}>
|
||||
<div>Left column</div>
|
||||
</Col>
|
||||
<Col anchor={false} sticky={true}>
|
||||
<div>Right column</div>
|
||||
</Col>
|
||||
</Row>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Left column')).toBeInTheDocument()
|
||||
expect(screen.getByText('Right column')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render PropertyInstruction inside Properties', () => {
|
||||
render(
|
||||
<Properties anchor={false}>
|
||||
<PropertyInstruction>
|
||||
Note: All fields are required
|
||||
</PropertyInstruction>
|
||||
<Property name="required_field" type="string" anchor={false}>
|
||||
A required field
|
||||
</Property>
|
||||
</Properties>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Note: All fields are required')).toBeInTheDocument()
|
||||
expect(screen.getByText('required_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
314
web/app/components/develop/secret-key/input-copy.spec.tsx
Normal file
314
web/app/components/develop/secret-key/input-copy.spec.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import InputCopy from './input-copy'
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
describe('InputCopy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the value', () => {
|
||||
render(<InputCopy value="test-api-key-12345" />)
|
||||
expect(screen.getByText('test-api-key-12345')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty value by default', () => {
|
||||
render(<InputCopy />)
|
||||
// Empty string should be rendered
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children when provided', () => {
|
||||
render(
|
||||
<InputCopy value="key">
|
||||
<span data-testid="custom-child">Custom Content</span>
|
||||
</InputCopy>,
|
||||
)
|
||||
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CopyFeedback component', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
// CopyFeedback should render a button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<InputCopy value="test" className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should have flex layout', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('flex')
|
||||
})
|
||||
|
||||
it('should have items-center alignment', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('items-center')
|
||||
})
|
||||
|
||||
it('should have rounded-lg class', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('should have background class', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('bg-components-input-bg-normal')
|
||||
})
|
||||
|
||||
it('should have hover state', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('hover:bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should have py-2 padding', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('py-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy functionality', () => {
|
||||
it('should copy value when clicked', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="copy-this-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('copy-this-value')
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
})
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('copy-this-value')
|
||||
})
|
||||
|
||||
it('should update copied state after clicking', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('test-value')
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
})
|
||||
|
||||
// Copy function should have been called
|
||||
expect(copy).toHaveBeenCalledWith('test-value')
|
||||
})
|
||||
|
||||
it('should reset copied state after timeout', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test-value" />)
|
||||
|
||||
const copyableArea = screen.getByText('test-value')
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
})
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('test-value')
|
||||
|
||||
// Advance time to reset the copied state
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
|
||||
// Component should still be functional
|
||||
expect(screen.getByText('test-value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip on value', () => {
|
||||
render(<InputCopy value="test-value" />)
|
||||
// Value should be wrapped in tooltip (tooltip shows on hover, not as visible text)
|
||||
const valueText = screen.getByText('test-value')
|
||||
expect(valueText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should render tooltip wrapper', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
expect(valueText).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor-pointer on clickable area', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
const clickableArea = valueText.closest('div[class*="cursor-pointer"]')
|
||||
expect(clickableArea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('divider', () => {
|
||||
it('should render vertical divider', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct divider dimensions', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider?.className).toContain('h-4')
|
||||
expect(divider?.className).toContain('w-px')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on divider', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const divider = container.querySelector('.bg-divider-regular')
|
||||
expect(divider?.className).toContain('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('value display', () => {
|
||||
it('should have truncate class for long values', () => {
|
||||
render(<InputCopy value="very-long-api-key-value-that-might-overflow" />)
|
||||
const valueText = screen.getByText('very-long-api-key-value-that-might-overflow')
|
||||
const container = valueText.closest('div[class*="truncate"]')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have text-secondary color on value', () => {
|
||||
render(<InputCopy value="test-value" />)
|
||||
const valueText = screen.getByText('test-value')
|
||||
expect(valueText.className).toContain('text-text-secondary')
|
||||
})
|
||||
|
||||
it('should have absolute positioning for overlay', () => {
|
||||
render(<InputCopy value="test" />)
|
||||
const valueText = screen.getByText('test')
|
||||
const container = valueText.closest('div[class*="absolute"]')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inner container', () => {
|
||||
it('should have grow class on inner container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const innerContainer = container.querySelector('.grow')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have h-5 height on inner container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const innerContainer = container.querySelector('.h-5')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with children', () => {
|
||||
it('should render children before value', () => {
|
||||
const { container } = render(
|
||||
<InputCopy value="key">
|
||||
<span data-testid="prefix">Prefix:</span>
|
||||
</InputCopy>,
|
||||
)
|
||||
const children = container.querySelector('[data-testid="prefix"]')
|
||||
expect(children).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both children and value', () => {
|
||||
render(
|
||||
<InputCopy value="api-key">
|
||||
<span>Label:</span>
|
||||
</InputCopy>,
|
||||
)
|
||||
expect(screen.getByText('Label:')).toBeInTheDocument()
|
||||
expect(screen.getByText('api-key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CopyFeedback section', () => {
|
||||
it('should have margin on CopyFeedback container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const copyFeedbackContainer = container.querySelector('.mx-1')
|
||||
expect(copyFeedbackContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative container', () => {
|
||||
it('should have relative positioning on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const relativeContainer = container.querySelector('.relative')
|
||||
expect(relativeContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have grow on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
// Find the relative container that also has grow
|
||||
const valueContainer = container.querySelector('.relative.grow')
|
||||
expect(valueContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have full height on value container', () => {
|
||||
const { container } = render(<InputCopy value="test" />)
|
||||
const valueContainer = container.querySelector('.relative.h-full')
|
||||
expect(valueContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined value', () => {
|
||||
render(<InputCopy value={undefined} />)
|
||||
// Should not crash
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
render(<InputCopy value="" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long values', () => {
|
||||
const longValue = 'a'.repeat(500)
|
||||
render(<InputCopy value={longValue} />)
|
||||
expect(screen.getByText(longValue)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in value', () => {
|
||||
const specialValue = 'key-with-special-chars!@#$%^&*()'
|
||||
render(<InputCopy value={specialValue} />)
|
||||
expect(screen.getByText(specialValue)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple clicks', () => {
|
||||
it('should handle multiple rapid clicks', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
render(<InputCopy value="test" />)
|
||||
|
||||
const copyableArea = screen.getByText('test')
|
||||
|
||||
// Click multiple times rapidly
|
||||
await act(async () => {
|
||||
await user.click(copyableArea)
|
||||
await user.click(copyableArea)
|
||||
await user.click(copyableArea)
|
||||
})
|
||||
|
||||
expect(copy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
300
web/app/components/develop/secret-key/secret-key-button.spec.tsx
Normal file
300
web/app/components/develop/secret-key/secret-key-button.spec.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyButton from './secret-key-button'
|
||||
|
||||
// Mock the SecretKeyModal since it has complex dependencies
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow, onClose, appId }: { isShow: boolean, onClose: () => void, appId?: string }) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="secret-key-modal">
|
||||
<span>
|
||||
Modal for
|
||||
{appId || 'no-app'}
|
||||
</span>
|
||||
<button onClick={onClose} data-testid="close-modal">Close</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
describe('SecretKeyButton', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render the button', () => {
|
||||
render(<SecretKeyButton />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the API key text', () => {
|
||||
render(<SecretKeyButton />)
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the key icon', () => {
|
||||
const { container } = render(<SecretKeyButton />)
|
||||
// RiKey2Line icon should be rendered as an svg
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show modal initially', () => {
|
||||
render(<SecretKeyButton />)
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('button interaction', () => {
|
||||
it('should open modal when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close modal when onClose is called', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
// Open modal
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle modal visibility', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
// Open
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
|
||||
// Open again
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<SecretKeyButton className="custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('should pass appId to modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton appId="app-123" />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Modal for app-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Modal for no-app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom textCls', () => {
|
||||
render(<SecretKeyButton textCls="custom-text-class" />)
|
||||
const text = screen.getByText('appApi.apiKey')
|
||||
expect(text.className).toContain('custom-text-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('button styling', () => {
|
||||
it('should have px-3 padding', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('px-3')
|
||||
})
|
||||
|
||||
it('should have small size', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('btn-small')
|
||||
})
|
||||
|
||||
it('should have ghost variant', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('btn-ghost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon styling', () => {
|
||||
it('should have icon container with flex layout', () => {
|
||||
const { container } = render(<SecretKeyButton />)
|
||||
const iconContainer = container.querySelector('.flex.items-center.justify-center')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct icon dimensions', () => {
|
||||
const { container } = render(<SecretKeyButton />)
|
||||
const iconContainer = container.querySelector('.h-3\\.5.w-3\\.5')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have tertiary text color on icon', () => {
|
||||
const { container } = render(<SecretKeyButton />)
|
||||
const icon = container.querySelector('.text-text-tertiary')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('text styling', () => {
|
||||
it('should have system-xs-medium class', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const text = screen.getByText('appApi.apiKey')
|
||||
expect(text.className).toContain('system-xs-medium')
|
||||
})
|
||||
|
||||
it('should have horizontal padding', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const text = screen.getByText('appApi.apiKey')
|
||||
expect(text.className).toContain('px-[3px]')
|
||||
})
|
||||
|
||||
it('should have tertiary text color', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const text = screen.getByText('appApi.apiKey')
|
||||
expect(text.className).toContain('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal props', () => {
|
||||
it('should pass isShow prop to modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
// Initially modal should not be visible
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
// Now modal should be visible
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass onClose callback to modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
await user.click(button)
|
||||
})
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
// Modal should be closed after clicking close
|
||||
expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have accessible button', () => {
|
||||
render(<SecretKeyButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be keyboard accessible', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyButton />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
button.focus()
|
||||
expect(document.activeElement).toBe(button)
|
||||
|
||||
// Press Enter to activate
|
||||
await act(async () => {
|
||||
await user.keyboard('{Enter}')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple instances', () => {
|
||||
it('should work independently when multiple instances exist', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<>
|
||||
<SecretKeyButton appId="app-1" />
|
||||
<SecretKeyButton appId="app-2" />
|
||||
</>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
// Click first button
|
||||
await act(async () => {
|
||||
await user.click(buttons[0])
|
||||
})
|
||||
|
||||
expect(screen.getByText('Modal for app-1')).toBeInTheDocument()
|
||||
|
||||
// Close first modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await act(async () => {
|
||||
await user.click(closeButton)
|
||||
})
|
||||
|
||||
// Click second button
|
||||
await act(async () => {
|
||||
await user.click(buttons[1])
|
||||
})
|
||||
|
||||
expect(screen.getByText('Modal for app-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,301 @@
|
||||
import type { CreateApiKeyResponse } from '@/models/app'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyGenerateModal from './secret-key-generate'
|
||||
|
||||
// Helper to create a valid CreateApiKeyResponse
|
||||
const createMockApiKey = (token: string): CreateApiKeyResponse => ({
|
||||
id: 'mock-id',
|
||||
token,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
describe('SecretKeyGenerateModal', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering when shown', () => {
|
||||
it('should render the modal when isShow is true', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the generate tips text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the OK button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.actionMsg.ok')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal, so query from document.body
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render InputCopy component', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token-123')} />)
|
||||
expect(screen.getByText('test-token-123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering when hidden', () => {
|
||||
it('should not render content when isShow is false', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} isShow={false} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('newKey prop', () => {
|
||||
it('should display the token when newKey is provided', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('sk-abc123xyz')} />)
|
||||
expect(screen.getByText('sk-abc123xyz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined newKey', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={undefined} />)
|
||||
// Should not crash and modal should still render
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newKey with empty token', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('')} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display long tokens correctly', () => {
|
||||
const longToken = `sk-${'a'.repeat(100)}`
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey(longToken)} />)
|
||||
expect(screen.getByText(longToken)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should call onClose when X icon is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
|
||||
// HeadlessUI Dialog may trigger onClose multiple times (icon click handler + dialog close)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when OK button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyGenerateModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
const okButton = screen.getByRole('button', { name: /ok/i })
|
||||
await act(async () => {
|
||||
await user.click(okButton)
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(
|
||||
<SecretKeyGenerateModal {...defaultProps} className="custom-modal-class" />,
|
||||
)
|
||||
// Modal renders via portal
|
||||
const modal = document.body.querySelector('.custom-modal-class')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply shrink-0 class', () => {
|
||||
render(
|
||||
<SecretKeyGenerateModal {...defaultProps} className="shrink-0" />,
|
||||
)
|
||||
// Modal renders via portal
|
||||
const modal = document.body.querySelector('.shrink-0')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal styling', () => {
|
||||
it('should have px-8 padding', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const modal = document.body.querySelector('.px-8')
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close icon styling', () => {
|
||||
it('should have cursor-pointer class on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct dimensions on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg[class*="h-6"][class*="w-6"]')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have tertiary text color on close icon', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg[class*="text-text-tertiary"]')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('header section', () => {
|
||||
it('should have flex justify-end on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
expect(closeContainer?.className).toContain('flex')
|
||||
expect(closeContainer?.className).toContain('justify-end')
|
||||
})
|
||||
|
||||
it('should have negative margin on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
expect(closeContainer?.className).toContain('-mr-2')
|
||||
expect(closeContainer?.className).toContain('-mt-6')
|
||||
})
|
||||
|
||||
it('should have bottom margin on close container', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
// Modal renders via portal
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
const closeContainer = closeIcon?.parentElement
|
||||
expect(closeContainer).toBeInTheDocument()
|
||||
expect(closeContainer?.className).toContain('mb-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tips text styling', () => {
|
||||
it('should have mt-1 margin on tips', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('mt-1')
|
||||
})
|
||||
|
||||
it('should have correct font size', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('text-[13px]')
|
||||
})
|
||||
|
||||
it('should have normal font weight', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('font-normal')
|
||||
})
|
||||
|
||||
it('should have leading-5 line height', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('leading-5')
|
||||
})
|
||||
|
||||
it('should have tertiary text color', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const tips = screen.getByText('appApi.apiKeyModal.generateTips')
|
||||
expect(tips.className).toContain('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InputCopy section', () => {
|
||||
it('should render InputCopy with token value', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test-token')} />)
|
||||
expect(screen.getByText('test-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have w-full class on InputCopy', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} newKey={createMockApiKey('test')} />)
|
||||
// The InputCopy component should have w-full
|
||||
const inputText = screen.getByText('test')
|
||||
const inputContainer = inputText.closest('.w-full')
|
||||
expect(inputContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OK button section', () => {
|
||||
it('should render OK button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have button container with flex layout', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
const container = button.parentElement
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(container?.className).toContain('flex')
|
||||
})
|
||||
|
||||
it('should have shrink-0 on button', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const button = screen.getByRole('button', { name: /ok/i })
|
||||
expect(button.className).toContain('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('button text styling', () => {
|
||||
it('should have text-xs font size on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('text-xs')
|
||||
})
|
||||
|
||||
it('should have font-medium on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('font-medium')
|
||||
})
|
||||
|
||||
it('should have secondary text color on button text', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
const buttonText = screen.getByText('appApi.actionMsg.ok')
|
||||
expect(buttonText.className).toContain('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default prop values', () => {
|
||||
it('should default isShow to false', () => {
|
||||
// When isShow is explicitly set to false
|
||||
render(<SecretKeyGenerateModal isShow={false} onClose={vi.fn()} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal title', () => {
|
||||
it('should display the correct title', () => {
|
||||
render(<SecretKeyGenerateModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
614
web/app/components/develop/secret-key/secret-key-modal.spec.tsx
Normal file
614
web/app/components/develop/secret-key/secret-key-modal.spec.tsx
Normal file
@ -0,0 +1,614 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyModal from './secret-key-modal'
|
||||
|
||||
// Mock the app context
|
||||
const mockCurrentWorkspace = vi.fn().mockReturnValue({
|
||||
id: 'workspace-1',
|
||||
name: 'Test Workspace',
|
||||
})
|
||||
const mockIsCurrentWorkspaceManager = vi.fn().mockReturnValue(true)
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn().mockReturnValue(true)
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: mockCurrentWorkspace(),
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the timestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`),
|
||||
formatDate: vi.fn((value: string, _format: string) => `Formatted: ${value}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API services
|
||||
const mockCreateAppApikey = vi.fn().mockResolvedValue({ token: 'new-app-token-123' })
|
||||
const mockDelAppApikey = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: (...args: unknown[]) => mockCreateAppApikey(...args),
|
||||
delApikey: (...args: unknown[]) => mockDelAppApikey(...args),
|
||||
}))
|
||||
|
||||
const mockCreateDatasetApikey = vi.fn().mockResolvedValue({ token: 'new-dataset-token-123' })
|
||||
const mockDelDatasetApikey = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: (...args: unknown[]) => mockCreateDatasetApikey(...args),
|
||||
delApikey: (...args: unknown[]) => mockDelDatasetApikey(...args),
|
||||
}))
|
||||
|
||||
// Mock React Query hooks for apps
|
||||
const mockAppApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsAppApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockInvalidateAppApiKeys = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: (_appId: string, _options: unknown) => ({
|
||||
data: mockAppApiKeysData(),
|
||||
isLoading: mockIsAppApiKeysLoading(),
|
||||
}),
|
||||
useInvalidateAppApiKeys: () => mockInvalidateAppApiKeys,
|
||||
}))
|
||||
|
||||
// Mock React Query hooks for datasets
|
||||
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockInvalidateDatasetApiKeys = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: (_options: unknown) => ({
|
||||
data: mockDatasetApiKeysData(),
|
||||
isLoading: mockIsDatasetApiKeysLoading(),
|
||||
}),
|
||||
useInvalidateDatasetApiKeys: () => mockInvalidateDatasetApiKeys,
|
||||
}))
|
||||
|
||||
describe('SecretKeyModal', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockAppApiKeysData.mockReturnValue({ data: [] })
|
||||
mockIsAppApiKeysLoading.mockReturnValue(false)
|
||||
mockDatasetApiKeysData.mockReturnValue({ data: [] })
|
||||
mockIsDatasetApiKeysLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
describe('rendering when shown', () => {
|
||||
it('should render the modal when isShow is true', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the tips text', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the create new key button', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal renders via portal, so we need to query from document.body
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering when hidden', () => {
|
||||
it('should not render content when isShow is false', () => {
|
||||
render(<SecretKeyModal {...defaultProps} isShow={false} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading when app API keys are loading', () => {
|
||||
mockIsAppApiKeysLoading.mockReturnValue(true)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading when dataset API keys are loading', () => {
|
||||
mockIsDatasetApiKeysLoading.mockReturnValue(true)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show loading when data is loaded', () => {
|
||||
mockIsAppApiKeysLoading.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API keys list for app', () => {
|
||||
const apiKeys = [
|
||||
{ id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
|
||||
{ id: 'key-2', token: 'sk-xyz987wvu654tsr321', created_at: 1700050000, last_used_at: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
})
|
||||
|
||||
it('should render API keys when available', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// Token 'sk-abc123def456ghi789' (21 chars) -> first 3 'sk-' + '...' + last 20 'k-abc123def456ghi789'
|
||||
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created time for keys', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('Formatted: 1700000000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render last used time for keys', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('Formatted: 1700100000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "never" for keys without last_used_at', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('appApi.never')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button for managers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// Delete button contains RiDeleteBinLine SVG - look for SVGs with h-4 w-4 class within buttons
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// There should be at least 3 buttons: copy feedback, delete, and create
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
// Check for delete icon SVG - Modal renders via portal
|
||||
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
|
||||
expect(deleteIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete button for non-managers', () => {
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
// The specific delete action button should not be present
|
||||
const actionButtons = screen.getAllByRole('button')
|
||||
// Should only have copy and create buttons, not delete
|
||||
expect(actionButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render table headers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
expect(screen.getByText('appApi.apiKeyModal.secretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.created')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.lastUsed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API keys list for dataset', () => {
|
||||
const datasetKeys = [
|
||||
{ id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
|
||||
})
|
||||
|
||||
it('should render dataset API keys when no appId', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Token 'dk-abc123def456ghi789' (21 chars) -> first 3 'dk-' + '...' + last 20 'k-abc123def456ghi789'
|
||||
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should call onClose when X icon is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<SecretKeyModal {...defaultProps} onClose={onClose} />)
|
||||
|
||||
// Modal renders via portal, so we need to query from document.body
|
||||
const closeIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(closeIcon!)
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create new key', () => {
|
||||
it('should call create API for app when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateAppApikey).toHaveBeenCalledWith({
|
||||
url: '/apps/app-123/api-keys',
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call create API for dataset when no appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDatasetApikey).toHaveBeenCalledWith({
|
||||
url: '/datasets/api-keys',
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show generate modal after creating key', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// The SecretKeyGenerateModal should be shown with the new token
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate app API keys after creating', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate dataset API keys after creating (no appId)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable create button when no workspace', () => {
|
||||
mockCurrentWorkspace.mockReturnValue(null)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
|
||||
expect(createButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable create button when not editor', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button')
|
||||
expect(createButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete key', () => {
|
||||
const apiKeys = [
|
||||
{ id: 'key-1', token: 'sk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
})
|
||||
|
||||
it('should render delete button for managers', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find buttons that contain SVG (delete/copy buttons)
|
||||
const actionButtons = screen.getAllByRole('button')
|
||||
// There should be at least copy, delete, and create buttons
|
||||
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should render API key row with actions', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Verify the truncated token is rendered
|
||||
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have action buttons in the key row', () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Check for action button containers - Modal renders via portal
|
||||
const actionContainers = document.body.querySelectorAll('[class*="space-x-2"]')
|
||||
expect(actionContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have delete button visible for managers', async () => {
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find the delete button by looking for the button with the delete icon
|
||||
const deleteIcon = document.body.querySelector('svg[class*="h-4"][class*="w-4"]')
|
||||
const deleteButton = deleteIcon?.closest('button')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show confirm dialog when delete button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find delete button by action-btn class (second action button after copy)
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
// The delete button is the second action button (first is copy)
|
||||
const deleteButton = actionButtons[1]
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Confirm dialog should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTips')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call delete API for app when confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the confirm button
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelAppApikey).toHaveBeenCalledWith({
|
||||
url: '/apps/app-123/api-keys/key-1',
|
||||
params: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate app API keys after deleting', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateAppApiKeys).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog and clear delKeyId when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByText('common.operation.cancel')
|
||||
await act(async () => {
|
||||
await user.click(cancelButton)
|
||||
})
|
||||
|
||||
// Confirm dialog should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.actionMsg.deleteConfirmTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Delete API should not be called
|
||||
expect(mockDelAppApikey).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete key for dataset', () => {
|
||||
const datasetKeys = [
|
||||
{ id: 'dk-1', token: 'dk-abc123def456ghi789', created_at: 1700000000, last_used_at: 1700100000 },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
mockDatasetApiKeysData.mockReturnValue({ data: datasetKeys })
|
||||
})
|
||||
|
||||
it('should call delete API for dataset when no appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelDatasetApikey).toHaveBeenCalledWith({
|
||||
url: '/datasets/api-keys/dk-1',
|
||||
params: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate dataset API keys after deleting', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
// Find and click delete button
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
})
|
||||
|
||||
// Wait for confirm dialog and click confirm
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidateDatasetApiKeys).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('token truncation', () => {
|
||||
it('should truncate token correctly', () => {
|
||||
const apiKeys = [
|
||||
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
|
||||
]
|
||||
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
|
||||
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Token format: first 3 chars + ... + last 20 chars
|
||||
// 'sk-abcdefghijklmnopqrstuvwxyz1234567890' -> 'sk-...qrstuvwxyz1234567890'
|
||||
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should render modal with expected structure', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal should render and contain the title
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create button with flex styling', () => {
|
||||
render(<SecretKeyModal {...defaultProps} />)
|
||||
// Modal renders via portal, so query from document.body
|
||||
const flexContainers = document.body.querySelectorAll('[class*="flex"]')
|
||||
expect(flexContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should not render table when no keys', () => {
|
||||
mockAppApiKeysData.mockReturnValue({ data: [] })
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render table when data is null', () => {
|
||||
mockAppApiKeysData.mockReturnValue(null)
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
expect(screen.queryByText('appApi.apiKeyModal.secretKey')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretKeyGenerateModal', () => {
|
||||
it('should close generate modal on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
|
||||
// Create a new key to open generate modal
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the close/OK button in generate modal
|
||||
const okButton = screen.getByText('appApi.actionMsg.ok')
|
||||
await act(async () => {
|
||||
await user.click(okButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.apiKeyModal.generateTips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
242
web/app/components/develop/tag.spec.tsx
Normal file
242
web/app/components/develop/tag.spec.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Tag } from './tag'
|
||||
|
||||
describe('Tag', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render children text', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
expect(screen.getByText('GET')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as a span element', () => {
|
||||
render(<Tag>POST</Tag>)
|
||||
const tag = screen.getByText('POST')
|
||||
expect(tag.tagName).toBe('SPAN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default color mapping based on HTTP methods', () => {
|
||||
it('should apply emerald color for GET method', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
it('should apply sky color for POST method', () => {
|
||||
render(<Tag>POST</Tag>)
|
||||
const tag = screen.getByText('POST')
|
||||
expect(tag.className).toContain('text-sky')
|
||||
})
|
||||
|
||||
it('should apply amber color for PUT method', () => {
|
||||
render(<Tag>PUT</Tag>)
|
||||
const tag = screen.getByText('PUT')
|
||||
expect(tag.className).toContain('text-amber')
|
||||
})
|
||||
|
||||
it('should apply rose color for DELETE method', () => {
|
||||
render(<Tag>DELETE</Tag>)
|
||||
const tag = screen.getByText('DELETE')
|
||||
expect(tag.className).toContain('text-red')
|
||||
})
|
||||
|
||||
it('should apply emerald color for unknown methods', () => {
|
||||
render(<Tag>UNKNOWN</Tag>)
|
||||
const tag = screen.getByText('UNKNOWN')
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
it('should handle lowercase method names', () => {
|
||||
render(<Tag>get</Tag>)
|
||||
const tag = screen.getByText('get')
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
it('should handle mixed case method names', () => {
|
||||
render(<Tag>Post</Tag>)
|
||||
const tag = screen.getByText('Post')
|
||||
expect(tag.className).toContain('text-sky')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom color prop', () => {
|
||||
it('should override default color with custom emerald color', () => {
|
||||
render(<Tag color="emerald">CUSTOM</Tag>)
|
||||
const tag = screen.getByText('CUSTOM')
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
it('should override default color with custom sky color', () => {
|
||||
render(<Tag color="sky">CUSTOM</Tag>)
|
||||
const tag = screen.getByText('CUSTOM')
|
||||
expect(tag.className).toContain('text-sky')
|
||||
})
|
||||
|
||||
it('should override default color with custom amber color', () => {
|
||||
render(<Tag color="amber">CUSTOM</Tag>)
|
||||
const tag = screen.getByText('CUSTOM')
|
||||
expect(tag.className).toContain('text-amber')
|
||||
})
|
||||
|
||||
it('should override default color with custom rose color', () => {
|
||||
render(<Tag color="rose">CUSTOM</Tag>)
|
||||
const tag = screen.getByText('CUSTOM')
|
||||
expect(tag.className).toContain('text-red')
|
||||
})
|
||||
|
||||
it('should override default color with custom zinc color', () => {
|
||||
render(<Tag color="zinc">CUSTOM</Tag>)
|
||||
const tag = screen.getByText('CUSTOM')
|
||||
expect(tag.className).toContain('text-zinc')
|
||||
})
|
||||
|
||||
it('should override automatic color mapping with explicit color', () => {
|
||||
render(<Tag color="sky">GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('text-sky')
|
||||
})
|
||||
})
|
||||
|
||||
describe('variant styles', () => {
|
||||
it('should apply medium variant styles by default', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('rounded-lg')
|
||||
expect(tag.className).toContain('px-1.5')
|
||||
expect(tag.className).toContain('ring-1')
|
||||
expect(tag.className).toContain('ring-inset')
|
||||
})
|
||||
|
||||
it('should apply small variant styles', () => {
|
||||
render(<Tag variant="small">GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
// Small variant should not have ring styles
|
||||
expect(tag.className).not.toContain('rounded-lg')
|
||||
expect(tag.className).not.toContain('ring-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base styles', () => {
|
||||
it('should always have font-mono class', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('should always have correct font-size class', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('text-[0.625rem]')
|
||||
})
|
||||
|
||||
it('should always have font-semibold class', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('should always have leading-6 class', () => {
|
||||
render(<Tag>GET</Tag>)
|
||||
const tag = screen.getByText('GET')
|
||||
expect(tag.className).toContain('leading-6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('color styles for medium variant', () => {
|
||||
it('should apply full emerald medium styles', () => {
|
||||
render(<Tag color="emerald" variant="medium">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('ring-emerald-300')
|
||||
expect(tag.className).toContain('bg-emerald-400/10')
|
||||
expect(tag.className).toContain('text-emerald-500')
|
||||
})
|
||||
|
||||
it('should apply full sky medium styles', () => {
|
||||
render(<Tag color="sky" variant="medium">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('ring-sky-300')
|
||||
expect(tag.className).toContain('bg-sky-400/10')
|
||||
expect(tag.className).toContain('text-sky-500')
|
||||
})
|
||||
|
||||
it('should apply full amber medium styles', () => {
|
||||
render(<Tag color="amber" variant="medium">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('ring-amber-300')
|
||||
expect(tag.className).toContain('bg-amber-400/10')
|
||||
expect(tag.className).toContain('text-amber-500')
|
||||
})
|
||||
|
||||
it('should apply full rose medium styles', () => {
|
||||
render(<Tag color="rose" variant="medium">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('ring-rose-200')
|
||||
expect(tag.className).toContain('bg-rose-50')
|
||||
expect(tag.className).toContain('text-red-500')
|
||||
})
|
||||
|
||||
it('should apply full zinc medium styles', () => {
|
||||
render(<Tag color="zinc" variant="medium">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('ring-zinc-200')
|
||||
expect(tag.className).toContain('bg-zinc-50')
|
||||
expect(tag.className).toContain('text-zinc-500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('color styles for small variant', () => {
|
||||
it('should apply emerald small styles', () => {
|
||||
render(<Tag color="emerald" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-emerald-500')
|
||||
// Small variant should not have background/ring styles
|
||||
expect(tag.className).not.toContain('bg-emerald-400/10')
|
||||
expect(tag.className).not.toContain('ring-emerald-300')
|
||||
})
|
||||
|
||||
it('should apply sky small styles', () => {
|
||||
render(<Tag color="sky" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-sky-500')
|
||||
})
|
||||
|
||||
it('should apply amber small styles', () => {
|
||||
render(<Tag color="amber" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-amber-500')
|
||||
})
|
||||
|
||||
it('should apply rose small styles', () => {
|
||||
render(<Tag color="rose" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-red-500')
|
||||
})
|
||||
|
||||
it('should apply zinc small styles', () => {
|
||||
render(<Tag color="zinc" variant="small">TEST</Tag>)
|
||||
const tag = screen.getByText('TEST')
|
||||
expect(tag.className).toContain('text-zinc-400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTTP method color combinations', () => {
|
||||
it('should correctly map PATCH to emerald (default)', () => {
|
||||
render(<Tag>PATCH</Tag>)
|
||||
const tag = screen.getByText('PATCH')
|
||||
// PATCH is not in the valueColorMap, so it defaults to emerald
|
||||
expect(tag.className).toContain('text-emerald')
|
||||
})
|
||||
|
||||
it('should correctly render all standard HTTP methods', () => {
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
const expectedColors = ['emerald', 'sky', 'amber', 'red']
|
||||
|
||||
methods.forEach((method, index) => {
|
||||
const { unmount } = render(<Tag>{method}</Tag>)
|
||||
const tag = screen.getByText(method)
|
||||
expect(tag.className).toContain(`text-${expectedColors[index]}`)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user